diff --git a/src/org/kde/kdeconnect/Helpers/SMSHelper.java b/src/org/kde/kdeconnect/Helpers/SMSHelper.java index d2342814..7f1f5047 100644 --- a/src/org/kde/kdeconnect/Helpers/SMSHelper.java +++ b/src/org/kde/kdeconnect/Helpers/SMSHelper.java @@ -1,391 +1,386 @@ /* * 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.annotation.SuppressLint; 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.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; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +@SuppressLint("InlinedApi") 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 */ private 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) private static Uri getSMSURIGood() { // TODO: Why not use Telephony.MmsSms.CONTENT_URI? return Telephony.Sms.CONTENT_URI; } private 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 */ private 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static List getMessagesSinceTimestamp(Context context, long timestamp) { final String selection = Message.DATE + " > ?"; final String[] selectionArgs = new String[] {Long.toString(timestamp)}; return getMessagesWithFilter(context, selection, selectionArgs); } /** * Gets Messages for caller functions, such as: getMessagesWithFilter() and getConversations() * * @param Uri Uri indicating the messages database to read * @param context android.content.Context running the request. * @param selection Parameterizable filter to use with the ContentResolver query. May be null. * @param selectionArgs Parameters for selection. May be null. * @return Returns HashMap>, which is transformed in caller functions into other classes. */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) private static HashMap> getMessages(Uri Uri, Context context, String selection, String[] selectionArgs) { HashMap> toReturn = new HashMap<>(); try (Cursor myCursor = context.getContentResolver().query( Uri, Message.smsColumns, selection, selectionArgs, null) ) { if (myCursor != null && myCursor.moveToFirst()) { int threadColumn = myCursor.getColumnIndexOrThrow(ThreadID.lookupColumn); do { HashMap messageInfo = new HashMap<>(); for (int columnIdx = 0; columnIdx < myCursor.getColumnCount(); columnIdx++) { String colName = myCursor.getColumnName(columnIdx); String body = myCursor.getString(columnIdx); messageInfo.put(colName, body); } Message message = new Message(messageInfo); ThreadID threadID = new ThreadID(message.threadID); if (!toReturn.containsKey(threadID)) { toReturn.put(threadID, new ArrayList<>()); } toReturn.get(threadID).add(message); } while (myCursor.moveToNext()); } else { // No conversations or SMSes available? } } return toReturn; } /** * Get all messages matching the passed filter. See documentation for Android's ContentResolver * * @param context android.content.Context running the request * @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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) private static List getMessagesWithFilter(Context context, String selection, String[] selectionArgs) { HashMap> result = getMessages(SMSHelper.getSMSUri(), context, selection, selectionArgs); List toReturn = new ArrayList<>(); for(Map.Entry> entry : result.entrySet()) { toReturn.addAll(entry.getValue()); } 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static Map getConversations(Context context) { HashMap> result = getMessages(SMSHelper.getConversationUri(), context, null, null); HashMap toReturn = new HashMap<>(); for(Map.Entry> entry : result.entrySet()) { ThreadID returnThreadID = entry.getKey(); List messages = entry.getValue(); toReturn.put(returnThreadID, messages.get(0)); } 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static class ThreadID { final Long threadID; static final String lookupColumn = Telephony.Sms.THREAD_ID; public ThreadID(Long threadID) { this.threadID = threadID; } @NonNull public String toString() { return threadID.toString(); } @Override public int hashCode() { return threadID.hashCode(); } @Override public boolean equals(Object other) { return other.getClass().isAssignableFrom(ThreadID.class) && ((ThreadID) other).threadID.equals(this.threadID); } } /** * Represent a message and all of its interesting data columns */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) public static class Message { final String address; final String body; public final long date; final int type; final int read; final long threadID; // ThreadID is *int* for SMS messages but *long* for MMS final int uID; /** * Named constants which are used to construct a Message * See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html for full documentation */ static final String ADDRESS = Telephony.Sms.ADDRESS; // Contact information (phone number or otherwise) of the remote static final String BODY = Telephony.Sms.BODY; // Body of the message static final String DATE = Telephony.Sms.DATE; // Date (Unix epoch millis) associated with the message static final String TYPE = Telephony.Sms.TYPE; // Compare with Telephony.TextBasedSmsColumns.MESSAGE_TYPE_* static final String READ = Telephony.Sms.READ; // Whether we have received a read report for this message (int) static final String THREAD_ID = ThreadID.lookupColumn; // Magic number which binds (message) threads static final String U_ID = Telephony.Sms._ID; // Something which uniquely identifies this message /** * Event flags * A message should have a bitwise-or of event flags before delivering the packet * Any events not supported by the receiving device should be ignored */ public static final int TEXT_MESSAGE = 0x1; // This message has a "body" field which contains // pure, human-readable text /** * Define the columns which are to be extracted from the Android SMS database */ static final String[] smsColumns = new String[]{ Message.ADDRESS, Message.BODY, Message.DATE, Message.TYPE, Message.READ, Message.THREAD_ID, Message.U_ID, }; Message(final HashMap messageInfo) { address = messageInfo.get(Message.ADDRESS); body = messageInfo.get(Message.BODY); 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 type = -1; } else { type = Integer.parseInt(messageInfo.get(Message.TYPE)); } read = Integer.parseInt(messageInfo.get(Message.READ)); threadID = Long.parseLong(messageInfo.get(Message.THREAD_ID)); uID = Integer.parseInt(messageInfo.get(Message.U_ID)); } public JSONObject toJSONObject() throws JSONException { JSONObject json = new JSONObject(); json.put(Message.ADDRESS, address); json.put(Message.BODY, body); json.put(Message.DATE, date); json.put(Message.TYPE, type); json.put(Message.READ, read); json.put(Message.THREAD_ID, threadID); json.put(Message.U_ID, uID); return json; } @Override public String toString() { return 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 final Lock looperReadyLock = new ReentrantLock(); private static final 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 d24c1a7c..92e87130 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java @@ -1,450 +1,449 @@ /* * Copyright 2014 Albert Vaca Cintora * Copyright 2019 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.SMSPlugin; import android.Manifest; +import android.annotation.SuppressLint; 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.database.ContentObserver; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; import android.provider.Telephony; import android.telephony.PhoneNumberUtils; import android.telephony.SmsManager; import android.telephony.SmsMessage; 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.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin; 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 androidx.annotation.RequiresApi; import androidx.core.content.ContextCompat; import static org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin.PACKET_TYPE_TELEPHONY; @PluginFactory.LoadablePlugin +@SuppressLint("InlinedApi") public class SMSPlugin 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" : 1, // 32-bit field containing a bitwise-or of event flags * // See constants declared in SMSHelper.Message for defined * // values and explanations * "body" : "Hello", // Text message body * "address" : "2021234567", // Sending or receiving address of the message * "date" : "1518846484880", // Timestamp of the message * "type" : "2", // Compare with Android's * // Telephony.TextBasedSmsColumns.MESSAGE_TYPE_* * "thread_id" : "132" // Thread to which the message belongs * "read" : true // Boolean representing whether a message is read or unread * }, * { ... }, * ... * ] */ private final static String PACKET_TYPE_SMS_MESSAGE = "kdeconnect.sms.messages"; /** * Packet sent to request a message be sent *

* This will almost certainly need to be replaced or augmented to support MMS, * but be sure the Android side remains compatible with old desktop apps! *

* The body should look like so: * { "sendSms": true, * "phoneNumber": "542904563213", * "messageBody": "Hi mom!" * } */ private final static String PACKET_TYPE_SMS_REQUEST = "kdeconnect.sms.request"; /** * Packet sent to request the most-recent message in each conversations on the device *

* The request packet shall contain no body */ private final static String PACKET_TYPE_SMS_REQUEST_CONVERSATIONS = "kdeconnect.sms.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 } */ private final static String PACKET_TYPE_SMS_REQUEST_CONVERSATION = "kdeconnect.sms.request_conversation"; private static final String KEY_PREF_BLOCKED_NUMBERS = "telephony_blocked_numbers"; 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 (Telephony.Sms.Intents.SMS_RECEIVED_ACTION.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)); } smsBroadcastReceivedDeprecated(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 final Lock mostRecentTimestampLock = new ReentrantLock(); private class MessageContentObserver extends ContentObserver { final 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 */ MessageContentObserver(SMSPlugin parent, Handler handler) { super(handler); mPlugin = parent; } /** * 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) @Override 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.date > mostRecentTimestamp) { mPlugin.mostRecentTimestamp = message.date; } } mostRecentTimestampLock.unlock(); // Send the alert about the update device.sendPacket(constructBulkMessagePacket(messages)); } } /** * 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 */ @Deprecated private void smsBroadcastReceivedDeprecated(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); } - @RequiresApi(api = Build.VERSION_CODES.KITKAT) @Override public boolean onCreate() { permissionExplanation = R.string.telepathy_permission_explanation; IntentFilter filter = new IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION); 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; } @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_telepathy); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_telepathy_desc); } - @RequiresApi(api = Build.VERSION_CODES.KITKAT) @Override public boolean onPacketReceived(NetworkPacket np) { switch (np.getType()) { case PACKET_TYPE_SMS_REQUEST_CONVERSATIONS: return this.handleRequestConversations(np); case PACKET_TYPE_SMS_REQUEST_CONVERSATION: return this.handleRequestConversation(np); case PACKET_TYPE_SMS_REQUEST: // Fall through to old-style handling // This space may be filled in differently once MMS support is implemented case TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST: if (np.getBoolean("sendSms")) { String phoneNo = np.getString("phoneNumber"); String sms = np.getString("messageBody"); try { SmsManager smsManager = SmsManager.getDefault(); ArrayList parts = smsManager.divideMessage(sms); // If this message turns out to fit in a single SMS, sendMultipartTextMessage // properly handles that case smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null); //TODO: Notify other end } catch (Exception e) { //TODO: Notify other end Log.e("SMSPlugin", "Exception", e); } } break; } 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 */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) private 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", SMSHelper.Message.TEXT_MESSAGE); 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 *

