File indexing completed on 2025-02-02 04:47:49
0001 /* 0002 * SPDX-FileCopyrightText: 2021 Simon Redman <simon@ergotech.com> 0003 * SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com> 0004 * 0005 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0006 */ 0007 0008 package org.kde.kdeconnect.Helpers; 0009 0010 import android.annotation.SuppressLint; 0011 import android.content.ContentUris; 0012 import android.content.Context; 0013 import android.database.ContentObserver; 0014 import android.database.Cursor; 0015 import android.database.sqlite.SQLiteException; 0016 import android.graphics.Bitmap; 0017 import android.media.MediaMetadataRetriever; 0018 import android.media.ThumbnailUtils; 0019 import android.net.Uri; 0020 import android.os.Build; 0021 import android.os.Looper; 0022 import android.provider.Telephony; 0023 import android.telephony.PhoneNumberUtils; 0024 import android.util.Log; 0025 import android.util.Pair; 0026 0027 import androidx.annotation.NonNull; 0028 import androidx.annotation.Nullable; 0029 import androidx.annotation.RequiresApi; 0030 0031 import com.google.android.mms.pdu_alt.MultimediaMessagePdu; 0032 import com.google.android.mms.pdu_alt.PduPersister; 0033 import com.google.android.mms.util_alt.PduCache; 0034 import com.google.android.mms.util_alt.PduCacheEntry; 0035 0036 import org.apache.commons.io.IOUtils; 0037 import org.apache.commons.lang3.ArrayUtils; 0038 import org.apache.commons.lang3.StringUtils; 0039 import org.apache.commons.lang3.math.NumberUtils; 0040 import org.json.JSONArray; 0041 import org.json.JSONException; 0042 import org.json.JSONObject; 0043 import org.kde.kdeconnect.Plugins.SMSPlugin.MimeType; 0044 import org.kde.kdeconnect.Plugins.SMSPlugin.SmsMmsUtils; 0045 0046 import java.io.IOException; 0047 import java.io.InputStream; 0048 import java.util.ArrayList; 0049 import java.util.Arrays; 0050 import java.util.Collection; 0051 import java.util.Collections; 0052 import java.util.Comparator; 0053 import java.util.HashMap; 0054 import java.util.HashSet; 0055 import java.util.Iterator; 0056 import java.util.List; 0057 import java.util.Map; 0058 import java.util.Objects; 0059 import java.util.Set; 0060 import java.util.SortedMap; 0061 import java.util.TreeMap; 0062 import java.util.concurrent.locks.Condition; 0063 import java.util.concurrent.locks.Lock; 0064 import java.util.concurrent.locks.ReentrantLock; 0065 import java.util.stream.Collectors; 0066 0067 import kotlin.text.Charsets; 0068 0069 @SuppressLint("InlinedApi") 0070 public class SMSHelper { 0071 0072 private static final int THUMBNAIL_HEIGHT = 100; 0073 private static final int THUMBNAIL_WIDTH = 100; 0074 0075 /** 0076 * Get a URI for querying SMS messages 0077 */ 0078 private static Uri getSMSUri() { 0079 // The value it represents was used in older Android versions so it *should* work but 0080 // might vary between vendors. 0081 return Telephony.Sms.CONTENT_URI; 0082 } 0083 0084 private static Uri getMMSUri() { 0085 return Telephony.Mms.CONTENT_URI; 0086 } 0087 0088 public static Uri getMMSPartUri() { 0089 // The constant Telephony.Mms.Part.CONTENT_URI was added in API 29 0090 return Uri.parse("content://mms/part/"); 0091 } 0092 0093 /** 0094 * Get the base address for all message conversations 0095 * We only use this to fetch thread_ids because the data it returns if often incomplete or useless 0096 */ 0097 private static Uri getConversationUri() { 0098 0099 // Special case for Samsung 0100 // For some reason, Samsung devices do not support the regular SmsMms column. 0101 // However, according to https://stackoverflow.com/a/13640868/3723163, we can work around it this way. 0102 // By my understanding, "simple=true" means we can't support multi-target messages. 0103 // Go complain to Samsung about their annoying OS changes! 0104 if ("Samsung".equalsIgnoreCase(Build.MANUFACTURER)) { 0105 Log.i("SMSHelper", "This appears to be a Samsung device. This may cause some features to not work properly."); 0106 } 0107 0108 return Uri.parse("content://mms-sms/conversations?simple=true"); 0109 } 0110 0111 private static Uri getCompleteConversationsUri() { 0112 // This glorious - but completely undocumented - content URI gives us all messages, both MMS and SMS, 0113 // in all conversations 0114 // See https://stackoverflow.com/a/36439630/3723163 0115 return Uri.parse("content://mms-sms/complete-conversations"); 0116 } 0117 0118 /** 0119 * Column used to discriminate between SMS and MMS messages 0120 * Unfortunately, this column is not defined for Telephony.MmsSms.CONTENT_CONVERSATIONS_URI 0121 * (aka. content://mms-sms/conversations) 0122 * which gives us the first message in every conversation, but it *is* defined for 0123 * content://mms-sms/conversations/<threadID> which gives us the complete conversation matching 0124 * that threadID, so at least it's partially useful to us. 0125 */ 0126 private static String getTransportTypeDiscriminatorColumn() { 0127 return Telephony.MmsSms.TYPE_DISCRIMINATOR_COLUMN; 0128 } 0129 0130 /** 0131 * Get some or all the messages in a requested thread, starting with the most-recent message 0132 * 0133 * @param context android.content.Context running the request 0134 * @param threadID Thread to look up 0135 * @param numberToGet Number of messages to return. Pass null for "all" 0136 * @return List of all messages in the thread 0137 */ 0138 public static @NonNull List<Message> getMessagesInThread( 0139 @NonNull Context context, 0140 @NonNull ThreadID threadID, 0141 @Nullable Long numberToGet 0142 ) { 0143 return getMessagesInRange(context, threadID, Long.MAX_VALUE, numberToGet, true); 0144 } 0145 0146 /** 0147 * Get some messages in the given thread based on a start timestamp and an optional count 0148 * 0149 * @param context android.content.Context running the request 0150 * @param threadID Optional ThreadID to look up. If not included, this method will return the latest messages from all threads. 0151 * @param startTimestamp Beginning of the range to return 0152 * @param numberToGet Number of messages to return. Pass null for "all" 0153 * @param getMessagesOlderStartTime If true, get messages with timestamps before the startTimestamp. If false, get newer messages 0154 * @return Some messages in the requested conversation 0155 */ 0156 @SuppressLint("NewApi") 0157 public static @NonNull List<Message> getMessagesInRange( 0158 @NonNull Context context, 0159 @Nullable ThreadID threadID, 0160 @NonNull Long startTimestamp, 0161 @Nullable Long numberToGet, 0162 @NonNull Boolean getMessagesOlderStartTime 0163 ) { 0164 // The stickiness with this is that Android's MMS database has its timestamp in epoch *seconds* 0165 // while the SMS database uses epoch *milliseconds*. 0166 // I can think of no way around this other than manually querying each one with a different 0167 // "WHERE" statement. 0168 Uri smsUri = getSMSUri(); 0169 Uri mmsUri = getMMSUri(); 0170 0171 List<String> allSmsColumns = new ArrayList<>(Arrays.asList(Message.smsColumns)); 0172 List<String> allMmsColumns = new ArrayList<>(Arrays.asList(Message.mmsColumns)); 0173 0174 if (getSubscriptionIdSupport(smsUri, context)) { 0175 allSmsColumns.addAll(Arrays.asList(Message.multiSIMColumns)); 0176 } 0177 0178 if (getSubscriptionIdSupport(mmsUri, context)) { 0179 allMmsColumns.addAll(Arrays.asList(Message.multiSIMColumns)); 0180 } 0181 0182 String selection; 0183 0184 if (getMessagesOlderStartTime) { 0185 selection = Message.DATE + " <= ?"; 0186 } else { 0187 selection = Message.DATE + " >= ?"; 0188 } 0189 0190 List<String> smsSelectionArgs = new ArrayList<>(2); 0191 smsSelectionArgs.add(startTimestamp.toString()); 0192 0193 List<String> mmsSelectionArgs = new ArrayList<>(2); 0194 mmsSelectionArgs.add(Long.toString(startTimestamp / 1000)); 0195 0196 if (threadID != null) { 0197 selection += " AND " + Message.THREAD_ID + " = ?"; 0198 smsSelectionArgs.add(threadID.toString()); 0199 mmsSelectionArgs.add(threadID.toString()); 0200 } 0201 0202 String sortOrder = Message.DATE + " DESC"; 0203 0204 List<Message> allMessages = getMessages(smsUri, context, allSmsColumns, selection, smsSelectionArgs.toArray(new String[0]), sortOrder, numberToGet); 0205 allMessages.addAll(getMessages(mmsUri, context, allMmsColumns, selection, mmsSelectionArgs.toArray(new String[0]), sortOrder, numberToGet)); 0206 0207 // Need to now only return the requested number of messages: 0208 // Suppose we were requested to return N values and suppose a user sends only one MMS per 0209 // week and N SMS per day. We have requested the same N for each, so if we just return everything 0210 // we would return some very old MMS messages which would be very confusing. 0211 SortedMap<Long, Collection<Message>> sortedMessages = new TreeMap<>(Comparator.reverseOrder()); 0212 for (Message message : allMessages) { 0213 Collection<Message> existingMessages = sortedMessages.computeIfAbsent(message.date, 0214 key -> new ArrayList<>()); 0215 existingMessages.add(message); 0216 } 0217 0218 List<Message> toReturn = new ArrayList<>(allMessages.size()); 0219 0220 for (Collection<Message> messages : sortedMessages.values()) { 0221 toReturn.addAll(messages); 0222 if (numberToGet != null && toReturn.size() >= numberToGet) { 0223 break; 0224 } 0225 } 0226 0227 return toReturn; 0228 } 0229 0230 /** 0231 * Checks if device supports `Telephony.Sms.SUBSCRIPTION_ID` column in database with URI `uri` 0232 * 0233 * @param uri Uri indicating the messages database to check 0234 * @param context android.content.Context running the request. 0235 */ 0236 private static boolean getSubscriptionIdSupport(@NonNull Uri uri, @NonNull Context context) { 0237 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { 0238 return false; 0239 } 0240 // Some (Xiaomi) devices running >= Android Lollipop (SDK 22+) don't support 0241 // `Telephony.Sms.SUBSCRIPTION_ID`, so additional check is needed. 0242 // It may be possible to use "sim_id" instead of "sub_id" on these devices 0243 // https://stackoverflow.com/a/38152331/6509200 0244 try (Cursor availableColumnsCursor = context.getContentResolver().query( 0245 uri, 0246 new String[] {Telephony.Sms.SUBSCRIPTION_ID}, 0247 null, 0248 null, 0249 null) 0250 ) { 0251 return availableColumnsCursor != null; // if we got the cursor, the query shouldn't fail 0252 } catch (SQLiteException | IllegalArgumentException e) { 0253 // With uri content://mms-sms/conversations this query throws an exception if sub_id is not supported 0254 return !StringUtils.contains(e.getMessage(), Telephony.Sms.SUBSCRIPTION_ID); 0255 } 0256 } 0257 0258 /** 0259 * Gets messages which match the selection 0260 * 0261 * @param uri Uri indicating the messages database to read 0262 * @param context android.content.Context running the request. 0263 * @param fetchColumns List of columns to fetch 0264 * @param selection Parameterizable filter to use with the ContentResolver query. May be null. 0265 * @param selectionArgs Parameters for selection. May be null. 0266 * @param sortOrder Sort ordering passed to Android's content resolver. May be null for unspecified 0267 * @param numberToGet Number of things to get from the result. Pass null to get all 0268 * @return Returns List<Message> of all messages in the return set, either in the order of sortOrder or in an unspecified order 0269 */ 0270 private static @NonNull List<Message> getMessages( 0271 @NonNull Uri uri, 0272 @NonNull Context context, 0273 @NonNull Collection<String> fetchColumns, 0274 @Nullable String selection, 0275 @Nullable String[] selectionArgs, 0276 @Nullable String sortOrder, 0277 @Nullable Long numberToGet 0278 ) { 0279 List<Message> toReturn = new ArrayList<>(); 0280 0281 // Get all the active phone numbers so we can filter the user out of the list of targets 0282 // of any MMSes 0283 List<TelephonyHelper.LocalPhoneNumber> userPhoneNumbers = TelephonyHelper.getAllPhoneNumbers(context); 0284 0285 try (Cursor myCursor = context.getContentResolver().query( 0286 uri, 0287 fetchColumns.toArray(ArrayUtils.EMPTY_STRING_ARRAY), 0288 selection, 0289 selectionArgs, 0290 sortOrder) 0291 ) { 0292 if (myCursor != null && myCursor.moveToFirst()) { 0293 do { 0294 int transportTypeColumn = myCursor.getColumnIndex(getTransportTypeDiscriminatorColumn()); 0295 0296 TransportType transportType; 0297 if (transportTypeColumn < 0) { 0298 // The column didn't actually exist. See https://issuetracker.google.com/issues/134592631 0299 // Try to determine using other information 0300 int messageBoxColumn = myCursor.getColumnIndex(Telephony.Mms.MESSAGE_BOX); 0301 // MessageBoxColumn is defined for MMS only 0302 boolean messageBoxExists = !myCursor.isNull(messageBoxColumn); 0303 if (messageBoxExists) { 0304 transportType = TransportType.MMS; 0305 } else { 0306 // There is room here for me to have made an assumption and we'll guess wrong 0307 // The penalty is the user will potentially get some garbled data, so that's not too bad. 0308 transportType = TransportType.SMS; 0309 } 0310 } else { 0311 String transportTypeString = myCursor.getString(transportTypeColumn); 0312 if ("mms".equals(transportTypeString)) { 0313 transportType = TransportType.MMS; 0314 } else if ("sms".equals(transportTypeString)) { 0315 transportType = TransportType.SMS; 0316 } else { 0317 Log.w("SMSHelper", "Skipping message with unknown TransportType: " + transportTypeString); 0318 continue; 0319 } 0320 } 0321 0322 HashMap<String, String> messageInfo = new HashMap<>(); 0323 for (int columnIdx = 0; columnIdx < myCursor.getColumnCount(); columnIdx++) { 0324 String colName = myCursor.getColumnName(columnIdx); 0325 String body = myCursor.getString(columnIdx); 0326 messageInfo.put(colName, body); 0327 } 0328 0329 try { 0330 switch (transportType) { 0331 case SMS: 0332 toReturn.add(parseSMS(context, messageInfo)); 0333 break; 0334 case MMS: 0335 toReturn.add(parseMMS(context, messageInfo, userPhoneNumbers)); 0336 break; 0337 } 0338 } catch (Exception e) { 0339 // Swallow exceptions in case we get an error reading one message so that we 0340 // might be able to read some of them 0341 Log.e("SMSHelper", "Got an error reading a message of type " + transportType, e); 0342 } 0343 } while ((numberToGet == null || toReturn.size() < numberToGet) && myCursor.moveToNext()); 0344 } 0345 } catch (SQLiteException | IllegalArgumentException e) { 0346 String[] unfilteredColumns = {}; 0347 try (Cursor unfilteredColumnsCursor = context.getContentResolver().query(uri, null, null, null, null)) { 0348 if (unfilteredColumnsCursor != null) { 0349 unfilteredColumns = unfilteredColumnsCursor.getColumnNames(); 0350 } 0351 } 0352 if (unfilteredColumns.length == 0) { 0353 throw new MessageAccessException(uri, e); 0354 } else { 0355 throw new MessageAccessException(unfilteredColumns, uri, e); 0356 } 0357 } 0358 0359 return toReturn; 0360 } 0361 0362 /** 0363 * Gets messages which match the selection 0364 * 0365 * @param uri Uri indicating the messages database to read 0366 * @param context android.content.Context running the request. 0367 * @param selection Parameterizable filter to use with the ContentResolver query. May be null. 0368 * @param selectionArgs Parameters for selection. May be null. 0369 * @param sortOrder Sort ordering passed to Android's content resolver. May be null for unspecified 0370 * @param numberToGet Number of things to get from the result. Pass null to get all 0371 * @return Returns List<Message> of all messages in the return set, either in the order of sortOrder or in an unspecified order 0372 */ 0373 @SuppressLint("NewApi") 0374 private static @NonNull List<Message> getMessages( 0375 @NonNull Uri uri, 0376 @NonNull Context context, 0377 @Nullable String selection, 0378 @Nullable String[] selectionArgs, 0379 @Nullable String sortOrder, 0380 @Nullable Long numberToGet 0381 ) { 0382 Set<String> allColumns = new HashSet<>(); 0383 allColumns.addAll(Arrays.asList(Message.smsColumns)); 0384 allColumns.addAll(Arrays.asList(Message.mmsColumns)); 0385 if (getSubscriptionIdSupport(uri, context)) { 0386 allColumns.addAll(Arrays.asList(Message.multiSIMColumns)); 0387 } 0388 0389 if (!uri.equals(getConversationUri())) { 0390 // See https://issuetracker.google.com/issues/134592631 0391 allColumns.add(getTransportTypeDiscriminatorColumn()); 0392 } 0393 0394 return getMessages(uri, context, allColumns, selection, selectionArgs, sortOrder, numberToGet); 0395 } 0396 0397 /** 0398 * Get all messages matching the passed filter. See documentation for Android's ContentResolver 0399 * 0400 * @param context android.content.Context running the request 0401 * @param selection Parameterizable filter to use with the ContentResolver query. May be null. 0402 * @param selectionArgs Parameters for selection. May be null. 0403 * @param numberToGet Number of things to return. Pass null to get all 0404 * @return List of messages matching the filter, from newest to oldest 0405 */ 0406 private static List<Message> getMessagesWithFilter( 0407 @NonNull Context context, 0408 @Nullable String selection, 0409 @Nullable String[] selectionArgs, 0410 @Nullable Long numberToGet 0411 ) { 0412 String sortOrder = Message.DATE + " DESC"; 0413 0414 return getMessages(getCompleteConversationsUri(), context, selection, selectionArgs, sortOrder, numberToGet); 0415 } 0416 0417 /** 0418 * Get the last message from each conversation. Can use the thread_ids in those messages to look 0419 * up more messages in those conversations 0420 * 0421 * Returns values ordered from most-recently-touched conversation to oldest, if possible. 0422 * Otherwise ordering is undefined. 0423 * 0424 * @param context android.content.Context running the request 0425 * @return Non-blocking iterable of the first message in each conversation 0426 */ 0427 public static Iterable<Message> getConversations( 0428 @NonNull Context context 0429 ) { 0430 Uri uri = SMSHelper.getConversationUri(); 0431 0432 // Used to avoid spewing logs in case there is an overall problem with fetching thread IDs 0433 boolean warnedForNullThreadIDs = false; 0434 0435 // Used to avoid spewing logs in case the date column doesn't return anything. 0436 boolean warnedForUnorderedOutputs = false; 0437 0438 // Step 1: Populate the list of all known threadIDs 0439 // This is basically instantaneous even with lots of conversations because we only make one 0440 // query. If someone wanted to squeeze better UI performance out of this method, they could 0441 // iterate over the threadIdCursor instead of getting all the threads before beginning to 0442 // return conversations, but I doubt anyone will ever find it necessary. 0443 List<ThreadID> threadIds; 0444 try (Cursor threadIdCursor = context.getContentResolver().query( 0445 uri, 0446 null, 0447 null, 0448 null, 0449 null)) { 0450 List<Pair<ThreadID, Long>> threadTimestampPair = new ArrayList<>(); 0451 while (threadIdCursor != null && threadIdCursor.moveToNext()) { 0452 // The "_id" column returned from the `content://sms-mms/conversations?simple=true` URI 0453 // is actually what the rest of the world calls a thread_id. 0454 // In my limited experimentation, the other columns are not populated, so don't bother 0455 // looking at them here. 0456 int idColumn = threadIdCursor.getColumnIndex("_id"); 0457 int dateColumn = threadIdCursor.getColumnIndex("date"); 0458 0459 ThreadID threadID = null; 0460 long messageDate = -1; 0461 if (!threadIdCursor.isNull(idColumn)) { 0462 threadID = new ThreadID(threadIdCursor.getLong(idColumn)); 0463 } 0464 if (!threadIdCursor.isNull(dateColumn)) { 0465 // I think the presence of the "date" column depends on the specifics of the 0466 // device. If it's there, we'll use it to return threads in a sorted order. 0467 // If it's not there, we'll return them unsorted (maybe you get lucky and the 0468 // conversations URI returns sorted anyway). 0469 messageDate = threadIdCursor.getLong(dateColumn); 0470 } 0471 0472 if (messageDate <= 0) { 0473 if (!warnedForUnorderedOutputs) { 0474 Log.w("SMSHelper", "Got no value for date of thread. Return order of results is undefined."); 0475 warnedForUnorderedOutputs = true; 0476 } 0477 } 0478 0479 if (threadID == null) { 0480 if (!warnedForNullThreadIDs) { 0481 Log.w("SMSHelper", "Got null for some thread IDs. If these were valid threads, they will not be returned."); 0482 warnedForNullThreadIDs = true; 0483 } 0484 continue; 0485 } 0486 0487 threadTimestampPair.add(new Pair<>(threadID, messageDate)); 0488 } 0489 0490 threadIds = threadTimestampPair.stream() 0491 .sorted((left, right) -> right.second.compareTo(left.second)) // Sort most-recent to least-recent (largest to smallest) 0492 .map(threadTimestampPairElement -> threadTimestampPairElement.first).collect(Collectors.toList()); 0493 } 0494 0495 // Step 2: Get the actual message object from each thread ID 0496 // Do this in an iterator, so that the caller can choose to interrupt us as frequently as 0497 // desired 0498 return new Iterable<Message>() { 0499 @NonNull 0500 @Override 0501 public Iterator<Message> iterator() { 0502 return new Iterator<Message>() { 0503 int threadIdsIndex = 0; 0504 0505 @Override 0506 public boolean hasNext() { 0507 return threadIdsIndex < threadIds.size(); 0508 } 0509 0510 @Override 0511 public Message next() { 0512 ThreadID nextThreadId = threadIds.get(threadIdsIndex); 0513 threadIdsIndex++; 0514 0515 List<Message> firstMessage = getMessagesInThread(context, nextThreadId, 1L); 0516 0517 if (firstMessage.size() > 1) { 0518 Log.w("SMSHelper", "getConversations got two messages for the same ThreadID: " + nextThreadId); 0519 } 0520 0521 if (firstMessage.size() == 0) 0522 { 0523 Log.e("SMSHelper", "ThreadID: " + nextThreadId + " did not return any messages"); 0524 // This is a strange issue, but I don't know how to say what is wrong, so just continue along 0525 return this.next(); 0526 } 0527 return firstMessage.get(0); 0528 } 0529 }; 0530 } 0531 }; 0532 } 0533 0534 private static int addEventFlag( 0535 int oldEvent, 0536 int eventFlag 0537 ) { 0538 return oldEvent | eventFlag; 0539 } 0540 0541 /** 0542 * Parse all parts of an SMS into a Message 0543 */ 0544 private static @NonNull Message parseSMS( 0545 @NonNull Context context, 0546 @NonNull Map<String, String> messageInfo 0547 ) { 0548 int event = Message.EVENT_UNKNOWN; 0549 event = addEventFlag(event, Message.EVENT_TEXT_MESSAGE); 0550 0551 @NonNull List<Address> address = Collections.singletonList(new Address(messageInfo.get(Telephony.Sms.ADDRESS))); 0552 @Nullable String maybeBody = messageInfo.getOrDefault(Message.BODY, ""); 0553 @NonNull String body = maybeBody != null ? maybeBody : ""; 0554 long date = NumberUtils.toLong(messageInfo.getOrDefault(Message.DATE, null)); 0555 int type = NumberUtils.toInt(messageInfo.getOrDefault(Message.TYPE, null)); 0556 int read = NumberUtils.toInt(messageInfo.getOrDefault(Message.READ, null)); 0557 @NonNull ThreadID threadID = new ThreadID(NumberUtils.toLong(messageInfo.getOrDefault(Message.THREAD_ID, null), ThreadID.invalidThreadId.threadID)); 0558 long uID = NumberUtils.toLong(messageInfo.getOrDefault(Message.U_ID, null)); 0559 int subscriptionID = NumberUtils.toInt(messageInfo.getOrDefault(Message.SUBSCRIPTION_ID, null)); 0560 0561 // Examine all the required SMS columns and emit a log if something seems amiss 0562 boolean anyNulls = Arrays.stream(new String[] { 0563 Telephony.Sms.ADDRESS, 0564 Message.BODY, 0565 Message.DATE, 0566 Message.TYPE, 0567 Message.READ, 0568 Message.THREAD_ID, 0569 Message.U_ID }) 0570 .map(key -> messageInfo.getOrDefault(key, null)) 0571 .anyMatch(Objects::isNull); 0572 if (anyNulls) 0573 { 0574 Log.e("parseSMS", "Some fields were invalid. This indicates either a corrupted SMS database or an unsupported device."); 0575 } 0576 0577 return new Message( 0578 address, 0579 body, 0580 date, 0581 type, 0582 read, 0583 threadID, 0584 uID, 0585 event, 0586 subscriptionID, 0587 null 0588 ); 0589 } 0590 0591 /** 0592 * Parse all parts of the MMS message into a message 0593 * Original implementation from https://stackoverflow.com/a/6446831/3723163 0594 */ 0595 private static @NonNull Message parseMMS( 0596 @NonNull Context context, 0597 @NonNull Map<String, String> messageInfo, 0598 @NonNull List<TelephonyHelper.LocalPhoneNumber> userPhoneNumbers 0599 ) { 0600 int event = Message.EVENT_UNKNOWN; 0601 0602 @NonNull String body = ""; 0603 long date; 0604 int type; 0605 int read = NumberUtils.toInt(messageInfo.get(Message.READ)); 0606 @NonNull ThreadID threadID = new ThreadID(NumberUtils.toLong(messageInfo.getOrDefault(Message.THREAD_ID, null), ThreadID.invalidThreadId.threadID)); 0607 long uID = NumberUtils.toLong(messageInfo.get(Message.U_ID)); 0608 int subscriptionID = NumberUtils.toInt(messageInfo.get(Message.SUBSCRIPTION_ID)); 0609 List<Attachment> attachments = new ArrayList<>(); 0610 0611 String[] columns = { 0612 Telephony.Mms.Part._ID, // The content ID of this part 0613 Telephony.Mms.Part._DATA, // The location in the filesystem of the data 0614 Telephony.Mms.Part.CONTENT_TYPE, // The mime type of the data 0615 Telephony.Mms.Part.TEXT, // The plain text body of this MMS 0616 Telephony.Mms.Part.CHARSET, // Charset of the plain text body 0617 }; 0618 0619 String mmsID = messageInfo.get(Message.U_ID); 0620 String selection = Telephony.Mms.Part.MSG_ID + " = ?"; 0621 String[] selectionArgs = {mmsID}; 0622 0623 // Get text body and attachments of the message 0624 try (Cursor cursor = context.getContentResolver().query( 0625 getMMSPartUri(), 0626 columns, 0627 selection, 0628 selectionArgs, 0629 null 0630 )) { 0631 if (cursor != null && cursor.moveToFirst()) { 0632 int partIDColumn = cursor.getColumnIndexOrThrow(Telephony.Mms.Part._ID); 0633 int contentTypeColumn = cursor.getColumnIndexOrThrow(Telephony.Mms.Part.CONTENT_TYPE); 0634 int dataColumn = cursor.getColumnIndexOrThrow(Telephony.Mms.Part._DATA); 0635 int textColumn = cursor.getColumnIndexOrThrow(Telephony.Mms.Part.TEXT); 0636 // TODO: Parse charset (As usual, it is skimpily documented) (Possibly refer to MMS spec) 0637 0638 do { 0639 long partID = cursor.getLong(partIDColumn); 0640 String contentType = cursor.getString(contentTypeColumn); 0641 String data = cursor.getString(dataColumn); 0642 if (MimeType.isTypeText(contentType)) { 0643 if (data != null) { 0644 // data != null means the data is on disk. Go get it. 0645 body = getMmsText(context, partID); 0646 } else { 0647 body = cursor.getString(textColumn); 0648 } 0649 event = addEventFlag(event, Message.EVENT_TEXT_MESSAGE); 0650 } else if (MimeType.isTypeImage(contentType)) { 0651 String fileName = data.substring(data.lastIndexOf('/') + 1); 0652 0653 // Get the actual image from the mms database convert it into thumbnail and encode to Base64 0654 Bitmap image = SmsMmsUtils.getMmsImage(context, partID); 0655 Bitmap thumbnailImage = ThumbnailUtils.extractThumbnail(image, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); 0656 String encodedThumbnail = SmsMmsUtils.bitMapToBase64(thumbnailImage); 0657 0658 attachments.add(new Attachment(partID, contentType, encodedThumbnail, fileName)); 0659 } else if (MimeType.isTypeVideo(contentType)) { 0660 String fileName = data.substring(data.lastIndexOf('/') + 1); 0661 0662 // Can't use try-with-resources since MediaMetadataRetriever's close method was only added in API 29 0663 MediaMetadataRetriever retriever = new MediaMetadataRetriever(); 0664 retriever.setDataSource(context, ContentUris.withAppendedId(getMMSPartUri(), partID)); 0665 Bitmap videoThumbnail = retriever.getFrameAtTime(); 0666 0667 String encodedThumbnail = SmsMmsUtils.bitMapToBase64( 0668 Bitmap.createScaledBitmap(videoThumbnail, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, true) 0669 ); 0670 0671 attachments.add(new Attachment(partID, contentType, encodedThumbnail, fileName)); 0672 } else if (MimeType.isTypeAudio(contentType)) { 0673 String fileName = data.substring(data.lastIndexOf('/') + 1); 0674 0675 attachments.add(new Attachment(partID, contentType, null, fileName)); 0676 } else { 0677 Log.v("SMSHelper", "Unsupported attachment type: " + contentType); 0678 } 0679 } while (cursor.moveToNext()); 0680 } 0681 } catch (Exception e) { 0682 e.printStackTrace(); 0683 } 0684 0685 // Determine whether the message was in- our out- bound 0686 long messageBox = NumberUtils.toLong(messageInfo.get(Telephony.Mms.MESSAGE_BOX)); 0687 if (messageBox == Telephony.Mms.MESSAGE_BOX_INBOX) { 0688 type = Telephony.Sms.MESSAGE_TYPE_INBOX; 0689 } else if (messageBox == Telephony.Mms.MESSAGE_BOX_SENT) { 0690 type = Telephony.Sms.MESSAGE_TYPE_SENT; 0691 } else { 0692 // As an undocumented feature, it looks like the values of Mms.MESSAGE_BOX_* 0693 // are the same as Sms.MESSAGE_TYPE_* of the same type. So by default let's just use 0694 // the value we've got. 0695 // This includes things like drafts, which are a far-distant plan to support 0696 type = NumberUtils.toInt(messageInfo.get(Telephony.Mms.MESSAGE_BOX)); 0697 } 0698 0699 // Get address(es) of the message 0700 MultimediaMessagePdu msg = getMessagePdu(context, uID); 0701 Address from = SmsMmsUtils.getMmsFrom(msg); 0702 List<Address> to = SmsMmsUtils.getMmsTo(msg); 0703 0704 List<Address> addresses = new ArrayList<>(); 0705 if (from != null) { 0706 boolean isLocalPhoneNumber = userPhoneNumbers.stream().anyMatch(localPhoneNumber -> localPhoneNumber.isMatchingPhoneNumber(from.address)); 0707 0708 if (!isLocalPhoneNumber && !from.toString().equals("insert-address-token")) { 0709 addresses.add(from); 0710 } 0711 } 0712 0713 if (to != null) { 0714 for (Address toAddress : to) { 0715 boolean isLocalPhoneNumber = userPhoneNumbers.stream().anyMatch(localPhoneNumber -> localPhoneNumber.isMatchingPhoneNumber(toAddress.address)); 0716 0717 if (!isLocalPhoneNumber && !toAddress.toString().equals("insert-address-token")) { 0718 addresses.add(toAddress); 0719 } 0720 } 0721 } 0722 0723 // It looks like addresses[0] is always the sender of the message and 0724 // following addresses are recipient(s) 0725 // This usually means the addresses list is at least 2 long, but there are cases (special 0726 // telco service messages) where it is not (only 1 long in that case, just the "sender") 0727 0728 if (addresses.size() >= 2) { 0729 event = addEventFlag(event, Message.EVENT_MULTI_TARGET); 0730 } 0731 0732 // Canonicalize the date field 0733 // SMS uses epoch milliseconds, MMS uses epoch seconds. Standardize on milliseconds. 0734 long rawDate = NumberUtils.toLong(messageInfo.get(Message.DATE)); 0735 date = rawDate * 1000; 0736 0737 return new Message( 0738 addresses, 0739 body, 0740 date, 0741 type, 0742 read, 0743 threadID, 0744 uID, 0745 event, 0746 subscriptionID, 0747 attachments 0748 ); 0749 } 0750 0751 private static MultimediaMessagePdu getMessagePdu(Context context, long uID) { 0752 Uri uri = ContentUris.appendId(getMMSUri().buildUpon(), uID).build(); 0753 MultimediaMessagePdu toReturn; 0754 try { 0755 // Work around https://bugs.kde.org/show_bug.cgi?id=434348 by querying the PduCache directly 0756 // Most likely, this is how we should do business anyway and we will probably see a 0757 // decent speedup... 0758 PduCache pduCache = PduCache.getInstance(); 0759 PduCacheEntry maybePduValue; 0760 synchronized (pduCache) { 0761 maybePduValue = pduCache.get(uri); 0762 } 0763 0764 if (maybePduValue != null) { 0765 toReturn = (MultimediaMessagePdu) maybePduValue.getPdu(); 0766 } else { 0767 toReturn = (MultimediaMessagePdu) PduPersister.getPduPersister(context).load(uri); 0768 } 0769 } catch (Exception e) { 0770 e.printStackTrace(); 0771 return null; 0772 } 0773 return toReturn; 0774 } 0775 0776 0777 /** 0778 * Get a text part of an MMS message 0779 * Original implementation from https://stackoverflow.com/a/6446831/3723163 0780 */ 0781 private static String getMmsText(@NonNull Context context, long id) { 0782 Uri partURI = ContentUris.withAppendedId(getMMSPartUri(), id); 0783 String body = ""; 0784 try (InputStream is = context.getContentResolver().openInputStream(partURI)) { 0785 if (is != null) { 0786 // The stream is buffered internally, so buffering it separately is unnecessary. 0787 body = IOUtils.toString(is, Charsets.UTF_8); 0788 } 0789 } catch (IOException e) { 0790 throw new SMSHelper.MessageAccessException(partURI, e); 0791 } 0792 return body; 0793 } 0794 0795 /** 0796 * Register a ContentObserver for the Messages database 0797 * 0798 * @param observer ContentObserver to alert on Message changes 0799 */ 0800 public static void registerObserver( 0801 @NonNull ContentObserver observer, 0802 @NonNull Context context 0803 ) { 0804 context.getContentResolver().registerContentObserver( 0805 SMSHelper.getConversationUri(), 0806 true, 0807 observer 0808 ); 0809 } 0810 0811 /** 0812 * Represent an ID used to uniquely identify a message thread 0813 */ 0814 public static class ThreadID { 0815 final long threadID; 0816 static final String lookupColumn = Telephony.Sms.THREAD_ID; 0817 0818 /** 0819 * Define a value against which we can compare others, which should never be returned from 0820 * a valid thread. 0821 */ 0822 public static final ThreadID invalidThreadId = new ThreadID(-1); 0823 0824 public ThreadID(long threadID) { 0825 this.threadID = threadID; 0826 } 0827 0828 @NonNull 0829 public String toString() { 0830 return Long.toString(threadID); 0831 } 0832 0833 @Override 0834 public int hashCode() { 0835 return Long.hashCode(threadID); 0836 } 0837 0838 @Override 0839 public boolean equals(Object other) { 0840 return other.getClass().isAssignableFrom(ThreadID.class) && ((ThreadID) other).threadID == this.threadID; 0841 } 0842 } 0843 0844 public static class Attachment { 0845 final long partID; 0846 final String mimeType; 0847 final String base64EncodedFile; 0848 final String uniqueIdentifier; 0849 0850 /** 0851 * Attachment object field names 0852 */ 0853 public static final String PART_ID = "part_id"; 0854 public static final String MIME_TYPE = "mime_type"; 0855 public static final String ENCODED_THUMBNAIL = "encoded_thumbnail"; 0856 public static final String UNIQUE_IDENTIFIER = "unique_identifier"; 0857 0858 public Attachment(long partID, 0859 String mimeType, 0860 @Nullable String base64EncodedFile, 0861 String uniqueIdentifier 0862 ) { 0863 this.partID = partID; 0864 this.mimeType = mimeType; 0865 this.base64EncodedFile = base64EncodedFile; 0866 this.uniqueIdentifier = uniqueIdentifier; 0867 } 0868 0869 public String getBase64EncodedFile() { return base64EncodedFile; } 0870 public String getMimeType() { return mimeType; } 0871 public String getUniqueIdentifier() { return uniqueIdentifier; } 0872 0873 public JSONObject toJson() throws JSONException { 0874 JSONObject json = new JSONObject(); 0875 0876 json.put(Attachment.PART_ID, this.partID); 0877 json.put(Attachment.MIME_TYPE, this.mimeType); 0878 0879 if (this.base64EncodedFile != null) { 0880 json.put(Attachment.ENCODED_THUMBNAIL, this.base64EncodedFile); 0881 } 0882 json.put(Attachment.UNIQUE_IDENTIFIER, this.uniqueIdentifier); 0883 0884 return json; 0885 } 0886 } 0887 0888 /** 0889 * Converts a given JSONArray of attachments into List<Attachment> 0890 * 0891 * The structure of the input is expected to be as follows: 0892 * [ 0893 * { 0894 * "fileName": <String> // Name of the file 0895 * "base64EncodedFile": <String> // Base64 encoded file 0896 * "mimeType": <String> // File type (eg: image/jpg, video/mp4 etc.) 0897 * }, 0898 * ... 0899 * ] 0900 */ 0901 public static @NonNull List<Attachment> jsonArrayToAttachmentsList( 0902 @Nullable JSONArray jsonArray) { 0903 if (jsonArray == null) { 0904 return Collections.emptyList(); 0905 } 0906 0907 List<Attachment> attachedFiles = new ArrayList<>(jsonArray.length()); 0908 try { 0909 for (int i = 0; i < jsonArray.length(); i++) { 0910 JSONObject jsonObject = jsonArray.getJSONObject(i); 0911 String base64EncodedFile = jsonObject.getString("base64EncodedFile"); 0912 String mimeType = jsonObject.getString("mimeType"); 0913 String fileName = jsonObject.getString("fileName"); 0914 attachedFiles.add(new Attachment(-1, mimeType, base64EncodedFile, fileName)); 0915 } 0916 } catch (Exception e) { 0917 e.printStackTrace(); 0918 } 0919 0920 return attachedFiles; 0921 } 0922 0923 public static class Address { 0924 public final String address; 0925 0926 /** 0927 * Address object field names 0928 */ 0929 public static final String ADDRESS = "address"; 0930 0931 public Address(String address) { 0932 this.address = address; 0933 } 0934 0935 public JSONObject toJson() throws JSONException { 0936 JSONObject json = new JSONObject(); 0937 0938 json.put(Address.ADDRESS, this.address); 0939 0940 return json; 0941 } 0942 0943 @NonNull 0944 @Override 0945 public String toString() { 0946 return address; 0947 } 0948 0949 @Override 0950 public boolean equals(Object other){ 0951 if (other == null) { 0952 return false; 0953 } 0954 if (other.getClass().isAssignableFrom(Address.class)) { 0955 return PhoneNumberUtils.compare(this.address, ((Address)other).address); 0956 } 0957 if (other.getClass().isAssignableFrom(String.class)) { 0958 return PhoneNumberUtils.compare(this.address, (String)other); 0959 } 0960 return false; 0961 } 0962 0963 @Override 0964 public int hashCode() { 0965 return this.address.hashCode(); 0966 } 0967 } 0968 0969 /** 0970 * converts a given JSONArray into List<Address> 0971 */ 0972 public static List<Address> jsonArrayToAddressList(JSONArray jsonArray) { 0973 if (jsonArray == null) { 0974 return null; 0975 } 0976 0977 List<Address> addresses = new ArrayList<>(); 0978 try { 0979 for (int i = 0; i < jsonArray.length(); i++) { 0980 JSONObject jsonObject = jsonArray.getJSONObject(i); 0981 String address = jsonObject.getString("address"); 0982 addresses.add(new Address(address)); 0983 } 0984 } catch (Exception e) { 0985 e.printStackTrace(); 0986 } 0987 0988 return addresses; 0989 } 0990 0991 /** 0992 * Indicate that some error has occurred while reading a message. 0993 * More useful for logging than catching and handling 0994 */ 0995 public static class MessageAccessException extends RuntimeException { 0996 MessageAccessException(Uri uri, Throwable cause) { 0997 super("Error getting messages from " + uri.toString(), cause); 0998 } 0999 1000 MessageAccessException(String[] availableColumns, Uri uri, Throwable cause) { 1001 super("Error getting messages from " + uri.toString() + " . Available columns were: " + Arrays.toString(availableColumns), cause); 1002 } 1003 } 1004 1005 /** 1006 * Represent all known transport types 1007 */ 1008 public enum TransportType { 1009 SMS, 1010 MMS, 1011 // Maybe in the future there will be more TransportType, but for now these are all I know about 1012 } 1013 1014 /** 1015 * Represent a message and all of its interesting data columns 1016 */ 1017 public static class Message { 1018 1019 public final List<Address> addresses; 1020 public final String body; 1021 public final long date; 1022 public final int type; 1023 public final int read; 1024 public final ThreadID threadID; 1025 public final long uID; 1026 public final int event; 1027 public final int subscriptionID; 1028 public final List<Attachment> attachments; 1029 1030 /** 1031 * Named constants which are used to construct a Message 1032 * See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html for full documentation 1033 */ 1034 static final String ADDRESSES = "addresses"; // Contact information (phone number or otherwise) of the remote 1035 static final String BODY = Telephony.Sms.BODY; // Body of the message 1036 static final String DATE = Telephony.Sms.DATE; // Date (Unix epoch millis) associated with the message 1037 static final String TYPE = Telephony.Sms.TYPE; // Compare with Telephony.TextBasedSmsColumns.MESSAGE_TYPE_* 1038 static final String READ = Telephony.Sms.READ; // Whether we have received a read report for this message (int) 1039 static final String THREAD_ID = ThreadID.lookupColumn; // Magic number which binds (message) threads 1040 static final String U_ID = Telephony.Sms._ID; // Something which uniquely identifies this message 1041 static final String EVENT = "event"; 1042 static final String SUBSCRIPTION_ID = Telephony.Sms.SUBSCRIPTION_ID; // An ID which appears to identify a SIM card 1043 static final String ATTACHMENTS = "attachments"; // List of files attached in an MMS 1044 1045 /** 1046 * Event flags 1047 * A message should have a bitwise-or of event flags before delivering the packet 1048 * Any events not supported by the receiving device should be ignored 1049 */ 1050 public static final int EVENT_UNKNOWN = 0x0; // The message was of some type we did not understand 1051 public static final int EVENT_TEXT_MESSAGE = 0x1; // This message has a "body" field which contains 1052 // pure, human-readable text 1053 public static final int EVENT_MULTI_TARGET = 0x2; // Indicates that this message has multiple recipients 1054 1055 /** 1056 * Define the columns which are to be extracted from the Android SMS database 1057 */ 1058 static final String[] smsColumns = new String[]{ 1059 Telephony.Sms.ADDRESS, 1060 Telephony.Sms.BODY, 1061 Telephony.Sms.DATE, 1062 Telephony.Sms.TYPE, 1063 Telephony.Sms.READ, 1064 Telephony.Sms.THREAD_ID, 1065 Message.U_ID, 1066 }; 1067 1068 static final String[] mmsColumns = new String[]{ 1069 Message.U_ID, 1070 Telephony.Mms.THREAD_ID, 1071 Telephony.Mms.DATE, 1072 Telephony.Mms.READ, 1073 Telephony.Mms.TEXT_ONLY, 1074 Telephony.Mms.MESSAGE_BOX, // Compare with Telephony.BaseMmsColumns.MESSAGE_BOX_* 1075 }; 1076 1077 /** 1078 * These columns are for determining what SIM card the message belongs to, and therefore 1079 * are only defined on Android versions with multi-sim capabilities 1080 */ 1081 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1) 1082 static final String[] multiSIMColumns = new String[]{ 1083 Telephony.Sms.SUBSCRIPTION_ID, 1084 }; 1085 1086 Message( 1087 @NonNull List<Address> addresses, 1088 @NonNull String body, 1089 long date, 1090 @NonNull Integer type, 1091 int read, 1092 @NonNull ThreadID threadID, 1093 long uID, 1094 int event, 1095 int subscriptionID, 1096 @Nullable List<Attachment> attachments 1097 ) { 1098 this.addresses = addresses; 1099 this.body = body; 1100 this.date = date; 1101 if (type == null) 1102 { 1103 // To be honest, I have no idea why this happens. The docs say the TYPE field is mandatory. 1104 Log.w("SMSHelper", "Encountered undefined message type"); 1105 this.type = -1; 1106 // Proceed anyway, maybe this is not an important problem. 1107 } else { 1108 this.type = type; 1109 } 1110 this.read = read; 1111 this.threadID = threadID; 1112 this.uID = uID; 1113 this.subscriptionID = subscriptionID; 1114 this.event = event; 1115 this.attachments = attachments; 1116 } 1117 1118 public JSONObject toJSONObject() throws JSONException { 1119 JSONObject json = new JSONObject(); 1120 1121 JSONArray jsonAddresses = new JSONArray(); 1122 for (Address address : this.addresses) { 1123 jsonAddresses.put(address.toJson()); 1124 } 1125 1126 json.put(Message.ADDRESSES, jsonAddresses); 1127 json.put(Message.BODY, body); 1128 json.put(Message.DATE, date); 1129 json.put(Message.TYPE, type); 1130 json.put(Message.READ, read); 1131 json.put(Message.THREAD_ID, threadID.threadID); 1132 json.put(Message.U_ID, uID); 1133 json.put(Message.SUBSCRIPTION_ID, subscriptionID); 1134 json.put(Message.EVENT, event); 1135 1136 if (this.attachments != null) { 1137 JSONArray jsonAttachments = new JSONArray(); 1138 for (Attachment attachment : this.attachments) { 1139 jsonAttachments.put(attachment.toJson()); 1140 } 1141 json.put(Message.ATTACHMENTS, jsonAttachments); 1142 } 1143 1144 return json; 1145 } 1146 1147 @NonNull 1148 @Override 1149 public String toString() { 1150 return body; 1151 } 1152 } 1153 1154 /** 1155 * If anyone wants to subscribe to changes in the messages database, they will need a thread 1156 * to handle callbacks on 1157 * This singleton conveniently provides such a thread, accessed and used via its Looper object 1158 */ 1159 public static class MessageLooper extends Thread { 1160 private static MessageLooper singleton = null; 1161 private static Looper looper = null; 1162 1163 private static final Lock looperReadyLock = new ReentrantLock(); 1164 private static final Condition looperReady = looperReadyLock.newCondition(); 1165 1166 private MessageLooper() { 1167 setName("MessageHelperLooper"); 1168 } 1169 1170 /** 1171 * Get the Looper object associated with this thread 1172 * 1173 * If the Looper has not been prepared, it is prepared as part of this method call. 1174 * Since this means a thread has to be spawned, this method might block until that thread is 1175 * ready to serve requests 1176 */ 1177 public static Looper getLooper() { 1178 if (singleton == null) { 1179 looperReadyLock.lock(); 1180 try { 1181 singleton = new MessageLooper(); 1182 singleton.start(); 1183 while (looper == null) { 1184 // Block until the looper is ready 1185 looperReady.await(); 1186 } 1187 } catch (InterruptedException e) { 1188 // I don't know when this would happen 1189 Log.e("SMSHelper", "Interrupted while waiting for Looper", e); 1190 return null; 1191 } finally { 1192 looperReadyLock.unlock(); 1193 } 1194 } 1195 1196 return looper; 1197 } 1198 1199 public void run() { 1200 looperReadyLock.lock(); 1201 try { 1202 Looper.prepare(); 1203 1204 looper = Looper.myLooper(); 1205 looperReady.signalAll(); 1206 } finally { 1207 looperReadyLock.unlock(); 1208 } 1209 1210 Looper.loop(); 1211 } 1212 } 1213 }