diff --git a/src/org/kde/kdeconnect/Helpers/SMSHelper.java b/src/org/kde/kdeconnect/Helpers/SMSHelper.java new file mode 100644 index 00000000..d8d2e903 --- /dev/null +++ b/src/org/kde/kdeconnect/Helpers/SMSHelper.java @@ -0,0 +1,277 @@ +/* + * 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.Helpers; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.Telephony; +import android.support.annotation.RequiresApi; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SMSHelper { + + /** + * Get the base address for the SMS content + *

+ * If we want to support API < 19, it seems to be possible to read via this query + * This is highly undocumented and very likely varies between vendors but appears to work + */ + protected static Uri getSMSURIBad() { + return Uri.parse("content://sms/"); + } + + /** + * Get the base address for the SMS content + *

+ * Use the new API way which should work on any phone API >= 19 + */ + @RequiresApi(Build.VERSION_CODES.KITKAT) + protected static Uri getSMSURIGood() { + // TODO: Why not use Telephony.MmsSms.CONTENT_URI? + return Telephony.Sms.CONTENT_URI; + } + + protected static Uri getSMSUri() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return getSMSURIGood(); + } else { + return getSMSURIBad(); + } + } + + /** + * Get the base address for all message conversations + */ + protected static Uri getConversationUri() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return Telephony.MmsSms.CONTENT_CONVERSATIONS_URI; + } else { + // As with getSMSUriBad, this is potentially unsafe depending on whether a specific + // manufacturer decided to do their own thing + return Uri.parse("content://mms-sms/conversations"); + } + } + + /** + * Get all the messages in a requested thread + * + * @param context android.content.Context running the request + * @param threadID Thread to look up + * @return List of all messages in the thread + */ + public static List getMessagesInThread(Context context, ThreadID threadID) { + 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); + String body = smsCursor.getString(columnIdx); + messageInfo.put(colName, body); + } + toReturn.add(new Message(messageInfo)); + } while (smsCursor.moveToNext()); + } else { + // No SMSes available? + } + + if (smsCursor != null) { + smsCursor.close(); + } + + return toReturn; + } + + /** + * Get the last message from each conversation. Can use those thread_ids to look up more + * messages in those conversations + * + * @param context android.content.Context running the request + * @return Mapping of thread_id to the first message in each thread + */ + public static Map getConversations(Context context) { + HashMap toReturn = new HashMap<>(); + + Uri conversationUri = getConversationUri(); + + Cursor conversationsCursor = context.getContentResolver().query( + conversationUri, + Message.smsColumns, + null, + null, + null); + + if (conversationsCursor != null && conversationsCursor.moveToFirst()) { + int threadColumn = conversationsCursor.getColumnIndexOrThrow(ThreadID.lookupColumn); + do { + int thread = conversationsCursor.getInt(threadColumn); + + HashMap messageInfo = new HashMap<>(); + for (int columnIdx = 0; columnIdx < conversationsCursor.getColumnCount(); columnIdx++) { + String colName = conversationsCursor.getColumnName(columnIdx); + String body = conversationsCursor.getString(columnIdx); + messageInfo.put(colName, body); + } + toReturn.put(new ThreadID(thread), new Message(messageInfo)); + } while (conversationsCursor.moveToNext()); + } else { + // No conversations available? + } + + if (conversationsCursor != null) { + conversationsCursor.close(); + } + + return toReturn; + } + + /** + * Represent an ID used to uniquely identify a message thread + */ + public static class ThreadID { + Integer threadID; + static final String lookupColumn = Telephony.Sms.THREAD_ID; + + public ThreadID(Integer threadID) { + this.threadID = threadID; + } + + public String toString() { + return this.threadID.toString(); + } + + @Override + public int hashCode() { + return this.threadID.hashCode(); + } + + @Override + public boolean equals(Object other) { + if (other.getClass().isAssignableFrom(ThreadID.class)) { + return ((ThreadID) other).threadID.equals(this.threadID); + } + + return false; + } + } + + /** + * Represent a message and all of its interesting data columns + */ + public static class Message { + + public final String m_address; + public final String m_body; + public final long m_date; + public final int m_type; + public final int m_read; + public final int m_threadID; + public final int m_uID; + + /** + * Named constants which are used to construct a Message + * See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html for full documentation + */ + public static final String ADDRESS = Telephony.Sms.ADDRESS; // Contact information (phone number or otherwise) of the remote + public static final String BODY = Telephony.Sms.BODY; // Body of the message + public static final String DATE = Telephony.Sms.DATE; // Date (Unix epoch millis) associated with the message + public static final String TYPE = Telephony.Sms.TYPE; // Compare with Telephony.TextBasedSmsColumns.MESSAGE_TYPE_* + public static final String READ = Telephony.Sms.READ; // Whether we have received a read report for this message (int) + public static final String THREAD_ID = ThreadID.lookupColumn; // Magic number which binds (message) threads + public static final String U_ID = Telephony.Sms._ID; // Something which uniquely identifies this message + + /** + * Define the columns which are to be extracted from the Android SMS database + */ + public static final String[] smsColumns = new String[]{ + Message.ADDRESS, + Message.BODY, + Message.DATE, + Message.TYPE, + Message.READ, + Message.THREAD_ID, + Message.U_ID, + }; + + public Message(final HashMap messageInfo) { + m_address = messageInfo.get(Message.ADDRESS); + m_body = messageInfo.get(Message.BODY); + m_date = Long.parseLong(messageInfo.get(Message.DATE)); + if (messageInfo.get(Message.TYPE) == null) + { + // To be honest, I have no idea why this happens. The docs say the TYPE field is mandatory. + // Just stick some junk in here and hope we can figure it out later. + // Quick investigation suggests that these are multi-target MMSes + m_type = -1; + } else { + m_type = Integer.parseInt(messageInfo.get(Message.TYPE)); + } + m_read = Integer.parseInt(messageInfo.get(Message.READ)); + m_threadID = Integer.parseInt(messageInfo.get(Message.THREAD_ID)); + m_uID = Integer.parseInt(messageInfo.get(Message.U_ID)); + } + + public JSONObject toJSONObject() throws JSONException { + JSONObject json = new JSONObject(); + + json.put(Message.ADDRESS, m_address); + json.put(Message.BODY, m_body); + json.put(Message.DATE, m_date); + json.put(Message.TYPE, m_type); + json.put(Message.READ, m_read); + json.put(Message.THREAD_ID, m_threadID); + json.put(Message.U_ID, m_uID); + + return json; + } + + @Override + public String toString() { + return this.m_body; + } + } +} + diff --git a/src/org/kde/kdeconnect/Plugins/TelephonyPlugin/TelephonyPlugin.java b/src/org/kde/kdeconnect/Plugins/TelephonyPlugin/TelephonyPlugin.java index 1c5e58df..8c182d21 100644 --- a/src/org/kde/kdeconnect/Plugins/TelephonyPlugin/TelephonyPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/TelephonyPlugin/TelephonyPlugin.java @@ -1,338 +1,470 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.TelephonyPlugin; import android.Manifest; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.media.AudioManager; +import android.net.Network; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.v4.content.ContextCompat; import android.telephony.PhoneNumberUtils; import android.telephony.SmsMessage; import android.telephony.TelephonyManager; import android.util.Log; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import org.kde.kdeconnect.Helpers.ContactsHelper; +import org.kde.kdeconnect.Helpers.SMSHelper; +import org.kde.kdeconnect.Helpers.SMSHelper.ThreadID; +import org.kde.kdeconnect.Helpers.SMSHelper.Message; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect_tp.BuildConfig; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; public class TelephonyPlugin extends Plugin { + /** + * Packet used to indicate a batch of messages has been pushed from the remote device + * + * The body should contain the key "messages" mapping to an array of messages + * + * For example: + * { "messages" : [ + * { "event" : "sms", + * "messageBody" : "Hello", + * "phoneNumber" : "2021234567", + * "messageDate" : "1518846484880", + * "messageType" : "2", + * "threadID" : "132" + * }, + * { ... }, + * ... + * ] + */ + private final static String PACKET_TYPE_TELEPHONY_MESSAGE = "kdeconnect.telephony.message"; + + /** + * Packet used for simple telephony events + * + * It contains the key "event" which maps to a string indicating the type of event: + * - "ringing" - A phone call is incoming + * - "missedCall" - An incoming call was not answered + * - "sms" - An incoming SMS message + * - Note: As of this writing (15 May 2018) the SMS interface is being improved and this type of event + * is no longer the preferred way of retrieving SMS. Use PACKET_TYPE_TELEPHONY_MESSAGE instead. + * + * Depending on the event, other fields may be defined + */ private final static String PACKET_TYPE_TELEPHONY = "kdeconnect.telephony"; public final static String PACKET_TYPE_TELEPHONY_REQUEST = "kdeconnect.telephony.request"; private static final String KEY_PREF_BLOCKED_NUMBERS = "telephony_blocked_numbers"; + /** + * Packet sent to request all conversations + * + * The request packet shall contain no body + */ + public final static String PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATIONS = "kdeconnect.telephony.request_conversations"; + + /** + * Packet sent to request all the messages in a particular conversation + * + * The body should contain the key "threadID" mapping to the threadID (as a string) being requested + * For example: + * { "threadID": 203 } + */ + public final static String PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATION = "kdeconnect.telephony.request_conversation"; + private int lastState = TelephonyManager.CALL_STATE_IDLE; private NetworkPacket lastPacket = null; private boolean isMuted = false; private final BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); //Log.e("TelephonyPlugin","Telephony event: " + action); if ("android.provider.Telephony.SMS_RECEIVED".equals(action)) { final Bundle bundle = intent.getExtras(); if (bundle == null) return; final Object[] pdus = (Object[]) bundle.get("pdus"); ArrayList messages = new ArrayList<>(); for (Object pdu : pdus) { // I hope, but am not sure, that the pdus array is in the order that the parts // of the SMS message should be // If it is not, I believe the pdu contains the information necessary to put it // in order, but in my testing the order seems to be correct, so I won't worry // about it now. messages.add(SmsMessage.createFromPdu((byte[]) pdu)); } smsBroadcastReceived(messages); } else if (TelephonyManager.ACTION_PHONE_STATE_CHANGED.equals(action)) { String state = intent.getStringExtra(TelephonyManager.EXTRA_STATE); int intState = TelephonyManager.CALL_STATE_IDLE; if (state.equals(TelephonyManager.EXTRA_STATE_RINGING)) intState = TelephonyManager.CALL_STATE_RINGING; else if (state.equals(TelephonyManager.EXTRA_STATE_OFFHOOK)) intState = TelephonyManager.CALL_STATE_OFFHOOK; String number = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER); if (number == null) number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER); final int finalIntState = intState; final String finalNumber = number; callBroadcastReceived(finalIntState, finalNumber); } } }; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_telephony); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_telephony_desc); } private void callBroadcastReceived(int state, String phoneNumber) { if (isNumberBlocked(phoneNumber)) return; NetworkPacket np = new NetworkPacket(PACKET_TYPE_TELEPHONY); int permissionCheck = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS); if (permissionCheck == PackageManager.PERMISSION_GRANTED) { Map contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber); if (contactInfo.containsKey("name")) { np.set("contactName", contactInfo.get("name")); } if (contactInfo.containsKey("photoID")) { String photoUri = contactInfo.get("photoID"); if (photoUri != null) { try { String base64photo = ContactsHelper.photoId64Encoded(context, photoUri); if (base64photo != null && !base64photo.isEmpty()) { np.set("phoneThumbnail", base64photo); } } catch (Exception e) { Log.e("TelephonyPlugin", "Failed to get contact photo"); } } } } else { np.set("contactName", phoneNumber); } if (phoneNumber != null) { np.set("phoneNumber", phoneNumber); } switch (state) { case TelephonyManager.CALL_STATE_RINGING: if (isMuted) { AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { am.setStreamVolume(AudioManager.STREAM_RING, AudioManager.ADJUST_UNMUTE, 0); } else { am.setStreamMute(AudioManager.STREAM_RING, false); } isMuted = false; } np.set("event", "ringing"); device.sendPacket(np); break; case TelephonyManager.CALL_STATE_OFFHOOK: //Ongoing call np.set("event", "talking"); device.sendPacket(np); break; case TelephonyManager.CALL_STATE_IDLE: if (lastState != TelephonyManager.CALL_STATE_IDLE && lastPacket != null) { //Resend a cancel of the last event (can either be "ringing" or "talking") lastPacket.set("isCancel", "true"); device.sendPacket(lastPacket); if (isMuted) { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { if (isMuted) { AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { am.setStreamVolume(AudioManager.STREAM_RING, AudioManager.ADJUST_UNMUTE, 0); } else { am.setStreamMute(AudioManager.STREAM_RING, false); } isMuted = false; } } }, 500); } //Emit a missed call notification if needed if (lastState == TelephonyManager.CALL_STATE_RINGING) { np.set("event", "missedCall"); np.set("phoneNumber", lastPacket.getString("phoneNumber", null)); np.set("contactName", lastPacket.getString("contactName", null)); device.sendPacket(np); } } break; } lastPacket = np; lastState = state; } private void smsBroadcastReceived(ArrayList messages) { if (BuildConfig.DEBUG) { if (!(messages.size() > 0)) { throw new AssertionError("This method requires at least one message"); } } NetworkPacket np = new NetworkPacket(PACKET_TYPE_TELEPHONY); np.set("event", "sms"); StringBuilder messageBody = new StringBuilder(); for (int index = 0; index < messages.size(); index++) { messageBody.append(messages.get(index).getMessageBody()); } np.set("messageBody", messageBody.toString()); String phoneNumber = messages.get(0).getOriginatingAddress(); if (isNumberBlocked(phoneNumber)) return; int permissionCheck = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS); if (permissionCheck == PackageManager.PERMISSION_GRANTED) { Map contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber); if (contactInfo.containsKey("name")) { np.set("contactName", contactInfo.get("name")); } if (contactInfo.containsKey("photoID")) { np.set("phoneThumbnail", ContactsHelper.photoId64Encoded(context, contactInfo.get("photoID"))); } } if (phoneNumber != null) { np.set("phoneNumber", phoneNumber); } device.sendPacket(np); } @Override public boolean onCreate() { IntentFilter filter = new IntentFilter("android.provider.Telephony.SMS_RECEIVED"); filter.setPriority(500); filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); context.registerReceiver(receiver, filter); permissionExplanation = R.string.telephony_permission_explanation; optionalPermissionExplanation = R.string.telephony_optional_permission_explanation; return true; } @Override public void onDestroy() { context.unregisterReceiver(receiver); } @Override public boolean onPacketReceived(NetworkPacket np) { + if (np.getType().equals(PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATIONS)) { + return this.handleRequestConversations(np); + } + else if (np.getType().equals(PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATION)) { + return this.handleRequestConversation(np); + } if (np.getString("action").equals("mute")) { if (!isMuted) { AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { am.setStreamVolume(AudioManager.STREAM_RING, AudioManager.ADJUST_MUTE, 0); } else { am.setStreamMute(AudioManager.STREAM_RING, true); } isMuted = true; } } //Do nothing return true; } private boolean isNumberBlocked(String number) { SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context); String[] blockedNumbers = sharedPref.getString(KEY_PREF_BLOCKED_NUMBERS, "").split("\n"); for (String s : blockedNumbers) { if (PhoneNumberUtils.compare(number, s)) return true; } return false; } + /** + * Respond to a request for all conversations + * + * Send one packet of type PACKET_TYPE_TELEPHONY_MESSAGE with the first message in all conversations + */ + protected boolean handleRequestConversations(NetworkPacket packet) { + Map conversations = SMSHelper.getConversations(this.context); + + NetworkPacket reply = new NetworkPacket(PACKET_TYPE_TELEPHONY_MESSAGE); + + JSONArray messages = new JSONArray(); + + for (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"); + } + } + + reply.set("messages", messages); + reply.set("event", "batch_messages"); // Not really necessary, since this is implied by PACKET_TYPE_TELEPHONY_MESSAGE, but good for readability + + device.sendPacket(reply); + + return true; + } + + protected boolean handleRequestConversation(NetworkPacket packet) { + ThreadID threadID = new ThreadID(packet.getInt("threadID")); + + List conversation = SMSHelper.getMessagesInThread(this.context, threadID); + + NetworkPacket reply = new NetworkPacket(PACKET_TYPE_TELEPHONY_MESSAGE); + + JSONArray messages = new JSONArray(); + + for (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"); + + device.sendPacket(reply); + + return true; + } + @Override public String[] getSupportedPacketTypes() { - return new String[]{PACKET_TYPE_TELEPHONY_REQUEST}; + return new String[]{ + PACKET_TYPE_TELEPHONY_REQUEST, + PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATIONS, + PACKET_TYPE_TELEPHONY_REQUEST_CONVERSATION, + }; } @Override public String[] getOutgoingPacketTypes() { - return new String[]{PACKET_TYPE_TELEPHONY}; + return new String[]{ + PACKET_TYPE_TELEPHONY, + PACKET_TYPE_TELEPHONY_MESSAGE, + }; } @Override public String[] getRequiredPermissions() { return new String[]{Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_SMS}; } @Override public String[] getOptionalPermissions() { return new String[]{Manifest.permission.READ_CONTACTS}; } @Override public boolean hasSettings() { return true; } }