From df42f992c8db5f04f2c9700ea3d36acf259e7942 Mon Sep 17 00:00:00 2001 From: Simon Redman Date: Mon, 17 Sep 2018 08:52:54 -0600 Subject: [PATCH] [Android] New-style Message received handling Summary: Mark old-style SMS packet sending as deprecated (but still supported for backwards-compatibility with old Desktop apps) Implement a ContentObserver on the Messages database, then send an update to the remote for all incoming or outgoing messages Test Plan: 1. DBus - Connect Android to desktop - Subscribe to /modules/kdeconnect/devices//org.kdeconnect.device.conversations/conversationUpdated - Receive a new message (text yourself?) - Verify that something comes back on dbus (This endpoint is updated by D15409. With that patch you should see the new Message object, before you should just get the (meaningless) conversation ID) 2. SMS App - Relies on D15409 - See instructions there Reviewers: #kde_connect, nicolasfella Reviewed By: #kde_connect, nicolasfella Subscribers: nicolasfella, kdeconnect Tags: #kde_connect Differential Revision: https://phabricator.kde.org/D15360 --- src/org/kde/kdeconnect/Helpers/SMSHelper.java | 113 +++++++++++- .../Plugins/SMSPlugin/SMSPlugin.java | 161 ++++++++++++++---- 2 files changed, 233 insertions(+), 41 deletions(-) diff --git a/src/org/kde/kdeconnect/Helpers/SMSHelper.java b/src/org/kde/kdeconnect/Helpers/SMSHelper.java index d8d2e903..f4b6b6b8 100644 --- a/src/org/kde/kdeconnect/Helpers/SMSHelper.java +++ b/src/org/kde/kdeconnect/Helpers/SMSHelper.java @@ -21,11 +21,14 @@ package org.kde.kdeconnect.Helpers; import android.content.Context; +import android.database.ContentObserver; import android.database.Cursor; import android.net.Uri; import android.os.Build; +import android.os.Looper; import android.provider.Telephony; import android.support.annotation.RequiresApi; +import android.util.Log; import org.json.JSONException; import org.json.JSONObject; @@ -34,6 +37,9 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; public class SMSHelper { @@ -87,13 +93,38 @@ public class SMSHelper { * @return List of all messages in the thread */ public static List getMessagesInThread(Context context, ThreadID threadID) { + final String selection = ThreadID.lookupColumn + " == ?"; + final String[] selectionArgs = new String[] { threadID.toString() }; + + return getMessagesWithFilter(context, selection, selectionArgs); + } + + /** + * Get all messages which have a timestamp after the requested timestamp + * + * @param timestamp epoch in millis matching the timestamp to return + * @return null if no matching message is found, otherwise return a Message + */ + public static List getMessagesSinceTimestamp(Context context, long timestamp) { + final String selection = Message.DATE + " > ?"; + final String[] selectionArgs = new String[] {Long.toString(timestamp)}; + + List messages = getMessagesWithFilter(context, selection, selectionArgs); + return messages; + } + + /** + * Get all messages matching the passed filter. See documentation for Android's ContentResolver + * + * @param selection Parameterizable filter to use with the ContentResolver query. May be null. + * @param selectionArgs Parameters for selection. May be null. + * @return List of messages matching the filter + */ + private static List getMessagesWithFilter(Context context, String selection, String[] selectionArgs) { List toReturn = new ArrayList<>(); Uri smsUri = getSMSUri(); - final String selection = ThreadID.lookupColumn + " == ?"; - final String[] selectionArgs = new String[] { threadID.toString() }; - Cursor smsCursor = context.getContentResolver().query( smsUri, Message.smsColumns, @@ -102,10 +133,7 @@ public class SMSHelper { null); if (smsCursor != null && smsCursor.moveToFirst()) { - int threadColumn = smsCursor.getColumnIndexOrThrow(ThreadID.lookupColumn); do { - int thread = smsCursor.getInt(threadColumn); - HashMap messageInfo = new HashMap<>(); for (int columnIdx = 0; columnIdx < smsCursor.getColumnCount(); columnIdx++) { String colName = smsCursor.getColumnName(columnIdx); @@ -168,6 +196,19 @@ public class SMSHelper { return toReturn; } + /** + * Register a ContentObserver for the Messages database + * + * @param observer ContentObserver to alert on Message changes + */ + public static void registerObserver(ContentObserver observer, Context context) { + context.getContentResolver().registerContentObserver( + SMSHelper.getSMSUri(), + true, + observer + ); + } + /** * Represent an ID used to uniquely identify a message thread */ @@ -273,5 +314,65 @@ public class SMSHelper { return this.m_body; } } + + /** + * If anyone wants to subscribe to changes in the messages database, they will need a thread + * to handle callbacks on + * This singleton conveniently provides such a thread, accessed and used via its Looper object + */ + public static class MessageLooper extends Thread { + private static MessageLooper singleton = null; + private static Looper looper = null; + + private static Lock looperReadyLock = new ReentrantLock(); + private static Condition looperReady = looperReadyLock.newCondition(); + + private MessageLooper() { + setName("MessageHelperLooper"); + } + + /** + * Get the Looper object associated with this thread + * + * If the Looper has not been prepared, it is prepared as part of this method call. + * Since this means a thread has to be spawned, this method might block until that thread is + * ready to serve requests + */ + public static Looper getLooper() { + if (singleton == null) { + looperReadyLock.lock(); + try { + singleton = new MessageLooper(); + singleton.start(); + while (looper == null) { + // Block until the looper is ready + looperReady.await(); + } + } catch (InterruptedException e) { + // I don't know when this would happen + Log.e("SMSHelper", "Interrupted while waiting for Looper", e); + return null; + } finally { + looperReadyLock.unlock(); + } + } + + return looper; + } + + public void run() { + looperReadyLock.lock(); + try { + Looper.prepare(); + + looper = Looper.myLooper(); + looperReady.signalAll(); + } finally { + looperReadyLock.unlock(); + } + + Looper.loop(); + } + } } diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java index 02d2019f..42195c4b 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java @@ -27,7 +27,10 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.database.ContentObserver; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.preference.PreferenceManager; import android.support.v4.content.ContextCompat; import android.telephony.PhoneNumberUtils; @@ -47,8 +50,11 @@ import org.kde.kdeconnect_tp.BuildConfig; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import static org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin.PACKET_TYPE_TELEPHONY; @@ -130,13 +136,89 @@ public class SMSPlugin extends Plugin { messages.add(SmsMessage.createFromPdu((byte[]) pdu)); } - smsBroadcastReceived(messages); - + smsBroadcastReceivedDeprecated(messages); } } }; - private void smsBroadcastReceived(ArrayList messages) { + /** + * Keep track of the most-recently-seen message so that we can query for later ones as they arrive + */ + private long mostRecentTimestamp = 0; + // Since the mostRecentTimestamp is accessed both from the plugin's thread and the ContentObserver + // thread, make sure that access is coherent + private Lock mostRecentTimestampLock = new ReentrantLock(); + + private class MessageContentObserver extends ContentObserver { + SMSPlugin mPlugin; + + /** + * Create a ContentObserver to watch the Messages database. onChange is called for + * every subscribed change + * + * @param parent Plugin which owns this observer + * @param handler Handler object used to make the callback + */ + public MessageContentObserver(SMSPlugin parent, Handler handler) { + super(handler); + mPlugin = parent; + } + + @Override + /** + * The onChange method is called whenever the subscribed-to database changes + * + * In this case, this onChange expects to be called whenever *anything* in the Messages + * database changes and simply reports those updated messages to anyone who might be listening + */ + public void onChange(boolean selfChange) { + if (mPlugin.mostRecentTimestamp == 0) { + // Since the timestamp has not been initialized, we know that nobody else + // has requested a message. That being the case, there is most likely + // nobody listening for message updates, so just drop them + return; + } + mostRecentTimestampLock.lock(); + // Grab the mostRecentTimestamp into the local stack because reading the Messages + // database could potentially be a long operation + long mostRecentTimestamp = mPlugin.mostRecentTimestamp; + mostRecentTimestampLock.unlock(); + + List messages = SMSHelper.getMessagesSinceTimestamp(mPlugin.context, mostRecentTimestamp); + + if (messages.size() == 0) { + // Our onChange often gets called many times for a single message. Don't make unnecessary + // noise + return; + } + + // Update the most recent counter + mostRecentTimestampLock.lock(); + for (SMSHelper.Message message : messages) { + if (message.m_date > mostRecentTimestamp) { + mPlugin.mostRecentTimestamp = message.m_date; + } + } + mostRecentTimestampLock.unlock(); + + // Send the alert about the update + device.sendPacket(constructBulkMessagePacket(messages)); + } + } + + @Deprecated + /** + * Deliver an old-style SMS packet in response to a new message arriving + * + * For backwards-compatibility with long-lived distro packages, this method needs to exist in + * order to support older desktop apps. However, note that it should no longer be used + * + * This comment is being written 30 August 2018. Distros will likely be running old versions for + * many years to come... + * + * @param messages Ordered list of parts of the message body which should be combined into a single message + */ + private void smsBroadcastReceivedDeprecated(ArrayList messages) { if (BuildConfig.DEBUG) { if (!(messages.size() > 0)) { @@ -189,6 +271,10 @@ public class SMSPlugin extends Plugin { filter.setPriority(500); context.registerReceiver(receiver, filter); + Looper helperLooper = SMSHelper.MessageLooper.getLooper(); + ContentObserver messageObserver = new MessageContentObserver(this, new Handler(helperLooper)); + SMSHelper.registerObserver(messageObserver, context); + return true; } @@ -239,6 +325,35 @@ public class SMSPlugin extends Plugin { return true; } + /** + * Construct a proper packet of PACKET_TYPE_SMS_MESSAGE from the passed messages + * + * @param messages Messages to include in the packet + * @return NetworkPacket of type PACKET_TYPE_SMS_MESSAGE + */ + public static NetworkPacket constructBulkMessagePacket(Collection messages) { + NetworkPacket reply = new NetworkPacket(PACKET_TYPE_SMS_MESSAGE); + + JSONArray body = new JSONArray(); + + for (SMSHelper.Message message : messages) { + try { + JSONObject json = message.toJSONObject(); + + json.put("event", "sms"); + + body.put(json); + } catch (JSONException e) { + Log.e("Conversations", "Error serializing message"); + } + } + + reply.set("messages", body); + reply.set("event", "batch_messages"); + + return reply; + } + /** * Respond to a request for all conversations *