* Send one packet of type PACKET_TYPE_SMS_MESSAGE with the first message in all conversations */ - @RequiresApi(api = Build.VERSION_CODES.KITKAT) private 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.date > mostRecentTimestamp) { mostRecentTimestamp = message.date; } } mostRecentTimestampLock.unlock(); NetworkPacket reply = constructBulkMessagePacket(conversations.values()); device.sendPacket(reply); return true; } - @RequiresApi(api = Build.VERSION_CODES.KITKAT) private boolean handleRequestConversation(NetworkPacket packet) { SMSHelper.ThreadID threadID = new SMSHelper.ThreadID(packet.getLong("threadID")); List conversation = SMSHelper.getMessagesInThread(this.context, threadID); NetworkPacket reply = constructBulkMessagePacket(conversation); device.sendPacket(reply); 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; } @Override public String[] getSupportedPacketTypes() { return new String[]{ PACKET_TYPE_SMS_REQUEST, TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST, PACKET_TYPE_SMS_REQUEST_CONVERSATIONS, PACKET_TYPE_SMS_REQUEST_CONVERSATION }; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_SMS_MESSAGE}; } @Override public String[] getRequiredPermissions() { return new String[]{ Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS, }; } + /** + * I suspect we can actually go lower than this, but it might get unstable + */ @Override public int getMinSdk() { return Build.VERSION_CODES.KITKAT; } }