Index: src/org/kde/kdeconnect/Helpers/SMSHelper.java =================================================================== --- src/org/kde/kdeconnect/Helpers/SMSHelper.java +++ src/org/kde/kdeconnect/Helpers/SMSHelper.java @@ -21,19 +21,25 @@ 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; 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,25 +93,47 @@ * @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, selection, selectionArgs, 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 @@ 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 @@ 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(); + } + } } Index: src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java =================================================================== --- src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java +++ src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java @@ -27,7 +27,10 @@ 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.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 @@ 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 and 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 @@ 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; } @@ -240,31 +326,53 @@ } /** - * Respond to a request for all conversations - *

- * Send one packet of type PACKET_TYPE_SMS_MESSAGE with the first message in all conversations + * 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 */ - private boolean handleRequestConversations(NetworkPacket packet) { - Map conversations = SMSHelper.getConversations(this.context); - + public static NetworkPacket constructBulkMessagePacket(Collection messages) { NetworkPacket reply = new NetworkPacket(PACKET_TYPE_SMS_MESSAGE); - JSONArray messages = new JSONArray(); + JSONArray body = new JSONArray(); - for (SMSHelper.Message message : conversations.values()) { + for (SMSHelper.Message message : messages) { try { JSONObject json = message.toJSONObject(); json.put("event", "sms"); - messages.put(json); + body.put(json); } catch (JSONException e) { Log.e("Conversations", "Error serializing message"); } } - 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 + reply.set("messages", body); + reply.set("event", "batch_messages"); + + return reply; + } + + /** + * Respond to a request for all conversations + *

+ * Send one packet of type PACKET_TYPE_SMS_MESSAGE with the first message in all conversations + */ + protected boolean handleRequestConversations(NetworkPacket packet) { + Map conversations = SMSHelper.getConversations(this.context); + + // 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()) { + if (message.m_date > mostRecentTimestamp) { + mostRecentTimestamp = message.m_date; + } + } + mostRecentTimestampLock.unlock(); + + NetworkPacket reply = constructBulkMessagePacket(conversations.values()); device.sendPacket(reply); @@ -276,24 +384,7 @@ 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);