diff --git a/build.gradle b/build.gradle --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,7 @@ buildscript { repositories { jcenter() - maven { - url 'https://maven.google.com/' - name 'Google' - } + google() } dependencies { classpath 'com.android.tools.build:gradle:3.0.1' @@ -73,10 +70,7 @@ repositories { jcenter() - maven { - url 'https://maven.google.com/' - name 'Google' - } + google() } implementation 'com.android.support:support-v4:25.4.0' diff --git a/res/drawable/ic_album_art_placeholder.xml b/res/drawable/ic_album_art_placeholder.xml --- a/res/drawable/ic_album_art_placeholder.xml +++ b/res/drawable/ic_album_art_placeholder.xml @@ -1,6 +1,6 @@ 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 @@ -222,6 +224,7 @@ 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 Select a ringtone Blocked numbers Don\'t show calls and SMS from these numbers. Please specify one number per line 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 @@ -1,5 +1,6 @@ /* * Copyright 2014 Albert Vaca Cintora + * 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 @@ -27,19 +28,35 @@ import android.os.Build; import android.provider.ContactsContract; import android.provider.ContactsContract.PhoneLookup; +import android.support.annotation.RequiresApi; import android.util.Base64; import android.util.Base64OutputStream; import android.util.Log; +import org.json.JSONArray; + +import java.io.BufferedReader; import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; +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); @@ -122,5 +139,422 @@ } } + + /** + * 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; + } + + /** + * Get VCards using the batch database query which requires Android API 21 + * + * @param context android.content.Context running the request + * @param IDs collection of raw contact IDs to look up + * @param lookupKeys + * @return Mapping of raw contact IDs to corresponding VCard + */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + protected static Map getVCardsFast(Context context, Collection IDs, Map lookupKeys) { + Map toReturn = new HashMap<>(); + StringBuilder keys = new StringBuilder(); + + List orderedIDs = new ArrayList<>(IDs); + + for (Long ID : orderedIDs) { + String key = lookupKeys.get(ID); + keys.append(key); + keys.append(':'); + } + + // Remove trailing ':' + keys.deleteCharAt(keys.length() - 1); + + Uri vcardURI = Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_MULTI_VCARD_URI, + Uri.encode(keys.toString())); + + InputStream input; + StringBuilder vcardJumble = new StringBuilder(); + try { + input = context.getContentResolver().openInputStream(vcardURI); + + BufferedReader bufferedInput = new BufferedReader(new InputStreamReader(input)); + String line; + + while ((line = bufferedInput.readLine()) != null) { + vcardJumble.append(line).append('\n'); + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + // WARNING -- UNDOCUMENTED BEHAVIOR USED AHEAD + // Android returns the vcards as a big flat string of vcards + // It **appears** that these vcards are in the same order as we requested them + // We are relying on this behavior to connect VCards to the unique IDs + + String[] vcards = vcardJumble.toString().split("END:VCARD"); + for (int index = 0; index < orderedIDs.size(); index ++) { + String vcard = vcards[index] + "END:VCARD"; + Long ID = orderedIDs.get(index); + toReturn.put(ID, vcard); + } + + return toReturn; + } + + /** + * Get VCards using serial database lookups. This is tragically slow, but at least supports old Android versions + * + * Use getVCardsFast for API >= 21 + * + * @param context android.content.Context running the request + * @param IDs collection of raw contact IDs to look up + * @param lookupKeys + * @return + */ + protected static Map getVCardsSlow(Context context, Collection IDs, Map lookupKeys) { + Map toReturn = new HashMap<>(); + + for ( Long ID : lookupKeys.keySet() ) { + String lookupKey = lookupKeys.get(ID); + Uri vcardURI = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey); + InputStream input; + try { + input = context.getContentResolver().openInputStream(vcardURI); + + BufferedReader bufferedInput = new BufferedReader(new InputStreamReader(input)); + + StringBuilder vcard = new StringBuilder(); + String line; + while ((line = bufferedInput.readLine()) != null) { + vcard.append(line).append('\n'); + } + + toReturn.put(ID, vcard.toString()); + } catch (FileNotFoundException e) { + // TODO: In what case is the vcard not found? + e.printStackTrace(); + continue; + } catch (IOException e) { + e.printStackTrace(); + continue; + } + } + + return toReturn; + } + + /** + * Get the VCard for every specified raw contact ID + * + * @param context android.content.Context running the request + * @param IDs collection of raw contact IDs to look up + * @return Mapping of raw contact IDs to the corresponding VCard + */ + public static Map getVCardsForContactIDs(Context context, Collection IDs) { + Map toReturn = new HashMap<>(); + + // Get the contacts' lookup keys, since that is how VCard is looked up + final String[] contactsProjection = new String[]{ + ContactsContract.Contacts.LOOKUP_KEY + }; + + Map> lookupKeysMap = getColumnsFromContactsForRawContactIDs(context, IDs, contactsProjection); + Map lookupKeys = new HashMap<>(); + + for (Long ID : lookupKeysMap.keySet()) { + Map returnedColumns = lookupKeysMap.get(ID); + lookupKeys.put(ID, (String) returnedColumns.get(ContactsContract.Contacts.LOOKUP_KEY)); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return getVCardsFast(context, IDs, lookupKeys); + } else { + return getVCardsSlow(context, IDs, lookupKeys); + } + + } + + /** + * 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) // Needed for Cursor.getType(..) + public static Map> getColumnsFromContactsForRawContactIDs(Context context, Collection 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; + } + + /** + * Return a mapping of raw contact IDs to a map of the requested data from the Data 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 + *

+ * For some types of data, there may be many entries in the Data database with the same raw contact ID, + * so a list of the relevant data is returned + * + * @param context android.content.Context running the request + * @param IDs collection of raw contact IDs to look up + * @param dataMimetype Mimetype of the column to look up, defined in ContactsContract.CommonDataKinds..CONTENT_ITEM_TYPE + * @param dataProjection List of column names to extract, defined in ContactsContract.CommonDataKinds. + * @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) // Needed for Cursor.getType(..) + public static Map>> getColumnsFromDataForRawContactIDs(Context context, Collection IDs, String dataMimetype, String[] dataProjection) { + HashMap>> toReturn = new HashMap<>(); + + // Define a filter for the type of data we were asked to get + final String dataSelection = ContactsContract.Data.MIMETYPE + " == ?"; + final String[] dataSelectionArgs = {dataMimetype}; + + Uri dataUri = ContactsContract.Data.CONTENT_URI; + + // Regardless of what the user requested, we need the RAW_CONTACT_ID field + // This will not be returned to the user if it wasn't asked for + Set actualDataProjectionSet = new HashSet(); + actualDataProjectionSet.addAll(Arrays.asList(dataProjection)); + actualDataProjectionSet.add(ContactsContract.Data.RAW_CONTACT_ID); + + String[] actualDataProjection = new String[0]; + actualDataProjection = actualDataProjectionSet.toArray(actualDataProjection); + + Cursor dataCursor = context.getContentResolver().query( + dataUri, + actualDataProjection, + dataSelection, + dataSelectionArgs, + null); + + if (dataCursor != null && dataCursor.moveToFirst()) { + do { + Long rawContactID; + + Map requestedData = new HashMap<>(); + + int rawContactIDIndex = dataCursor.getColumnIndex(ContactsContract.Data.RAW_CONTACT_ID); + if (rawContactIDIndex != -1) { + rawContactID = dataCursor.getLong(rawContactIDIndex); + } else { + // This didn't have a RAW_CONTACT_ID? Something is very wrong. + // TODO: Investigate why this would happen + Log.e("ContactsHelper", "Got a data contact which does not have a RAW_CONTACT_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 dataCursor, but I can't + // figure that out + continue; + } + // For each column, collect the data from that column + for (String column : dataProjection) { + int index = dataCursor.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 = dataCursor.getType(index); + switch (type) { + case Cursor.FIELD_TYPE_INTEGER: + data = dataCursor.getInt(index); + break; + case Cursor.FIELD_TYPE_FLOAT: + data = dataCursor.getFloat(index); + break; + case Cursor.FIELD_TYPE_STRING: + data = dataCursor.getString(index); + break; + case Cursor.FIELD_TYPE_BLOB: + data = dataCursor.getBlob(index); + break; + default: + Log.w("ContactsHelper", "Got an undefined type of column " + column + " -- Skipping"); + continue; + } + + requestedData.put(column, data); + } + + // If we have not already stored some data for this contact, make a new list + if (!toReturn.containsKey(rawContactID)) { + toReturn.put(rawContactID, new ArrayList>()); + } + toReturn.get(rawContactID).add(requestedData); + } while (dataCursor.moveToNext()); + dataCursor.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,273 @@ +/* + * 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.annotation.TargetApi; +import android.os.Build; +import android.provider.ContactsContract; +import android.util.Log; + +import org.kde.kdeconnect.Helpers.ContactsHelper; +import org.kde.kdeconnect.NetworkPacket; +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; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) +public class ContactsPlugin extends Plugin { + + /** + * Used to request the device send the unique ID of every contact + */ + public static final String PACKET_TYPE_CONTACTS_REQUEST_ALL_UIDS_TIMESTAMPS = "kdeconnect.contacts.request_all_uids_timestamps"; + + /** + * Used to request the names for 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 PACKET_TYPE_CONTACTS_REQUEST_VCARDS_BY_UIDS = "kdeconnect.contacts.request_vcards_by_uid"; + + /** + * Response indicating the packet 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 PACKET_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS = "kdeconnect.contacts.response_uids_timestamps"; + + /** + * Response indicating the packet 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 PACKET_TYPE_CONTACTS_RESPONSE_VCARDS = "kdeconnect.contacts.response_vcards"; + + 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[] getSupportedPacketTypes() { + return new String[]{ + PACKET_TYPE_CONTACTS_REQUEST_ALL_UIDS_TIMESTAMPS, + PACKET_TYPE_CONTACTS_REQUEST_VCARDS_BY_UIDS + }; + } + + @Override + public String[] getOutgoingPacketTypes() { + return new String[]{ + PACKET_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS, + PACKET_TYPE_CONTACTS_RESPONSE_VCARDS + }; + } + + @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 true; + } + + @Override + public String[] getRequiredPermissions() { + return new String[]{Manifest.permission.READ_CONTACTS}; + // One day maybe we will also support WRITE_CONTACTS, but not yet + } + + @Override + public int getMinSdk() { + // Need API 18 for contact timestamps + return Build.VERSION_CODES.JELLY_BEAN_MR2; + } + + /** + * Add custom fields to the vcard to keep track of KDE Connect-specific fields + *

+ * These include the local device's uID as well as last-changed timestamp + *

+ * This might be extended in the future to include more fields + * + * @param vcard vcard to apply metadata to + * @param uID uID to which the vcard corresponds + * @return + */ + protected String addVCardMetadata(String vcard, Long uID) { + StringBuilder newVCard = new StringBuilder(); + + // Clean the END:VCARD tag + String vcardBody = vcard.substring(0, vcard.indexOf("END:VCARD")); + + // Build the device ID line + // Unclear if the deviceID forms a valid name per the vcard spec. Worry about that later.. + String uIDLine = "X-KDECONNECT-ID-DEV-" + device.getDeviceId() + ":" + uID.toString(); + + // Build the timestamp line + // Maybe one day this should be changed into the vcard-standard REV key + List uIDs = new ArrayList<>(); + uIDs.add(uID); + + final String[] contactsProjection = new String[]{ + ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + }; + + Map> timestamp = ContactsHelper.getColumnsFromContactsForRawContactIDs(context, uIDs, contactsProjection); + String timestampLine = "X-KDECONNECT-TIMESTAMP:" + ((Integer) timestamp.get(uID).get(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)).toString(); + + newVCard.append(vcardBody) // Body already has a trailing newline + .append(uIDLine).append('\n') + .append(timestampLine).append('\n') + .append("END:VCARD"); + + return newVCard.toString(); + } + + /** + * 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 handleRequestAllUIDsTimestamps(NetworkPacket np) { + NetworkPacket reply = new NetworkPacket(PACKET_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS); + + List uIDs = ContactsHelper.getAllContactRawContactIDs(context); + + ContactsHelper.getVCardsForContactIDs(context, uIDs); + + List uIDsAsStrings = new ArrayList(uIDs.size()); + + for (Long uID : uIDs) { + uIDsAsStrings.add(uID.toString()); + } + + final String[] contactsProjection = new String[]{ + ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + }; + + reply.set("uids", uIDsAsStrings); + + // Add last-modified timestamps + Map> uIDsToTimestamps = ContactsHelper.getColumnsFromContactsForRawContactIDs(context, uIDs, contactsProjection); + for (Long ID : uIDsToTimestamps.keySet()) { + Map data = uIDsToTimestamps.get(ID); + reply.set(ID.toString(), (Integer) data.get(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)); + } + + device.sendPacket(reply); + + return true; + } + + protected boolean handleRequestVCardsByUIDs(NetworkPacket 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 Collection to call getVCardsForContactIDs + 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 uIDsToVCards = ContactsHelper.getVCardsForContactIDs(context, uIDs); + + // ContactsHelper.getVCardsForContactIDs(..) is allowed to reply without + // some of the requested uIDs if they were not in the database, so update our list + uIDsAsStrings = new ArrayList(uIDsToVCards.size()); + + NetworkPacket reply = new NetworkPacket(PACKET_TYPE_CONTACTS_RESPONSE_VCARDS); + + // Add the vcards to the packet + for (Long uID : uIDsToVCards.keySet()) { + String vcard = uIDsToVCards.get(uID); + + vcard = this.addVCardMetadata(vcard, uID); + + // Store this as a valid uID + uIDsAsStrings.add(uID.toString()); + // Add the uid : name pairing to the packet + reply.set(uID.toString(), vcard); + } + + // Add the valid uIDs to the packet + reply.set("uids", uIDsAsStrings); + + device.sendPacket(reply); + + return true; + } + + @Override + public boolean onPacketReceived(NetworkPacket np) { + if (np.getType().equals(PACKET_TYPE_CONTACTS_REQUEST_ALL_UIDS_TIMESTAMPS)) { + return this.handleRequestAllUIDsTimestamps(np); + } else if (np.getType().equals(PACKET_TYPE_CONTACTS_REQUEST_VCARDS_BY_UIDS)) { + return this.handleRequestVCardsByUIDs(np); + } else { + Log.e("ContactsPlugin", "Contacts plugin received an unexpected packet!"); + return false; + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java @@ -66,6 +66,10 @@ * A list of urls yet to be fetched. */ private static final ArrayList fetchUrlList = new ArrayList<>(); + /** + * A list of urls currently being fetched + */ + private static final ArrayList isFetchingList = new ArrayList<>(); /** * A integer indicating how many fetches are in progress. */ @@ -123,7 +127,7 @@ * @param albumUrl The album art url * @return A bitmap for the album art. Can be null if not (yet) found */ - public static Bitmap getAlbumArt(String albumUrl) { + public static Bitmap getAlbumArt(String albumUrl, MprisPlugin plugin, String player) { //If the url is invalid, return "no album art" if (albumUrl == null || albumUrl.isEmpty()) { return null; @@ -138,8 +142,8 @@ return null; } - //We currently only support http(s) urls - if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) { + //We currently only support http(s) and file urls + if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https") && !url.getProtocol().equals("file")) { return null; } @@ -163,11 +167,7 @@ try { DiskLruCache.Snapshot item = diskCache.get(urlToDiskCacheKey(albumUrl)); if (item != null) { - BitmapFactory.Options decodeOptions = new BitmapFactory.Options(); - decodeOptions.inScaled = false; - decodeOptions.inDensity = 1; - decodeOptions.inTargetDensity = 1; - Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0), null, decodeOptions); + Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0)); item.close(); MemoryCacheItem memItem = new MemoryCacheItem(); if (result != null) { @@ -189,7 +189,20 @@ /* If not found, we have not tried fetching it (recently), or a fetch is in-progress. Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */ - fetchUrl(url); + if ("file".equals(url.getProtocol())) { + //Special-case file, since we need to fetch it from the remote + if (isFetchingList.contains(url)) return null; + + if (!plugin.askTransferAlbumArt(albumUrl, player)) { + //It doesn't support transferring the art, so mark it as failed in the memory cache + MemoryCacheItem cacheItem = new MemoryCacheItem(); + cacheItem.failedFetch = true; + cacheItem.albumArt = null; + memoryCache.put(url.toString(), cacheItem); + } + } else { + fetchUrl(url); + } return null; } @@ -206,7 +219,7 @@ } //Only fetch an URL if we're not fetching it already - if (fetchUrlList.contains(url)) { + if (fetchUrlList.contains(url) || isFetchingList.contains(url)) { return; } @@ -323,8 +336,8 @@ memoryCache.put(url.toString(), cacheItem); } - //Remove the url from the to-fetch list - fetchUrlList.remove(url); + //Remove the url from the fetching list + isFetchingList.remove(url); //Fetch the next url (if any) --numFetching; initiateFetch(); @@ -338,10 +351,19 @@ if (numFetching >= 2) return; if (fetchUrlList.isEmpty()) return; - ++numFetching; - //Fetch the last-requested url first, it will probably be needed first URL url = fetchUrlList.get(fetchUrlList.size() - 1); + //Remove the url from the to-fetch list + fetchUrlList.remove(url); + + if ("file".equals(url.getProtocol())) { + throw new AssertionError("Not file urls should be possible here!"); + } + + //Download the album art ourselves + ++numFetching; + //Add the url to the currently-fetching list + isFetchingList.add(url); try { DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString())); if (cacheItem == null) { @@ -392,36 +414,68 @@ //We need the disk cache for this if (diskCache == null) { Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!"); + try { + payload.close(); + } catch (IOException ignored) {} return; } URL url; try { url = new URL(albumUrl); } catch (MalformedURLException e) { //Shouldn't happen (checked on receival of the url), but just to be sure + try { + payload.close(); + } catch (IOException ignored) {} return; } if (!"file".equals(url.getProtocol())) { //Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure + try { + payload.close(); + } catch (IOException ignored) {} return; } //Only fetch the URL if we're not fetching it already - if (fetchUrlList.contains(url)) { + if (isFetchingList.contains(url)) { + try { + payload.close(); + } catch (IOException ignored) {} return; } - fetchUrlList.add(url); + //Check if we already have this art + try { + if (memoryCache.get(albumUrl) != null || diskCache.get(urlToDiskCacheKey(albumUrl)) != null) { + try { + payload.close(); + } catch (IOException ignored) {} + return; + } + } catch (IOException e) { + Log.e("KDE/Mpris/AlbumArtCache", "Disk cache problem!", e); + try { + payload.close(); + } catch (IOException ignored) {} + return; + } + + //Add it to the currently-fetching list + isFetchingList.add(url); ++numFetching; try { DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString())); if (cacheItem == null) { Log.e("KDE/Mpris/AlbumArtCache", "Two disk cache edits happened at the same time, should be impossible!"); --numFetching; + try { + payload.close(); + } catch (IOException ignored) {} return; } diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java @@ -139,6 +139,10 @@ } targetPlayer = mpris.getPlayerStatus(player); updatePlayerStatus(mpris); + + if (targetPlayer.isPlaying()) { + MprisMediaSession.getInstance().playerSelected(targetPlayer); + } } @Override diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaSession.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaSession.java --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaSession.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaSession.java @@ -25,6 +25,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.graphics.Bitmap; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -253,6 +254,11 @@ metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, notificationPlayer.getLength()); } + Bitmap albumArt = notificationPlayer.getAlbumArt(); + if (albumArt != null) { + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArt); + } + mediaSession.setMetadata(metadata.build()); PlaybackStateCompat.Builder playbackState = new PlaybackStateCompat.Builder(); @@ -326,6 +332,10 @@ notification.setContentText(notificationPlayer.getPlayer()); } + if (albumArt != null) { + notification.setLargeIcon(albumArt); + } + if (!notificationPlayer.isPlaying()) { Intent iCloseNotification = new Intent(service, MprisMediaNotificationReceiver.class); iCloseNotification.setAction(MprisMediaNotificationReceiver.ACTION_CLOSE_NOTIFICATION); @@ -405,4 +415,9 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { updateMediaNotification(); } + + public void playerSelected(MprisPlugin.MprisPlayer player) { + notificationPlayer = player; + updateMediaNotification(); + } } diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java @@ -130,7 +130,7 @@ * @return The album art, or null if not available */ public Bitmap getAlbumArt() { - return AlbumArtCache.getAlbumArt(albumArtUrl); + return AlbumArtCache.getAlbumArt(albumArtUrl, MprisPlugin.this, player); } public boolean isSetVolumeAllowed() { @@ -205,6 +205,7 @@ public final static String PACKET_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request"; private HashMap players = new HashMap<>(); + private boolean supportAlbumArtPayload = false; private HashMap playerStatusUpdated = new HashMap<>(); private HashMap playerListUpdated = new HashMap<>(); @@ -231,7 +232,6 @@ @Override public boolean onCreate() { - requestPlayerList(); MprisMediaSession.getInstance().onCreate(context.getApplicationContext(), this, device.getDeviceId()); //Always request the player list so the data is up-to-date @@ -266,6 +266,11 @@ @Override public boolean onPacketReceived(NetworkPacket np) { + if (np.getBoolean("transferringAlbumArt", false)) { + AlbumArtCache.payloadToDiskCache(np.getString("albumArtUrl"), np.getPayload()); + return true; + } + if (np.has("player")) { MprisPlayer playerStatus = players.get(np.getString("player")); if (playerStatus != null) { @@ -306,6 +311,9 @@ } } + //Remember if the connected device support album art payloads + supportAlbumArtPayload = np.getBoolean("supportAlbumArtPayload", supportAlbumArtPayload); + List newPlayerList = np.getStringList("playerList"); if (newPlayerList != null) { boolean equals = true; @@ -463,4 +471,22 @@ } } } + + public boolean askTransferAlbumArt(String url, String playerName) { + //First check if the remote supports transferring album art + if (!supportAlbumArtPayload) return false; + if (url.isEmpty()) return false; + + MprisPlayer player = getPlayerStatus(playerName); + if (player == null) return false; + + if (player.albumArtUrl.equals(url)) { + NetworkPacket np = new NetworkPacket(PACKET_TYPE_MPRIS_REQUEST); + np.set("player", player.getPlayer()); + np.set("albumArtUrl", url); + device.sendPacket(np); + return true; + } + 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; @@ -127,6 +128,7 @@ PluginFactory.registerPlugin(TelepathyPlugin.class); PluginFactory.registerPlugin(FindMyPhonePlugin.class); PluginFactory.registerPlugin(RunCommandPlugin.class); + PluginFactory.registerPlugin(ContactsPlugin.class); PluginFactory.registerPlugin(RemoteKeyboardPlugin.class); } diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java --- a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.java @@ -323,6 +323,7 @@ if (device.isPairRequestedByPeer()) { ((TextView) rootView.findViewById(R.id.pair_message)).setText(R.string.pair_requested); + rootView.findViewById(R.id.pairing_buttons).setVisibility(View.VISIBLE); rootView.findViewById(R.id.pair_progress).setVisibility(View.GONE); rootView.findViewById(R.id.pair_button).setVisibility(View.GONE); rootView.findViewById(R.id.pair_request).setVisibility(View.VISIBLE); diff --git a/src/org/kde/kdeconnect/UserInterface/PairingFragment.java b/src/org/kde/kdeconnect/UserInterface/PairingFragment.java --- a/src/org/kde/kdeconnect/UserInterface/PairingFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/PairingFragment.java @@ -159,28 +159,28 @@ SectionItem section; Resources res = getResources(); - section = new SectionItem(res.getString(R.string.category_not_paired_devices)); + section = new SectionItem(res.getString(R.string.category_connected_devices)); section.isSectionEmpty = true; items.add(section); for (Device device : devices) { - if (device.isReachable() && !device.isPaired()) { + if (device.isReachable() && device.isPaired()) { items.add(new PairingDeviceItem(device, PairingFragment.this)); section.isSectionEmpty = false; } } + if (section.isSectionEmpty) { + items.remove(items.size() - 1); //Remove connected devices section if empty + } - section = new SectionItem(res.getString(R.string.category_connected_devices)); + section = new SectionItem(res.getString(R.string.category_not_paired_devices)); section.isSectionEmpty = true; items.add(section); for (Device device : devices) { - if (device.isReachable() && device.isPaired()) { + if (device.isReachable() && !device.isPaired()) { items.add(new PairingDeviceItem(device, PairingFragment.this)); section.isSectionEmpty = false; } } - if (section.isSectionEmpty) { - items.remove(items.size() - 1); //Remove connected devices section if empty - } section = new SectionItem(res.getString(R.string.category_remembered_devices)); section.isSectionEmpty = true;