• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.bluetooth.mapclient;
18 
19 import static android.telephony.PhoneNumberUtils.areSamePhoneNumber;
20 import static android.telephony.PhoneNumberUtils.extractNetworkPortion;
21 
22 import android.bluetooth.BluetoothDevice;
23 import android.bluetooth.BluetoothMapClient;
24 import android.content.ContentResolver;
25 import android.content.ContentValues;
26 import android.content.Context;
27 import android.database.ContentObserver;
28 import android.database.Cursor;
29 import android.net.Uri;
30 import android.provider.BaseColumns;
31 import android.provider.Telephony;
32 import android.provider.Telephony.Mms;
33 import android.provider.Telephony.MmsSms;
34 import android.provider.Telephony.Sms;
35 import android.provider.Telephony.Threads;
36 import android.telephony.SubscriptionInfo;
37 import android.telephony.SubscriptionManager;
38 import android.telephony.TelephonyManager;
39 import android.util.ArraySet;
40 import android.util.Log;
41 
42 import com.android.bluetooth.Utils;
43 import com.android.bluetooth.map.BluetoothMapbMessageMime;
44 import com.android.bluetooth.map.BluetoothMapbMessageMime.MimePart;
45 import com.android.vcard.VCardConstants;
46 import com.android.vcard.VCardEntry;
47 import com.android.vcard.VCardProperty;
48 
49 import com.google.android.mms.pdu.PduHeaders;
50 
51 import java.time.Instant;
52 import java.time.ZoneId;
53 import java.time.format.DateTimeFormatter;
54 import java.util.ArrayList;
55 import java.util.Arrays;
56 import java.util.Collections;
57 import java.util.HashMap;
58 import java.util.List;
59 import java.util.Map;
60 import java.util.Objects;
61 import java.util.Set;
62 
63 class MapClientContent {
64     private static final String TAG = MapClientContent.class.getSimpleName();
65 
66     private static final String INBOX_PATH = "telecom/msg/inbox";
67     private static final int DEFAULT_CHARSET = 106;
68     private static final int ORIGINATOR_ADDRESS_TYPE = 137;
69     private static final int RECIPIENT_ADDRESS_TYPE = 151;
70 
71     private static final int NUM_RECENT_MSGS_TO_DUMP = 5;
72 
73     private enum Type {
74         UNKNOWN,
75         SMS,
76         MMS
77     }
78 
79     private enum Folder {
80         UNKNOWN,
81         INBOX,
82         SENT
83     }
84 
85     private final HashMap<String, Uri> mHandleToUriMap = new HashMap<>();
86     private final HashMap<Uri, MessageStatus> mUriToHandleMap = new HashMap<>();
87 
88     final ContentObserver mContentObserver;
89     private final Context mContext;
90     private final BluetoothDevice mDevice;
91     private final Callbacks mCallbacks;
92     private final ContentResolver mResolver;
93     private final SubscriptionManager mSubscriptionManager;
94     private final TelephonyManager mTelephonyManager;
95 
96     String mPhoneNumber = null;
97 
98     private int mSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
99 
100     /** Callbacks API to notify about statusChanges as observed from the content provider */
101     interface Callbacks {
onMessageStatusChanged(String handle, int status)102         void onMessageStatusChanged(String handle, int status);
103     }
104 
105     /**
106      * MapClientContent manages all interactions between Bluetooth and the messaging provider.
107      *
108      * <p>Changes to the database are mirrored between the remote and local providers, specifically
109      * new messages, changes to read status, and removal of messages.
110      *
111      * <p>Object is invalid after cleanUp() is called.
112      *
113      * <p>context: the context that all content provider interactions are conducted MceStateMachine:
114      * the interface to send outbound updates such as when a message is read locally device: the
115      * associated Bluetooth device used for associating messages with a subscription
116      */
MapClientContent(Context context, Callbacks callbacks, BluetoothDevice device)117     MapClientContent(Context context, Callbacks callbacks, BluetoothDevice device) {
118         mContext = context;
119         mDevice = device;
120         mCallbacks = callbacks;
121         mResolver = mContext.getContentResolver();
122         mSubscriptionManager = mContext.getSystemService(SubscriptionManager.class);
123         mTelephonyManager = mContext.getSystemService(TelephonyManager.class);
124         mSubscriptionManager.addSubscriptionInfoRecord(
125                 mDevice.getAddress(),
126                 Utils.getName(mDevice),
127                 0,
128                 SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM);
129         SubscriptionInfo info =
130                 mSubscriptionManager.getActiveSubscriptionInfoForIcc(mDevice.getAddress());
131         if (info != null) {
132             mSubscriptionId = info.getSubscriptionId();
133         }
134 
135         mContentObserver =
136                 new ContentObserver(null) {
137                     @Override
138                     public boolean deliverSelfNotifications() {
139                         return false;
140                     }
141 
142                     @Override
143                     public void onChange(boolean selfChange) {
144                         verbose("onChange(self=" + selfChange + ")");
145                         findChangeInDatabase();
146                     }
147 
148                     @Override
149                     public void onChange(boolean selfChange, Uri uri) {
150                         verbose("onChange(self=" + selfChange + ", uri=" + uri.toString() + ")");
151                         findChangeInDatabase();
152                     }
153                 };
154 
155         clearMessages(mContext, mSubscriptionId);
156         mResolver.registerContentObserver(Sms.CONTENT_URI, true, mContentObserver);
157         mResolver.registerContentObserver(Mms.CONTENT_URI, true, mContentObserver);
158         mResolver.registerContentObserver(MmsSms.CONTENT_URI, true, mContentObserver);
159     }
160 
clearAllContent(Context context)161     static void clearAllContent(Context context) {
162         SubscriptionManager subscriptionManager =
163                 context.getSystemService(SubscriptionManager.class);
164         List<SubscriptionInfo> subscriptions = subscriptionManager.getActiveSubscriptionInfoList();
165         if (subscriptions == null) {
166             Log.w(TAG, "[AllDevices] Active subscription list is missing");
167             return;
168         }
169         for (SubscriptionInfo info : subscriptions) {
170             if (info.getSubscriptionType() == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
171                 clearMessages(context, info.getSubscriptionId());
172                 try {
173                     subscriptionManager.removeSubscriptionInfoRecord(
174                             info.getIccId(), SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM);
175                 } catch (Exception e) {
176                     Log.w(TAG, "[AllDevices] cleanUp failed: " + e.toString());
177                 }
178             }
179         }
180     }
181 
error(String message)182     private void error(String message) {
183         Log.e(TAG, "[" + mDevice + "] " + message);
184     }
185 
warn(String message)186     private void warn(String message) {
187         Log.w(TAG, "[" + mDevice + "] " + message);
188     }
189 
warn(String message, Exception e)190     private void warn(String message, Exception e) {
191         Log.w(TAG, "[" + mDevice + "] " + message, e);
192     }
193 
info(String message)194     private void info(String message) {
195         Log.i(TAG, "[" + mDevice + "] " + message);
196     }
197 
debug(String message)198     private void debug(String message) {
199         Log.d(TAG, "[" + mDevice + "] " + message);
200     }
201 
verbose(String message)202     private void verbose(String message) {
203         Log.v(TAG, "[" + mDevice + "] " + message);
204     }
205 
206     /**
207      * This number is necessary for thread_id to work properly. thread_id is needed for (group) MMS
208      * messages to be displayed/stitched correctly.
209      */
setRemoteDeviceOwnNumber(String phoneNumber)210     void setRemoteDeviceOwnNumber(String phoneNumber) {
211         mPhoneNumber = phoneNumber;
212         verbose("Remote device " + mDevice.getAddress() + " phone number set to: " + mPhoneNumber);
213     }
214 
215     /**
216      * storeMessage
217      *
218      * <p>Store a message in database with the associated handle and timestamp. The handle is used
219      * to associate the local message with the remote message.
220      */
storeMessage(Bmessage message, String handle, Long timestamp, boolean seen)221     void storeMessage(Bmessage message, String handle, Long timestamp, boolean seen) {
222         info(
223                 "storeMessage(time="
224                         + timestamp
225                         + "["
226                         + toDatetimeString(timestamp)
227                         + "]"
228                         + ", handle="
229                         + handle
230                         + ", type="
231                         + message.getType()
232                         + ", folder="
233                         + message.getFolder());
234 
235         switch (message.getType()) {
236             case MMS:
237                 storeMms(message, handle, timestamp, seen);
238                 return;
239             case SMS_CDMA:
240             case SMS_GSM:
241                 storeSms(message, handle, timestamp, seen);
242                 return;
243             default:
244                 debug("Request to store unsupported message type: " + message.getType());
245         }
246     }
247 
storeSms(Bmessage message, String handle, Long timestamp, boolean seen)248     private void storeSms(Bmessage message, String handle, Long timestamp, boolean seen) {
249         debug("storeSms");
250         verbose(message.toString());
251         final String recipients;
252         if (INBOX_PATH.equals(message.getFolder())) {
253             recipients = getOriginatorNumber(message);
254         } else {
255             recipients = getFirstRecipientNumber(message);
256             if (recipients == null) {
257                 debug("invalid recipients");
258                 return;
259             }
260         }
261         verbose("Received SMS from Number " + recipients);
262 
263         Uri contentUri =
264                 INBOX_PATH.equalsIgnoreCase(message.getFolder())
265                         ? Sms.Inbox.CONTENT_URI
266                         : Sms.Sent.CONTENT_URI;
267         ContentValues values = new ContentValues();
268         long threadId = getThreadId(message);
269         int readStatus = message.getStatus() == Bmessage.Status.READ ? 1 : 0;
270 
271         values.put(Sms.THREAD_ID, threadId);
272         values.put(Sms.ADDRESS, recipients);
273         values.put(Sms.BODY, message.getBodyContent());
274         values.put(Sms.SUBSCRIPTION_ID, mSubscriptionId);
275         values.put(Sms.DATE, timestamp);
276         values.put(Sms.READ, readStatus);
277         values.put(Sms.SEEN, seen);
278 
279         Uri results = mResolver.insert(contentUri, values);
280         if (results == null) {
281             error("Failed to get SMS URI, insert failed. Dropping message.");
282             return;
283         }
284 
285         mHandleToUriMap.put(handle, results);
286         mUriToHandleMap.put(results, new MessageStatus(handle, readStatus));
287         debug("Map InsertedThread" + results);
288     }
289 
290     /** deleteMessage remove a message from the local provider based on a remote change */
deleteMessage(String handle)291     void deleteMessage(String handle) {
292         debug("deleting handle" + handle);
293         Uri messageToChange = mHandleToUriMap.get(handle);
294         if (messageToChange != null) {
295             mResolver.delete(messageToChange, null);
296         }
297     }
298 
299     /** markRead mark a message read in the local provider based on a remote change */
markRead(String handle)300     void markRead(String handle) {
301         debug("marking read " + handle);
302         Uri messageToChange = mHandleToUriMap.get(handle);
303         if (messageToChange != null) {
304             ContentValues values = new ContentValues();
305             values.put(Sms.READ, 1);
306             mResolver.update(messageToChange, values, null);
307         }
308     }
309 
310     /**
311      * findChangeInDatabase compare the current state of the local content provider to the expected
312      * state and propagate changes to the remote.
313      */
findChangeInDatabase()314     private void findChangeInDatabase() {
315         HashMap<Uri, MessageStatus> originalUriToHandleMap;
316         HashMap<Uri, MessageStatus> duplicateUriToHandleMap;
317 
318         originalUriToHandleMap = mUriToHandleMap;
319         duplicateUriToHandleMap = new HashMap<>(originalUriToHandleMap);
320         for (Uri uri : new Uri[] {Mms.CONTENT_URI, Sms.CONTENT_URI}) {
321             try (Cursor cursor = mResolver.query(uri, null, null, null, null)) {
322                 while (cursor.moveToNext()) {
323                     Uri index =
324                             Uri.withAppendedPath(
325                                     uri, cursor.getString(cursor.getColumnIndex("_id")));
326                     int readStatus = cursor.getInt(cursor.getColumnIndex(Sms.READ));
327                     MessageStatus currentMessage = duplicateUriToHandleMap.remove(index);
328                     if (currentMessage != null && currentMessage.mRead != readStatus) {
329                         verbose(currentMessage.mHandle);
330                         currentMessage.mRead = readStatus;
331                         mCallbacks.onMessageStatusChanged(
332                                 currentMessage.mHandle, BluetoothMapClient.READ);
333                     }
334                 }
335             }
336         }
337         for (Map.Entry record : duplicateUriToHandleMap.entrySet()) {
338             verbose("Deleted " + ((MessageStatus) record.getValue()).mHandle);
339             originalUriToHandleMap.remove(record.getKey());
340             mCallbacks.onMessageStatusChanged(
341                     ((MessageStatus) record.getValue()).mHandle, BluetoothMapClient.DELETED);
342         }
343     }
344 
storeMms(Bmessage message, String handle, Long timestamp, boolean seen)345     private void storeMms(Bmessage message, String handle, Long timestamp, boolean seen) {
346         debug("storeMms");
347         verbose(message.toString());
348         try {
349             ContentValues values = new ContentValues();
350             long threadId = getThreadId(message);
351             BluetoothMapbMessageMime mmsBmessage = new BluetoothMapbMessageMime();
352             mmsBmessage.parseMsgPart(message.getBodyContent());
353             int read = message.getStatus() == Bmessage.Status.READ ? 1 : 0;
354             Uri contentUri;
355             int messageBox;
356             if (INBOX_PATH.equalsIgnoreCase(message.getFolder())) {
357                 contentUri = Mms.Inbox.CONTENT_URI;
358                 messageBox = Mms.MESSAGE_BOX_INBOX;
359             } else {
360                 contentUri = Mms.Sent.CONTENT_URI;
361                 messageBox = Mms.MESSAGE_BOX_SENT;
362             }
363             debug("Parsed");
364             values.put(Mms.SUBSCRIPTION_ID, mSubscriptionId);
365             values.put(Mms.THREAD_ID, threadId);
366             values.put(Mms.DATE, timestamp / 1000L);
367             values.put(Mms.TEXT_ONLY, true);
368             values.put(Mms.MESSAGE_BOX, messageBox);
369             values.put(Mms.READ, read);
370             values.put(Mms.SEEN, seen);
371             values.put(Mms.MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ);
372             values.put(Mms.MMS_VERSION, PduHeaders.CURRENT_MMS_VERSION);
373             values.put(Mms.PRIORITY, PduHeaders.PRIORITY_NORMAL);
374             values.put(Mms.READ_REPORT, PduHeaders.VALUE_NO);
375             values.put(Mms.TRANSACTION_ID, "T" + Long.toHexString(System.currentTimeMillis()));
376             values.put(Mms.DELIVERY_REPORT, PduHeaders.VALUE_NO);
377             values.put(Mms.LOCKED, 0);
378             values.put(Mms.CONTENT_TYPE, "application/vnd.wap.multipart.related");
379             values.put(Mms.MESSAGE_CLASS, PduHeaders.MESSAGE_CLASS_PERSONAL_STR);
380             values.put(Mms.MESSAGE_SIZE, mmsBmessage.getSize());
381 
382             Uri results = mResolver.insert(contentUri, values);
383             if (results == null) {
384                 error("Failed to get MMS entry URI. Cannot store MMS parts. Dropping message.");
385                 return;
386             }
387 
388             mHandleToUriMap.put(handle, results);
389             mUriToHandleMap.put(results, new MessageStatus(handle, read));
390 
391             debug("Map InsertedThread" + results);
392 
393             // Some Messenger Applications don't listen to address table changes and only listen
394             // for message content changes. Adding the address parts first makes it so they're
395             // already in the tables when a given app syncs due to content updates. Otherwise, we
396             // risk a race where the address content may not be ready.
397             storeAddressPart(message, results);
398 
399             for (MimePart part : mmsBmessage.getMimeParts()) {
400                 storeMmsPart(part, results);
401             }
402         } catch (Exception e) {
403             error("Error while storing MMS: " + e.toString());
404             throw e;
405         }
406     }
407 
storeMmsPart(MimePart messagePart, Uri messageUri)408     private Uri storeMmsPart(MimePart messagePart, Uri messageUri) {
409         ContentValues values = new ContentValues();
410         values.put(Mms.Part.CONTENT_TYPE, "text/plain");
411         values.put(Mms.Part.CHARSET, DEFAULT_CHARSET);
412         values.put(Mms.Part.FILENAME, "text_1.txt");
413         values.put(Mms.Part.NAME, "text_1.txt");
414         values.put(Mms.Part.CONTENT_ID, messagePart.mContentId);
415         values.put(Mms.Part.CONTENT_LOCATION, messagePart.mContentLocation);
416         values.put(Mms.Part.TEXT, messagePart.getDataAsString());
417 
418         Uri contentUri = Uri.parse(messageUri.toString() + "/part");
419         Uri results = mResolver.insert(contentUri, values);
420 
421         if (results == null) {
422             warn("failed to insert MMS part");
423             return null;
424         }
425 
426         debug("Inserted" + results);
427         return results;
428     }
429 
storeAddressPart(Bmessage message, Uri messageUri)430     private void storeAddressPart(Bmessage message, Uri messageUri) {
431         ContentValues values = new ContentValues();
432         Uri contentUri = Uri.parse(messageUri.toString() + "/addr");
433         String originator = getOriginatorNumber(message);
434         values.put(Mms.Addr.CHARSET, DEFAULT_CHARSET);
435         values.put(Mms.Addr.ADDRESS, originator);
436         values.put(Mms.Addr.TYPE, ORIGINATOR_ADDRESS_TYPE);
437 
438         Uri results = mResolver.insert(contentUri, values);
439         if (results == null) {
440             warn("failed to insert originator address");
441         }
442 
443         Set<String> messageContacts = new ArraySet<>();
444         getRecipientsFromMessage(message, messageContacts);
445         for (String recipient : messageContacts) {
446             values.put(Mms.Addr.ADDRESS, recipient);
447             values.put(Mms.Addr.TYPE, RECIPIENT_ADDRESS_TYPE);
448             results = mResolver.insert(contentUri, values);
449             if (results == null) {
450                 warn("failed to insert recipient address");
451             }
452         }
453     }
454 
455     /** cleanUp clear the subscription info and content on shutdown */
cleanUp()456     void cleanUp() {
457         debug(
458                 "cleanUp(device="
459                         + Utils.getLoggableAddress(mDevice)
460                         + "subscriptionId="
461                         + mSubscriptionId);
462         mResolver.unregisterContentObserver(mContentObserver);
463         clearMessages(mContext, mSubscriptionId);
464         try {
465             mSubscriptionManager.removeSubscriptionInfoRecord(
466                     mDevice.getAddress(), SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM);
467             mSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID;
468         } catch (Exception e) {
469             warn("cleanUp failed: " + e.toString());
470         }
471     }
472 
473     /** clearMessages clean up the content provider on startup */
clearMessages(Context context, int subscriptionId)474     private static void clearMessages(Context context, int subscriptionId) {
475         Log.d(TAG, "[AllDevices] clearMessages(subscriptionId=" + subscriptionId);
476 
477         ContentResolver resolver = context.getContentResolver();
478         StringBuilder threadsBuilder = new StringBuilder();
479 
480         Uri uri = Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build();
481         try (Cursor threadCursor = resolver.query(uri, null, null, null, null)) {
482             while (threadCursor.moveToNext()) {
483                 threadsBuilder
484                         .append(threadCursor.getInt(threadCursor.getColumnIndex(Threads._ID)))
485                         .append(", ");
486             }
487         }
488 
489         resolver.delete(
490                 Sms.CONTENT_URI,
491                 Sms.SUBSCRIPTION_ID + " =? ",
492                 new String[] {Integer.toString(subscriptionId)});
493         resolver.delete(
494                 Mms.CONTENT_URI,
495                 Mms.SUBSCRIPTION_ID + " =? ",
496                 new String[] {Integer.toString(subscriptionId)});
497         if (threadsBuilder.length() > 2) {
498             String threads = threadsBuilder.substring(0, threadsBuilder.length() - 2);
499             resolver.delete(Threads.CONTENT_URI, Threads._ID + " IN (" + threads + ")", null);
500         }
501     }
502 
503     /** getThreadId utilize the originator and recipients to obtain the thread id */
getThreadId(Bmessage message)504     private long getThreadId(Bmessage message) {
505         Set<String> messageContacts = new ArraySet<>();
506         String originator = extractNetworkPortion(getOriginatorNumber(message));
507         if (originator != null) {
508             messageContacts.add(originator);
509         }
510         getRecipientsFromMessage(message, messageContacts);
511         // If there is only one contact don't remove it.
512         if (messageContacts.isEmpty()) {
513             return Telephony.Threads.COMMON_THREAD;
514         } else if (messageContacts.size() > 1) {
515             if (mPhoneNumber == null) {
516                 warn("getThreadId called, mPhoneNumber never found.");
517             }
518             final String networkCountryIso = mTelephonyManager.getNetworkCountryIso();
519             messageContacts.removeIf(
520                     number -> areSamePhoneNumber(number, mPhoneNumber, networkCountryIso));
521         }
522 
523         verbose("Contacts = " + messageContacts.toString());
524         return Telephony.Threads.getOrCreateThreadId(mContext, messageContacts);
525     }
526 
getRecipientsFromMessage(Bmessage message, Set<String> messageContacts)527     private static void getRecipientsFromMessage(Bmessage message, Set<String> messageContacts) {
528         message.getRecipients().stream()
529                 .map(recipient -> recipient.getPhoneList())
530                 .filter(phoneData -> phoneData != null && !phoneData.isEmpty())
531                 .map(phoneData -> extractNetworkPortion(phoneData.get(0).getNumber()))
532                 .forEach(messageContacts::add);
533     }
534 
getOriginatorNumber(Bmessage message)535     private static String getOriginatorNumber(Bmessage message) {
536         VCardEntry originator = message.getOriginator();
537         if (originator == null) {
538             return null;
539         }
540 
541         List<VCardEntry.PhoneData> phoneData = originator.getPhoneList();
542         if (phoneData == null || phoneData.isEmpty()) {
543             return null;
544         }
545 
546         return extractNetworkPortion(phoneData.get(0).getNumber());
547     }
548 
getFirstRecipientNumber(Bmessage message)549     private static String getFirstRecipientNumber(Bmessage message) {
550         List<VCardEntry> recipients = message.getRecipients();
551         if (recipients == null || recipients.isEmpty()) {
552             return null;
553         }
554 
555         List<VCardEntry.PhoneData> phoneData = recipients.get(0).getPhoneList();
556         if (phoneData == null || phoneData.isEmpty()) {
557             return null;
558         }
559 
560         return phoneData.get(0).getNumber();
561     }
562 
563     /**
564      * addThreadContactToEntries utilizing the thread id fill in the appropriate fields of bmsg with
565      * the intended recipients
566      */
addThreadContactsToEntries(Bmessage bmsg, String thread)567     boolean addThreadContactsToEntries(Bmessage bmsg, String thread) {
568         String threadId = Uri.parse(thread).getLastPathSegment();
569 
570         debug("MATCHING THREAD" + threadId);
571         debug(MmsSms.CONTENT_CONVERSATIONS_URI + threadId + "/recipients");
572 
573         try (Cursor cursor =
574                 mResolver.query(
575                         Uri.withAppendedPath(
576                                 MmsSms.CONTENT_CONVERSATIONS_URI, threadId + "/recipients"),
577                         null,
578                         null,
579                         null,
580                         null)) {
581 
582             if (cursor.moveToNext()) {
583                 debug("Columns" + Arrays.toString(cursor.getColumnNames()));
584                 verbose(
585                         "CONTACT LIST: "
586                                 + cursor.getString(cursor.getColumnIndex("recipient_ids")));
587                 addRecipientsToEntries(
588                         bmsg, cursor.getString(cursor.getColumnIndex("recipient_ids")).split(" "));
589                 return true;
590             } else {
591                 warn("Thread Not Found");
592                 return false;
593             }
594         }
595     }
596 
addRecipientsToEntries(Bmessage bmsg, String[] recipients)597     private void addRecipientsToEntries(Bmessage bmsg, String[] recipients) {
598         verbose("CONTACT LIST: " + Arrays.toString(recipients));
599         for (String recipient : recipients) {
600             try (Cursor cursor =
601                     mResolver.query(
602                             Uri.parse("content://mms-sms/canonical-address/" + recipient),
603                             null,
604                             null,
605                             null,
606                             null)) {
607                 while (cursor.moveToNext()) {
608                     String number = cursor.getString(cursor.getColumnIndex(Mms.Addr.ADDRESS));
609                     verbose("CONTACT number: " + number);
610                     VCardEntry destEntry = new VCardEntry();
611                     VCardProperty destEntryPhone = new VCardProperty();
612                     destEntryPhone.setName(VCardConstants.PROPERTY_TEL);
613                     destEntryPhone.addValues(number);
614                     destEntry.addProperty(destEntryPhone);
615                     bmsg.addRecipient(destEntry);
616                 }
617             }
618         }
619     }
620 
621     /**
622      * Get the total number of messages we've stored under this device's subscription ID, for a
623      * given message source, provided by the "uri" parameter.
624      */
getStoredMessagesCount(Uri uri)625     private int getStoredMessagesCount(Uri uri) {
626         if (mSubscriptionId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
627             verbose("getStoredMessagesCount(uri=" + uri + "): Failed, no subscription ID");
628             return 0;
629         }
630 
631         Cursor cursor = null;
632         if (Sms.CONTENT_URI.equals(uri)
633                 || Sms.Inbox.CONTENT_URI.equals(uri)
634                 || Sms.Sent.CONTENT_URI.equals(uri)) {
635             cursor =
636                     mResolver.query(
637                             uri,
638                             new String[] {"count(*)"},
639                             Sms.SUBSCRIPTION_ID + " =? ",
640                             new String[] {Integer.toString(mSubscriptionId)},
641                             null);
642         } else if (Mms.CONTENT_URI.equals(uri)
643                 || Mms.Inbox.CONTENT_URI.equals(uri)
644                 || Mms.Sent.CONTENT_URI.equals(uri)) {
645             cursor =
646                     mResolver.query(
647                             uri,
648                             new String[] {"count(*)"},
649                             Mms.SUBSCRIPTION_ID + " =? ",
650                             new String[] {Integer.toString(mSubscriptionId)},
651                             null);
652         } else if (Threads.CONTENT_URI.equals(uri)) {
653             uri = Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build();
654             cursor = mResolver.query(uri, new String[] {"count(*)"}, null, null, null);
655         }
656 
657         if (cursor == null) {
658             return 0;
659         }
660 
661         cursor.moveToFirst();
662         int count = cursor.getInt(0);
663         cursor.close();
664 
665         return count;
666     }
667 
getRecentMessagesFromFolder(Folder folder)668     private List<MessageDumpElement> getRecentMessagesFromFolder(Folder folder) {
669         final Uri smsUri;
670         final Uri mmsUri;
671         if (folder == Folder.INBOX) {
672             smsUri = Sms.Inbox.CONTENT_URI;
673             mmsUri = Mms.Inbox.CONTENT_URI;
674         } else if (folder == Folder.SENT) {
675             smsUri = Sms.Sent.CONTENT_URI;
676             mmsUri = Mms.Sent.CONTENT_URI;
677         } else {
678             warn("getRecentMessagesFromFolder: Failed, unsupported folder=" + folder);
679             return null;
680         }
681 
682         List<MessageDumpElement> messages = new ArrayList<>();
683         for (Uri uri : new Uri[] {smsUri, mmsUri}) {
684             messages.addAll(getMessagesFromUri(uri));
685         }
686         verbose(
687                 "getRecentMessagesFromFolder: "
688                         + folder
689                         + ", "
690                         + messages.size()
691                         + " messages found.");
692 
693         Collections.sort(messages);
694         if (messages.size() > NUM_RECENT_MSGS_TO_DUMP) {
695             return messages.subList(0, NUM_RECENT_MSGS_TO_DUMP);
696         }
697         return messages;
698     }
699 
getMessagesFromUri(Uri uri)700     private List<MessageDumpElement> getMessagesFromUri(Uri uri) {
701         debug("getMessagesFromUri: uri=" + uri);
702         List<MessageDumpElement> messages = new ArrayList<>();
703 
704         if (mSubscriptionId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
705             warn("getMessagesFromUri: Failed, no subscription ID");
706             return messages;
707         }
708 
709         Type type = getMessageTypeFromUri(uri);
710         if (type == Type.UNKNOWN) {
711             warn("getMessagesFromUri: unknown message type");
712             return messages;
713         }
714 
715         String[] selectionArgs = new String[] {Integer.toString(mSubscriptionId)};
716         String limit = " LIMIT " + NUM_RECENT_MSGS_TO_DUMP;
717         String[] projection = null;
718         String selectionClause = null;
719         String threadIdColumnName = null;
720         String timestampColumnName = null;
721 
722         if (type == Type.SMS) {
723             projection = new String[] {BaseColumns._ID, Sms.THREAD_ID, Sms.DATE};
724             selectionClause = Sms.SUBSCRIPTION_ID + " =? ";
725             threadIdColumnName = Sms.THREAD_ID;
726             timestampColumnName = Sms.DATE;
727         } else if (type == Type.MMS) {
728             projection = new String[] {BaseColumns._ID, Mms.THREAD_ID, Mms.DATE};
729             selectionClause = Mms.SUBSCRIPTION_ID + " =? ";
730             threadIdColumnName = Mms.THREAD_ID;
731             timestampColumnName = Mms.DATE;
732         }
733 
734         Cursor cursor =
735                 mResolver.query(
736                         uri,
737                         projection,
738                         selectionClause,
739                         selectionArgs,
740                         timestampColumnName + " DESC" + limit);
741 
742         try {
743             if (cursor == null) {
744                 warn("getMessagesFromUri: null cursor for uri=" + uri);
745                 return messages;
746             }
747             verbose("Number of rows in cursor = " + cursor.getCount() + ", for uri=" + uri);
748 
749             cursor.moveToPosition(-1);
750             while (cursor.moveToNext()) {
751                 // Even though {@link storeSms} and {@link storeMms} use Uris that contain the
752                 // folder name (e.g., {@code Sms.Inbox.CONTENT_URI}), the Uri returned by
753                 // {@link ContentResolver#insert} does not (e.g., {@code Sms.CONTENT_URI}).
754                 // Therefore, the Uris in the keyset of {@code mUriToHandleMap} do not contain
755                 // the folder name, but unfortunately, the Uri passed in to query the database
756                 // does contains the folder name, so we can't simply append messageId to the
757                 // passed-in Uri.
758                 String messageId = cursor.getString(cursor.getColumnIndex(BaseColumns._ID));
759                 Uri messageUri =
760                         Uri.withAppendedPath(
761                                 type == Type.SMS ? Sms.CONTENT_URI : Mms.CONTENT_URI, messageId);
762 
763                 MessageStatus handleAndStatus = mUriToHandleMap.get(messageUri);
764                 String messageHandle = "<unknown>";
765                 if (handleAndStatus == null) {
766                     warn("getMessagesFromUri: no entry for message uri=" + messageUri);
767                 } else {
768                     messageHandle = handleAndStatus.mHandle;
769                 }
770 
771                 long timestamp = cursor.getLong(cursor.getColumnIndex(timestampColumnName));
772                 // TODO: why does `storeMms` truncate down to the seconds instead of keeping it
773                 // millisec, like `storeSms`?
774                 if (type == Type.MMS) {
775                     timestamp *= 1000L;
776                 }
777 
778                 messages.add(
779                         new MessageDumpElement(
780                                 messageHandle,
781                                 messageUri,
782                                 timestamp,
783                                 cursor.getLong(cursor.getColumnIndex(threadIdColumnName)),
784                                 type));
785             }
786         } catch (Exception e) {
787             warn("Exception when querying db for dumpsys", e);
788         } finally {
789             cursor.close();
790         }
791         return messages;
792     }
793 
getMessageTypeFromUri(Uri uri)794     private static Type getMessageTypeFromUri(Uri uri) {
795         if (Sms.CONTENT_URI.equals(uri)
796                 || Sms.Inbox.CONTENT_URI.equals(uri)
797                 || Sms.Sent.CONTENT_URI.equals(uri)) {
798             return Type.SMS;
799         } else if (Mms.CONTENT_URI.equals(uri)
800                 || Mms.Inbox.CONTENT_URI.equals(uri)
801                 || Mms.Sent.CONTENT_URI.equals(uri)) {
802             return Type.MMS;
803         } else {
804             return Type.UNKNOWN;
805         }
806     }
807 
dump(StringBuilder sb)808     public void dump(StringBuilder sb) {
809         sb.append("    Device Message DB:");
810         sb.append("\n      Subscription ID: ").append(mSubscriptionId);
811         if (mSubscriptionId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
812             sb.append("\n      SMS Messages (Inbox/Sent/Total): ")
813                     .append(getStoredMessagesCount(Sms.Inbox.CONTENT_URI))
814                     .append(" / ")
815                     .append(getStoredMessagesCount(Sms.Sent.CONTENT_URI))
816                     .append(" / ")
817                     .append(getStoredMessagesCount(Sms.CONTENT_URI));
818 
819             sb.append("\n      MMS Messages (Inbox/Sent/Total): ")
820                     .append(getStoredMessagesCount(Mms.Inbox.CONTENT_URI))
821                     .append(" / ")
822                     .append(getStoredMessagesCount(Mms.Sent.CONTENT_URI))
823                     .append(" / ")
824                     .append(getStoredMessagesCount(Mms.CONTENT_URI));
825 
826             sb.append("\n      Threads: ").append(getStoredMessagesCount(Threads.CONTENT_URI));
827 
828             sb.append("\n      Most recent 'Sent' messages:");
829             sb.append("\n        ").append(MessageDumpElement.getFormattedColumnNames());
830             for (MessageDumpElement e : getRecentMessagesFromFolder(Folder.SENT)) {
831                 sb.append("\n        ").append(e);
832             }
833             sb.append("\n      Most recent 'Inbox' messages:");
834             sb.append("\n        ").append(MessageDumpElement.getFormattedColumnNames());
835             for (MessageDumpElement e : getRecentMessagesFromFolder(Folder.INBOX)) {
836                 sb.append("\n        ").append(e);
837             }
838         }
839         sb.append("\n");
840     }
841 
842     /**
843      * MessageStatus
844      *
845      * <p>Helper class to store associations between remote and local provider based on message
846      * handle and read status
847      */
848     static class MessageStatus {
849 
850         String mHandle;
851         int mRead;
852 
MessageStatus(String handle, int read)853         MessageStatus(String handle, int read) {
854             mHandle = handle;
855             mRead = read;
856         }
857 
858         @Override
equals(Object obj)859         public boolean equals(Object obj) {
860             if (obj == this) {
861                 return true;
862             }
863 
864             if (!(obj instanceof MessageStatus other)) {
865                 return false;
866             }
867 
868             return other.mHandle.equals(mHandle);
869         }
870 
871         @Override
hashCode()872         public int hashCode() {
873             return Objects.hash(mHandle);
874         }
875     }
876 
877     @SuppressWarnings("GoodTime") // Use system time zone to render times for logging
toDatetimeString(long epochMillis)878     private static String toDatetimeString(long epochMillis) {
879         return DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.SSS")
880                 .format(
881                         Instant.ofEpochMilli(epochMillis)
882                                 .atZone(ZoneId.systemDefault())
883                                 .toLocalDateTime());
884     }
885 
886     private record MessageDumpElement(
887             String handle, Uri uri, long timestamp, long threadId, Type type)
888             implements Comparable<MessageDumpElement> {
889 
getFormattedColumnNames()890         public static String getFormattedColumnNames() {
891             return String.format(
892                     "%-19s %s %-16s %s %s", "Timestamp", "ThreadId", "Handle", "Type", "Uri");
893         }
894 
895         @Override
toString()896         public String toString() {
897             return String.format(
898                     "%-19s %8d %-16s %-4s %s",
899                     toDatetimeString(timestamp), threadId, handle, type, uri);
900         }
901 
902         @Override
compareTo(MessageDumpElement e)903         public int compareTo(MessageDumpElement e) {
904             // we want reverse chronological.
905             if (this.timestamp < e.timestamp) {
906                 return 1;
907             } else if (this.timestamp > e.timestamp) {
908                 return -1;
909             } else {
910                 return 0;
911             }
912         }
913     }
914 }
915