diff --git a/res/values/strings.xml b/res/values/strings.xml --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -17,6 +17,8 @@ Provides a remote control for your media player Run Command Trigger remote commands from your phone or tablet + Contacts Synchronizer + Allow synchronizing the device\'s contacts book Ping Send and receive pings Notification sync @@ -216,5 +218,6 @@ To read and write SMS from your desktop you need to give permission to SMS To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS To see a contact name instead of a phone number you need to give access to the phone\'s contacts + To see a contact name instead of a phone number you need to give access to the phone\'s contacts diff --git a/src/org/kde/kdeconnect/Helpers/ContactsHelper.java b/src/org/kde/kdeconnect/Helpers/ContactsHelper.java --- a/src/org/kde/kdeconnect/Helpers/ContactsHelper.java +++ b/src/org/kde/kdeconnect/Helpers/ContactsHelper.java @@ -31,15 +31,23 @@ import android.util.Base64OutputStream; import android.util.Log; +import org.json.JSONArray; + import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; public class ContactsHelper { @TargetApi(Build.VERSION_CODES.HONEYCOMB) + /** + * Lookup the name and photoID of a contact given a phone number + */ public static Map phoneNumberLookup(Context context, String number) { //Log.e("PhoneNumberLookup", number); @@ -111,5 +119,182 @@ try { output.close(); } catch(Exception ignored) { }; } } + + /** + * Return all the NAME_RAW_CONTACT_IDS which contribute an entry to a Contact in the database + * + * If the user has, for example, joined several contacts, on the phone, the IDs returned will + * be representative of the joined contact + * + * See here: https://developer.android.com/reference/android/provider/ContactsContract.Contacts.html + * for more information about the connection between contacts and raw contacts + * + * @param context android.content.Context running the request + * @return List of each NAME_RAW_CONTACT_ID in the Contacts database + */ + public static List getAllContactRawContactIDs(Context context) + { + ArrayList toReturn = new ArrayList(); + + // Define the columns we want to read from the Contacts database + final String[] projection = new String[] { + ContactsContract.Contacts.NAME_RAW_CONTACT_ID + }; + + Uri contactsUri = ContactsContract.Contacts.CONTENT_URI; + Cursor contactsCursor = context.getContentResolver().query( + contactsUri, + projection, + null, null, null); + if (contactsCursor != null && contactsCursor.moveToFirst()) + { + do { + Long contactID; + + int idIndex = contactsCursor.getColumnIndex(ContactsContract.Contacts.NAME_RAW_CONTACT_ID); + if (idIndex != -1) { + contactID = contactsCursor.getLong(idIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + Log.e("ContactsHelper", "Got a contact which does not have a NAME_RAW_CONTACT_ID"); + continue; + } + + toReturn.add(contactID); + } while (contactsCursor.moveToNext()); + try { contactsCursor.close(); } catch (Exception e) {} + } + + return toReturn; + } + + /** + * Return a mapping of raw contact IDs to a map of the requested data from the Contacts database + * + * If for some reason there is no row associated with the raw contact ID in the database, + * there will not be a corresponding field in the returned map + * + * @param context android.content.Context running the request + * @param IDs collection of raw contact IDs to look up + * @param contactsProjection List of column names to extract, defined in ContactsContract.Contacts + * @return mapping of raw contact IDs to desired values, which are a mapping of column names to the data contained there + */ + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public static Map> getColumnsFromContactsForRawContactIDs(Context context, Set IDs, String[] contactsProjection) + { + HashMap> toReturn = new HashMap<>(); + + // Define the columns we want to read from the RawContacts database + final String[] rawContactsProjection = new String[] { + ContactsContract.RawContacts._ID, + ContactsContract.RawContacts.CONTACT_ID + }; + + Uri rawContactsUri = ContactsContract.RawContacts.CONTENT_URI; + Uri contactsUri = ContactsContract.Contacts.CONTENT_URI; + + Cursor rawContactsCursor = context.getContentResolver().query( + rawContactsUri, + rawContactsProjection, + null, + null, + null); + + if (rawContactsCursor != null && rawContactsCursor.moveToFirst()) + { + do { + Long rawContactID; + Long contactID; + + int rawContactIDIndex = rawContactsCursor.getColumnIndex(ContactsContract.RawContacts._ID); + if (rawContactIDIndex != -1) { + rawContactID = rawContactsCursor.getLong(rawContactIDIndex); + } else { + // This raw contact didn't have an ID? Something is very wrong. + // TODO: Investigate why this would happen + Log.e("ContactsHelper", "Got a raw contact which does not have an _ID"); + continue; + } + + // Filter only for the rawContactIDs we were asked to look up + if (! IDs.contains(rawContactID)) + { + // This should be achievable (and faster) by providing a selection + // and selectionArgs when fetching rawContactsCursor, but I can't + // figure that out + continue; + } + + int contactIDIndex = rawContactsCursor.getColumnIndex(ContactsContract.RawContacts.CONTACT_ID); + if (contactIDIndex != -1) { + contactID = rawContactsCursor.getLong(contactIDIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + Log.e("ContactsHelper", "Got a raw contact which does not have a CONTACT_ID"); + continue; + } + + // Filter on only the contact we are interested in + final String contactsSelection = ContactsContract.Contacts._ID + " == ? "; + final String[] contactsArgs = new String[] {contactID.toString()}; + + Cursor contactsCursor = context.getContentResolver().query( + contactsUri, + contactsProjection, + contactsSelection, + contactsArgs, null + ); + + Map requestedData = new HashMap<>(); + + if (contactsCursor != null && contactsCursor.moveToFirst()) + { + // For each column, collect the data from that column + for (String column : contactsProjection) + { + int index = contactsCursor.getColumnIndex(column); + // Since we might be getting various kinds of data, Object is the best we can do + Object data; + int type; + if (index == -1) { + // This raw contact didn't have an ID? Something is very wrong. + // TODO: Investigate why this would happen + Log.e("ContactsHelper", "Got a raw contact which does not have an _ID"); + continue; + } + + type = contactsCursor.getType(index); + switch(type) { + case Cursor.FIELD_TYPE_INTEGER: + data = contactsCursor.getInt(index); + break; + case Cursor.FIELD_TYPE_FLOAT: + data = contactsCursor.getFloat(index); + break; + case Cursor.FIELD_TYPE_STRING: + data = contactsCursor.getString(index); + break; + case Cursor.FIELD_TYPE_BLOB: + data = contactsCursor.getBlob(index); + break; + default: + Log.e("ContactsHelper", "Got an undefined type of column " + column); + continue; + } + + requestedData.put(column, data); + } + } + contactsCursor.close(); + + toReturn.put(rawContactID, requestedData); + } while (rawContactsCursor.moveToNext()); + rawContactsCursor.close(); + } + + return toReturn; + } } diff --git a/src/org/kde/kdeconnect/Plugins/ContactsPlugin/ContactsPlugin.java b/src/org/kde/kdeconnect/Plugins/ContactsPlugin/ContactsPlugin.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/ContactsPlugin/ContactsPlugin.java @@ -0,0 +1,376 @@ +/* + * ContactsPlugin.java - This file is part of KDE Connect's Android App + * Implement a way to request and send contact information + * + * Copyright 2018 Simon Redman + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +package org.kde.kdeconnect.Plugins.ContactsPlugin; + +import android.Manifest; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.util.Log; + +import org.json.JSONArray; +import org.kde.kdeconnect.Helpers.ContactsHelper; +import org.kde.kdeconnect.NetworkPackage; +import org.kde.kdeconnect.Plugins.Plugin; +import org.kde.kdeconnect_tp.R; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class ContactsPlugin extends Plugin { + + /** + * Used to request this device's entire contacts book + * + * This package type is soon to be depreciated and deleted + */ + public static final String PACKAGE_TYPE_CONTACTS_REQUEST_ALL = "kdeconnect.contacts.request_all"; + + /** + * Used to request the device send the unique ID of every contact + */ + public static final String PACKAGE_TYPE_CONTACTS_REQUEST_ALL_UIDS = "kdeconnect.contacts.request_all_uids"; + + /** + * Used to request the names for a the contacts corresponding to a list of UIDs + * + * It shall contain the key "uids", which will have a list of uIDs (long int, as string) + */ + public static final String PACKAGE_TYPE_CONTACTS_REQUEST_NAMES_BY_UIDS = "kdeconnect.contacts.request_names_by_uid"; + + /** + * Send a list of pairings of contact names and phone numbers + * + * This package type is soon to be depreciated and deleted + */ + public static final String PACKAGE_TYPE_CONTACTS_RESPONSE = "kdeconnect.contacts.response"; + + /** + * Response indicating the package contains a list of contact uIDs + * + * It shall contain the key "uids", which will mark a list of uIDs (long int, as string) + * The returned IDs can be used in future requests for more information about the contact + */ + public static final String PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS = "kdeconnect.contacts.response_uids"; + + /** + * Response indicating the package contains a list of contact names + * + * It shall contain the key "uids", which will mark a list of uIDs (long int, as string) + * then, for each UID, there shall be a field with the key of that UID and the value of the name of the contact + * + * For example: + * ( 'uids' : ['1', '3', '15'], + * '1' : 'John Smith', + * '3' : 'Abe Lincoln', + * '15' : 'Mom' ) + */ + public static final String PACKAGE_TYPE_CONTACTS_RESPONSE_NAMES = "kdeconnect.contacts.response_names"; + + private int contactsPermissionExplanation = R.string.contacts_permission_explanation; + + @Override + public String getDisplayName() { + return context.getResources().getString(R.string.pref_plugin_contacts); + } + + @Override + public String getDescription() { + return context.getResources().getString(R.string.pref_plugin_contacts_desc); + } + + @Override + public String[] getSupportedPackageTypes() { + return new String[] { + PACKAGE_TYPE_CONTACTS_REQUEST_ALL, + PACKAGE_TYPE_CONTACTS_REQUEST_ALL_UIDS, + PACKAGE_TYPE_CONTACTS_REQUEST_NAMES_BY_UIDS + }; + } + + @Override + public String[] getOutgoingPackageTypes() { + return new String[] { + PACKAGE_TYPE_CONTACTS_RESPONSE, + PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS, + PACKAGE_TYPE_CONTACTS_RESPONSE_NAMES + }; + } + + @Override + public boolean onCreate() + { + permissionExplanation = contactsPermissionExplanation; + + return true; + } + + @Override + /** + * Since this plugin could leak sensitive information, probably best to leave disabled by default + */ + public boolean isEnabledByDefault() + { + return false; + } + + @Override + public String[] getRequiredPermissions() { + return new String[]{Manifest.permission.READ_CONTACTS}; + // One day maybe we will also support WRITE_CONTACTS, but not yet + } + + /** + * Return a unique identifier (long int) for all contacts in the Contacts database + * + * The identifiers returned can be used in future requests to get more information + * about the contact + * + * @param np The package containing the request + * @return true if successfully handled, false otherwise + */ + protected boolean handleRequestAllUIDs(NetworkPackage np) { + NetworkPackage reply = new NetworkPackage(PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS); + + List uIDs = ContactsHelper.getAllContactRawContactIDs(context); + + List uIDsAsStrings = new ArrayList(uIDs.size()); + + for (Long uID : uIDs) + { + uIDsAsStrings.add(uID.toString()); + } + + reply.set("uids", uIDsAsStrings); + + device.sendPackage(reply); + + return true; + } + + protected boolean handleRequestNamesByUIDs(NetworkPackage np) { + if (!np.has("uids")) + { + Log.e("ContactsPlugin", "handleRequestNamesByUIDs received a malformed packet with no uids key"); + return false; + } + + List uIDsAsStrings = np.getStringList("uids"); + + // Convert to Set to call getColumnsFromContactsForRawContactIDs + Set uIDs = new HashSet(uIDsAsStrings.size()); + for (String uID : uIDsAsStrings) + { + uIDs.add(Long.parseLong(uID)); + } + + final String[] contactsProjection = new String[] { + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + }; + + Map> uIDsToNames = ContactsHelper.getColumnsFromContactsForRawContactIDs(context, uIDs, contactsProjection); + + // ContactsHelper.getColumnsFromContactsForRawContactIDs(..) is allowed to reply without + // some of the requested uIDs if they were not in the database, so update our list + uIDsAsStrings = new ArrayList<>(uIDsToNames.size()); + + NetworkPackage reply = new NetworkPackage(PACKAGE_TYPE_CONTACTS_RESPONSE_NAMES); + + // Add the names to the packet + for (Long uID : uIDsToNames.keySet()) + { + Map data = uIDsToNames.get(uID); + String name; + + if (! data.containsKey(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)) { + // This contact apparently does not have a name + Log.w("ContactsHelper", "Got a uID " + uID + " which does not have a name"); + continue; + } + + name = (String)data.get(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY); + + // Store this as a valid uID + uIDsAsStrings.add(uID.toString()); + // Add the uid : name pairing to the packet + reply.set(uID.toString(), name); + } + + // Add the valid uIDs to the packet + reply.set("uids", uIDsAsStrings); + + device.sendPackage(reply); + + return true; + } + + @Override + public boolean onPackageReceived(NetworkPackage np) { + if (np.getType().equals(PACKAGE_TYPE_CONTACTS_REQUEST_ALL)) + { + // Return the whole contacts book + // The reply is formatted as a series of: + // : Name, Category, Number + // Where int is a unique (incrementing) integer, + // Name is the contact's name + // Category is the contact's number category, to differentiate in case there is more + // than one + // Number is the contact's number + + NetworkPackage reply = new NetworkPackage(PACKAGE_TYPE_CONTACTS_RESPONSE); + + int index = 0; + + Uri contactsUri = ContactsContract.Contacts.CONTENT_URI; + Cursor contactsCursor = null; + try { + contactsCursor = context.getContentResolver().query( + contactsUri, + new String[] { + ContactsContract.Contacts.DISPLAY_NAME + //, ContactsContract.PhoneLookup.PHOTO_URI // One day... + , ContactsContract.Contacts.NAME_RAW_CONTACT_ID // Used to index the Data table + }, + null, null, null); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + if (contactsCursor != null && contactsCursor.moveToFirst()) + { + do { + String contactName; + String contactNumber; + String contactNumberCategory; + Long contactID; + + int nameIndex = contactsCursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME); + + if (nameIndex != -1) { + contactName = contactsCursor.getString(nameIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + continue; + } + + int idIndex = contactsCursor.getColumnIndex(ContactsContract.Contacts.NAME_RAW_CONTACT_ID); + if (idIndex != -1) { + contactID = contactsCursor.getLong(idIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + continue; + } + + // For this contact, query the phone's database for its number(s) + Uri dataUri = ContactsContract.Data.CONTENT_URI; + Cursor dataCursor = null; + + dataCursor = context.getContentResolver().query( + dataUri, + new String[]{ + ContactsContract.Data.MIMETYPE + // We may need to handle more than "Phone"-type contacts, but for now, only those + , ContactsContract.CommonDataKinds.Phone.NUMBER + , ContactsContract.CommonDataKinds.Phone.TYPE + // Stores the label of the type of the number + , ContactsContract.CommonDataKinds.Phone.LABEL + }, + "RAW_CONTACT_ID == " + contactID, null, null); + + if (dataCursor != null && dataCursor.moveToFirst()) + { + do + { + int mimetypeIndex = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE); + if (mimetypeIndex != -1) + { + // Check if this is actually a phone record (not email, etc.) + String mimetype = dataCursor.getString(mimetypeIndex); + + if (!(mimetype.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE))) + { + // Not a phone record + continue; + } + } + + int numberIndex = dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER); + + if (numberIndex != -1) { + contactNumber = dataCursor.getString(numberIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + continue; + } + + int labelIndex = dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LABEL); + if (labelIndex != -1) { + contactNumberCategory = dataCursor.getString(labelIndex); + } else { + // Something went wrong with this contact + // TODO: Investigate why this would happen + continue; + } + + // TODO: Decode ContactsContract.CommonDataKinds.Phone.TYPE to get categories + if (contactNumberCategory == null) + { + contactNumberCategory = "Unimplemented"; + } + + List contactInfo = new ArrayList(); + contactInfo.add(contactName); + contactInfo.add(contactNumberCategory); // Category + contactInfo.add(contactNumber); // Number + reply.set(Integer.toString(index), new JSONArray(contactInfo)); + + index ++; + } while (dataCursor.moveToNext()); + try { dataCursor.close(); } catch (Exception e) {} + } + } while (contactsCursor.moveToNext()); + try { contactsCursor.close(); } catch (Exception e) {} + } + + device.sendPackage(reply); + + return true; + } else if (np.getType().equals(PACKAGE_TYPE_CONTACTS_REQUEST_ALL_UIDS)) + { + return this.handleRequestAllUIDs(np); + } else if (np.getType().equals(PACKAGE_TYPE_CONTACTS_REQUEST_NAMES_BY_UIDS)) { + return this.handleRequestNamesByUIDs(np); + } else + { + Log.e("ContactsPlugin", "Contacts plugin received an unexpected packet!"); + return false; + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/PluginFactory.java b/src/org/kde/kdeconnect/Plugins/PluginFactory.java --- a/src/org/kde/kdeconnect/Plugins/PluginFactory.java +++ b/src/org/kde/kdeconnect/Plugins/PluginFactory.java @@ -27,6 +27,7 @@ import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Plugins.BatteryPlugin.BatteryPlugin; import org.kde.kdeconnect.Plugins.ClibpoardPlugin.ClipboardPlugin; +import org.kde.kdeconnect.Plugins.ContactsPlugin.ContactsPlugin; import org.kde.kdeconnect.Plugins.FindMyPhonePlugin.FindMyPhonePlugin; import org.kde.kdeconnect.Plugins.MousePadPlugin.MousePadPlugin; import org.kde.kdeconnect.Plugins.MprisPlugin.MprisPlugin; @@ -125,6 +126,7 @@ PluginFactory.registerPlugin(TelepathyPlugin.class); PluginFactory.registerPlugin(FindMyPhonePlugin.class); PluginFactory.registerPlugin(RunCommandPlugin.class); + PluginFactory.registerPlugin(ContactsPlugin.class); PluginFactory.registerPlugin(RemoteKeyboardPlugin.class); }