@@ -247,24 +362,17 @@ public class SMSPlugin extends Plugin { private boolean handleRequestConversations(NetworkPacket packet) { Map conversations = SMSHelper.getConversations(this.context); - NetworkPacket reply = new NetworkPacket(PACKET_TYPE_SMS_MESSAGE); - - JSONArray messages = new JSONArray(); - + // Prepare the mostRecentTimestamp counter based on these messages, since they are the most + // recent in every conversation + mostRecentTimestampLock.lock(); for (SMSHelper.Message message : conversations.values()) { - try { - JSONObject json = message.toJSONObject(); - - json.put("event", "sms"); - - messages.put(json); - } catch (JSONException e) { - Log.e("Conversations", "Error serializing message"); + if (message.m_date > mostRecentTimestamp) { + mostRecentTimestamp = message.m_date; } } + mostRecentTimestampLock.unlock(); - reply.set("messages", messages); - reply.set("event", "batch_messages"); // Not really necessary, since this is implied by PACKET_TYPE_SMS_MESSAGE, but good for readability + NetworkPacket reply = constructBulkMessagePacket(conversations.values()); device.sendPacket(reply); @@ -276,24 +384,7 @@ public class SMSPlugin extends Plugin { List conversation = SMSHelper.getMessagesInThread(this.context, threadID); - NetworkPacket reply = new NetworkPacket(PACKET_TYPE_SMS_MESSAGE); - - JSONArray messages = new JSONArray(); - - for (SMSHelper.Message message : conversation) { - try { - JSONObject json = message.toJSONObject(); - - json.put("event", "sms"); - - messages.put(json); - } catch (JSONException e) { - Log.e("Conversations", "Error serializing message"); - } - } - - reply.set("messages", messages); - reply.set("event", "batch_messages"); + NetworkPacket reply = constructBulkMessagePacket(conversation); device.sendPacket(reply);