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 }