package com.joshuapinter.RNUnifiedContacts; import android.app.Activity; import android.content.ContentResolver; import android.content.ContentUris; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.preference.PreferenceManager; import android.provider.ContactsContract; import android.provider.Settings; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; import android.text.format.DateFormat; import android.util.Base64; import android.Manifest; import com.facebook.react.bridge.ActivityEventListener; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.BaseActivityEventListener; import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; class RNUnifiedContactsModule extends ReactContextBaseJavaModule { private static final int ON_REQUEST_PERMISSIONS_RESULT_READ_CONTACTS = 0; private static final int ON_SELECT_CONTACT_RESULT = 1; private static Callback requestAccessToContactsCallback; private static Callback selectContactCallback; private ContentResolver contentResolver; private SharedPreferences sharedPreferences; public RNUnifiedContactsModule(ReactApplicationContext reactContext) { super(reactContext); sharedPreferences = PreferenceManager.getDefaultSharedPreferences( getReactApplicationContext() ); reactContext.addActivityEventListener( activityEventListener ); } @Override public String getName() { return "RNUnifiedContacts"; } @Override public Map<String, Object> getConstants() { final Map<String, Object> constants = new HashMap<>(); // constants.put(DURATION_SHORT_KEY, Toast.LENGTH_SHORT); // constants.put(DURATION_LONG_KEY, Toast.LENGTH_LONG); return constants; // @"phoneNumberLabel": @{ // @"HOME" : @"home", // @"WORK" : @"work", // @"MOBILE" : @"mobile", // @"IPHONE" : @"iPhone", // @"MAIN" : @"main", // @"HOME_FAX" : @"home fax", // @"WORK_FAX" : @"work fax", // @"PAGER" : @"pager", // @"OTHER" : @"other", // }, // @"emailAddressLabel": @{ // @"HOME" : @"home", // @"WORK" : @"work", // @"ICLOUD" : @"iCloud", // @"OTHER" : @"other", // }, } // TODO: This is no longer necessary because React Native has a PermissionsAndroid library that allows // us to just use Javascript for permissions management in Android. So, implement this in index.js. // @Deprecated @ReactMethod public void userCanAccessContacts( Callback callback ) { int userCanAccessContacts = ContextCompat.checkSelfPermission( getCurrentActivity(), Manifest.permission.READ_CONTACTS ); if ( userCanAccessContacts == PackageManager.PERMISSION_GRANTED ) { callback.invoke( true ); } else { callback.invoke( false ); } } @Deprecated @ReactMethod public void requestAccessToContacts( Callback callback ) { requestAccessToContactsCallback = callback; boolean canAccessContacts = ContextCompat.checkSelfPermission( getCurrentActivity(), Manifest.permission.READ_CONTACTS ) == PackageManager.PERMISSION_GRANTED; alreadyRequestedAccessToContacts( true ); // Set shared preferences so we know permissions have already been asked before. Note: This is the only way to properly capture when the User checksk "Don't ask again." if ( canAccessContacts ) { callback.invoke( true ); } else { ActivityCompat.requestPermissions( getCurrentActivity(), new String[]{ Manifest.permission.READ_CONTACTS }, ON_REQUEST_PERMISSIONS_RESULT_READ_CONTACTS ); } } @ReactMethod public void alreadyRequestedAccessToContacts( Callback callback ) { callback.invoke( alreadyRequestedAccessToContacts() ); } @ReactMethod public void openPrivacySettings() { Uri packageUri = Uri.fromParts( "package", getReactApplicationContext().getPackageName(), null ); Intent applicationDetailsSettingsIntent = new Intent(); applicationDetailsSettingsIntent.setAction( Settings.ACTION_APPLICATION_DETAILS_SETTINGS ); applicationDetailsSettingsIntent.setData( packageUri ); applicationDetailsSettingsIntent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK ); getReactApplicationContext().startActivity( applicationDetailsSettingsIntent ); } @ReactMethod public void getContacts(final Callback callback) { searchContacts(null, callback); } @ReactMethod public void searchContacts( String searchText, Callback callback ) { WritableArray contacts = Arguments.createArray(); Set<Integer> contactIds = new HashSet<Integer>(); contentResolver = getCurrentActivity().getContentResolver(); String whereString = null; String[] whereParams = null; if ( searchText != null && !searchText.equals( "" ) ) { whereString = "display_name LIKE ?"; whereParams = new String[]{ "%" + searchText + "%" }; } Cursor contactCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null ); while( contactCursor.moveToNext() ) { int contactId = getIntFromCursor( contactCursor, ContactsContract.Data.CONTACT_ID ); if ( contactIds.contains( contactId ) ) continue; WritableMap contact = getContactDetailsFromContactId(contactId); contacts.pushMap(contact); contactIds.add( contactId ); } contactCursor.close(); // ToDo: Add check for error and return error callback instead // i.e. callback.invoke(error, null) // Success callback.invoke(null, contacts); } @ReactMethod public void selectContact(Callback callback) { selectContactCallback = callback; Intent intent = new Intent( Intent.ACTION_PICK ); intent.setType( ContactsContract.Contacts.CONTENT_TYPE ); Activity currentActivity = getCurrentActivity(); if ( intent.resolveActivity(currentActivity.getPackageManager()) != null ) { currentActivity.startActivityForResult( intent, ON_SELECT_CONTACT_RESULT ); } } @ReactMethod public void openContact( String contactId ) { Intent intent = new Intent(Intent.ACTION_VIEW); Uri uri = Uri.withAppendedPath( ContactsContract.Contacts.CONTENT_URI, contactId ); intent.setData( uri ); getCurrentActivity().startActivity( intent ); } public static void onRequestPermissionsResult( int requestCode, String permissions[], int[] grantResults ) { switch( requestCode ) { case ON_REQUEST_PERMISSIONS_RESULT_READ_CONTACTS: if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { requestAccessToContactsCallback.invoke( true ); } else { requestAccessToContactsCallback.invoke( false ); } break; } } ////////////// // PRIVATE // ////////////// private final ActivityEventListener activityEventListener = new BaseActivityEventListener() { @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { switch( requestCode ) { case ON_SELECT_CONTACT_RESULT: if ( resultCode == 0 ) return; Uri contactUri = data.getData(); contentResolver = activity.getContentResolver(); Cursor contactCursor = contentResolver.query( contactUri, null, null, null, null ); contactCursor.moveToFirst(); int contactId = getIntFromCursor( contactCursor, ContactsContract.Contacts._ID ); WritableMap contact = getContactDetailsFromContactId( contactId ); selectContactCallback.invoke( null, contact ); break; } } }; private WritableMap getContactDetailsFromContactId(int contactId) { WritableMap contactMap = Arguments.createMap(); String contactIdAsString = Integer.toString(contactId); contactMap.putString( "identifier", contactIdAsString ); // TODO: Consider standardizing on "id" instead. contactMap.putInt( "id", contactId ); // Provided for Android devs used to getting it like this. Maybe _ID is necessary as well. WritableMap names = getNamesFromContact(contactId); contactMap.merge( names ); WritableMap thumbnail = getThumbnailFromContact(contactId); contactMap.merge( thumbnail ); WritableMap organization = getOrganizationFromContact(contactId); contactMap.merge( organization ); WritableArray phoneNumbers = getPhoneNumbersFromContact(contactId); contactMap.putArray( "phoneNumbers", phoneNumbers ); WritableArray emailAddresses = getEmailAddressesFromContact(contactId); contactMap.putArray( "emailAddresses", emailAddresses ); WritableArray postalAddresses = getPostalAddressesFromContact(contactId); contactMap.putArray( "postalAddresses", postalAddresses ); WritableMap birthday = getBirthdayFromContact(contactId); contactMap.putMap( "birthday", birthday ); // TODO: Instant Messenger entries. String note = getNoteFromContact(contactId); contactMap.putString( "note", note ); return contactMap; } @NonNull private WritableMap getNamesFromContact(int contactId) { WritableMap names = Arguments.createMap(); String whereString = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; String[] whereParams = new String[]{String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE }; Cursor namesCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null, null); if ( !namesCursor.moveToFirst() ) return names; String prefix = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.PREFIX ); String givenName = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME ); String middleName = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME ); String familyName = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME ); String suffix = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.SUFFIX ); String displayName = getStringFromCursor( namesCursor, ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME ); names.putString( "prefix", prefix ); names.putString( "givenName", givenName ); names.putString( "middleName", middleName ); names.putString( "familyName", familyName ); names.putString( "suffix", suffix ); names.putString( "displayName", displayName ); names.putString( "fullName", displayName ); namesCursor.close(); return names; } @NonNull private WritableMap getThumbnailFromContact(int contactId) { WritableMap thumbnail = Arguments.createMap(); // NOTE: See this for getting the high-res image: https://developer.android.com/reference/android/provider/ContactsContract.Contacts.Photo.html Uri contactUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId); Uri photoUri = Uri.withAppendedPath(contactUri, ContactsContract.Contacts.Photo.CONTENT_DIRECTORY); Cursor photoCursor = contentResolver.query( photoUri, new String[] {ContactsContract.Contacts.Photo.PHOTO}, null, null, null, null); if ( !photoCursor.moveToFirst() ) return thumbnail; byte[] data = photoCursor.getBlob(0); if (data != null) { thumbnail.putBoolean( "imageDataAvailable", true ); String base64Thumbnail = Base64.encodeToString(data, Base64.DEFAULT); thumbnail.putString( "thumbnailImageData", base64Thumbnail ); } else { thumbnail.putBoolean( "imageDataAvailable", false ); } photoCursor.close(); return thumbnail; } @NonNull private WritableMap getOrganizationFromContact(int contactId) { WritableMap organization = Arguments.createMap(); String whereString = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; String[] whereParams = new String[]{String.valueOf(contactId), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE }; Cursor organizationCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null, null); if ( !organizationCursor.moveToFirst() ) return organization; String company = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.COMPANY ); String department = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.DEPARTMENT ); String jobDescription = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.JOB_DESCRIPTION ); String officeLocation = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.OFFICE_LOCATION ); String symbol = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.SYMBOL ); String title = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.TITLE ); String label = getStringFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.LABEL ); int typeInt = getIntFromCursor( organizationCursor, ContactsContract.CommonDataKinds.Organization.TYPE ); String type = String.valueOf(ContactsContract.CommonDataKinds.Organization.getTypeLabel(getCurrentActivity().getResources(), typeInt, "")); // NOTE: label is only set for custom Types, so to keep things consistent between iOS and Android // and to essentially give the user what they really want, which is the label, put type into label if it's null. if (label == null) label = type; organization.putString( "company", company ); organization.putString( "title", title ); // NOTE: Below are hidden because based on current Android Contacts only company and title can be entered. // Additionally, it would be good to handle this as an array if we really want to do it correct. For example, // somebody could be the chairman or on the board of multiple companies and that would be important to capture. // organization.putString( "department", department ); // organization.putString( "jobDescription", jobDescription ); // organization.putString( "officeLocation", officeLocation ); // organization.putString( "symbol", symbol ); // organization.putString( "label", label ); // organization.putString( "type", type ); organizationCursor.close(); return organization; } @NonNull private WritableArray getPhoneNumbersFromContact(int contactId) { WritableArray phoneNumbers = Arguments.createArray(); Cursor phoneNumbersCursor = contentResolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = " + contactId, null, null); while (phoneNumbersCursor.moveToNext()) { WritableMap phoneNumber = Arguments.createMap(); String number = getStringFromCursor( phoneNumbersCursor, ContactsContract.CommonDataKinds.Phone.NUMBER ); String normalizedNumber = getStringFromCursor( phoneNumbersCursor, ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER ); String label = getStringFromCursor( phoneNumbersCursor, ContactsContract.CommonDataKinds.Phone.LABEL ); int typeInt = getIntFromCursor( phoneNumbersCursor, ContactsContract.CommonDataKinds.Phone.TYPE ); String type = String.valueOf(ContactsContract.CommonDataKinds.Phone.getTypeLabel(getCurrentActivity().getResources(), typeInt, "")); // NOTE: label is only set for custom Types, so to keep things consistent between iOS and Android // and to essentially give the user what they really want, which is the label, put type into label if it's null. if (label == null) label = type; phoneNumber.putString("stringValue", number); phoneNumber.putString("digits", normalizedNumber); phoneNumber.putString("label", label); phoneNumber.putString("type", type); phoneNumbers.pushMap(phoneNumber); } phoneNumbersCursor.close(); return phoneNumbers; } @NonNull private WritableArray getEmailAddressesFromContact(int contactId) { WritableArray emailAddresses = Arguments.createArray(); Cursor emailAddressesCursor = contentResolver.query( ContactsContract.CommonDataKinds.Email.CONTENT_URI, null, ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = " + contactId, null, null); while (emailAddressesCursor.moveToNext()) { WritableMap emailAddress = Arguments.createMap(); String value = getStringFromCursor( emailAddressesCursor, ContactsContract.CommonDataKinds.Email.ADDRESS ); String label = getStringFromCursor( emailAddressesCursor, ContactsContract.CommonDataKinds.Email.LABEL ); int typeInt = getIntFromCursor( emailAddressesCursor, ContactsContract.CommonDataKinds.Email.TYPE ); String type = String.valueOf(ContactsContract.CommonDataKinds.Email.getTypeLabel(getCurrentActivity().getResources(), typeInt, "")); // NOTE: label is only set for custom Types, so to keep things consistent between iOS and Android // and to essentially give the user what they really want, which is the label, put type into label if it's null. if (label == null) label = type; emailAddress.putString("value", value); // TODO: Consider standardizing on "address" instead of "value". emailAddress.putString("address", value); // Added in case Android devs are used to accessing it like this. emailAddress.putString("label", label); emailAddress.putString("type", type); emailAddresses.pushMap(emailAddress); } emailAddressesCursor.close(); return emailAddresses; } @NonNull private WritableArray getPostalAddressesFromContact(int contactId) { WritableArray postalAddresses = Arguments.createArray(); String whereString = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; String[] whereParams = new String[]{String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE }; Cursor postalAddressesCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null, null); while (postalAddressesCursor.moveToNext()) { WritableMap postalAddress = Arguments.createMap(); String pobox = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.POBOX); String street = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.STREET); String neighborhood = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD); String city = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.CITY); String region = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.REGION); String postcode = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE); String country = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY); String formattedAddress = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS); String label = getStringFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.LABEL ); int typeInt = getIntFromCursor( postalAddressesCursor, ContactsContract.CommonDataKinds.StructuredPostal.TYPE ); String type = String.valueOf(ContactsContract.CommonDataKinds.StructuredPostal.getTypeLabel(getCurrentActivity().getResources(), typeInt, "")); // NOTE: label is only set for custom Types, so to keep things consistent between iOS and Android // and to essentially give the user what they really want, which is the label, put type into label if it's null. if (label == null) label = type; postalAddress.putString("pobox", pobox); postalAddress.putString("street", street); postalAddress.putString("neighborhood", neighborhood); postalAddress.putString("city", city); postalAddress.putString("state", region); // // TODO: Consider standardizing on "region" instead. postalAddress.putString("region", region); // Added in case Android devs are used to accessing it like this. postalAddress.putString("postalCode", postcode); // TODO: Consider standardizing on "postalCode" instead. postalAddress.putString("postcode", postcode); // Added in case Android devs are used to accessing it like this. postalAddress.putString("country", country); postalAddress.putString("stringValue", formattedAddress); // TODO: Consider standardizing on "formattedString" instead. postalAddress.putString("formattedAddress", formattedAddress); // Added in case Android devs are used to accessing it like this. postalAddress.putString("label", label); postalAddress.putString("type", type); postalAddresses.pushMap(postalAddress); } postalAddressesCursor.close(); return postalAddresses; } private WritableMap getBirthdayFromContact(int contactId) { WritableMap birthday = Arguments.createMap(); String whereString = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.CommonDataKinds.Event.TYPE + " = ? AND " + ContactsContract.CommonDataKinds.Event.MIMETYPE + " = ?" ; String[] whereParams = new String[]{String.valueOf(contactId), String.valueOf(ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY), ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE }; Cursor birthdayCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null, null); if ( !birthdayCursor.moveToFirst() ) return null; String stringValue = getStringFromCursor( birthdayCursor, ContactsContract.CommonDataKinds.Event.START_DATE ); birthday.putString( "stringValue", stringValue ); // This will always be returned but day/month/year might not be if it's not available. SimpleDateFormat dateFormat = new SimpleDateFormat("YYYY-MM-DD"); try { Date birthdayDate = dateFormat.parse(stringValue); String day = (String) DateFormat.format("dd", birthdayDate); String month = (String) DateFormat.format("MM", birthdayDate); String year = (String) DateFormat.format("yyyy", birthdayDate); birthday.putString( "day", day ); birthday.putString( "month", month ); birthday.putString( "year", year ); } catch (ParseException e) { e.printStackTrace(); } birthdayCursor.close(); return birthday; } private String getNoteFromContact(int contactId) { String whereString = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; String[] whereParams = new String[]{String.valueOf(contactId), ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE }; Cursor noteCursor = contentResolver.query( ContactsContract.Data.CONTENT_URI, null, whereString, whereParams, null, null); if ( !noteCursor.moveToFirst() ) return null; String note = getStringFromCursor( noteCursor, ContactsContract.CommonDataKinds.Note.NOTE ); noteCursor.close(); return note; } private String getStringFromCursor(Cursor cursor, String column) { int columnIndex = cursor.getColumnIndex(column); return cursor.getString(columnIndex); } private int getIntFromCursor(Cursor cursor, String column) { int columnIndex = cursor.getColumnIndex(column); return cursor.getInt(columnIndex); } private void alreadyRequestedAccessToContacts( boolean value ) { SharedPreferences.Editor sharedPreferencesEditor = sharedPreferences.edit(); sharedPreferencesEditor.putBoolean( "ALREADY_REQUESTED_ACCESS_TO_CONTACTS", value ); sharedPreferencesEditor.apply(); } private boolean alreadyRequestedAccessToContacts() { return sharedPreferences.getBoolean( "ALREADY_REQUESTED_ACCESS_TO_CONTACTS", false ); } }