| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.bluetooth.mapclient; |
| |
| import android.bluetooth.BluetoothDevice; |
| import android.bluetooth.BluetoothMapClient; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.database.ContentObserver; |
| import android.database.Cursor; |
| import android.net.Uri; |
| import android.provider.Telephony; |
| import android.provider.Telephony.Mms; |
| import android.provider.Telephony.MmsSms; |
| import android.provider.Telephony.Sms; |
| import android.provider.Telephony.Threads; |
| import android.telephony.PhoneNumberUtils; |
| import android.telephony.SubscriptionInfo; |
| import android.telephony.SubscriptionManager; |
| import android.telephony.TelephonyManager; |
| import android.util.ArraySet; |
| import android.util.Log; |
| |
| import com.android.bluetooth.Utils; |
| import com.android.bluetooth.map.BluetoothMapbMessageMime; |
| import com.android.bluetooth.map.BluetoothMapbMessageMime.MimePart; |
| import com.android.vcard.VCardConstants; |
| import com.android.vcard.VCardEntry; |
| import com.android.vcard.VCardProperty; |
| |
| import com.google.android.mms.pdu.PduHeaders; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Set; |
| |
| class MapClientContent { |
| |
| private static final String INBOX_PATH = "telecom/msg/inbox"; |
| private static final String TAG = "MapClientContent"; |
| private static final int DEFAULT_CHARSET = 106; |
| private static final int ORIGINATOR_ADDRESS_TYPE = 137; |
| private static final int RECIPIENT_ADDRESS_TYPE = 151; |
| |
| final BluetoothDevice mDevice; |
| private final Context mContext; |
| private final Callbacks mCallbacks; |
| private final ContentResolver mResolver; |
| ContentObserver mContentObserver; |
| String mPhoneNumber = null; |
| private int mSubscriptionId = SubscriptionManager.INVALID_SUBSCRIPTION_ID; |
| private SubscriptionManager mSubscriptionManager; |
| private TelephonyManager mTelephonyManager; |
| private HashMap<String, Uri> mHandleToUriMap = new HashMap<>(); |
| private HashMap<Uri, MessageStatus> mUriToHandleMap = new HashMap<>(); |
| |
| /** |
| * Callbacks |
| * API to notify about statusChanges as observed from the content provider |
| */ |
| interface Callbacks { |
| void onMessageStatusChanged(String handle, int status); |
| } |
| |
| /** |
| * MapClientContent manages all interactions between Bluetooth and the messaging provider. |
| * |
| * Changes to the database are mirrored between the remote and local providers, specifically new |
| * messages, changes to read status, and removal of messages. |
| * |
| * context: the context that all content provider interactions are conducted |
| * MceStateMachine: the interface to send outbound updates such as when a message is read |
| * locally |
| * device: the associated Bluetooth device used for associating messages with a subscription |
| */ |
| MapClientContent(Context context, Callbacks callbacks, |
| BluetoothDevice device) { |
| mContext = context; |
| mDevice = device; |
| mCallbacks = callbacks; |
| mResolver = mContext.getContentResolver(); |
| |
| mSubscriptionManager = mContext.getSystemService(SubscriptionManager.class); |
| mTelephonyManager = mContext.getSystemService(TelephonyManager.class); |
| mSubscriptionManager |
| .addSubscriptionInfoRecord(mDevice.getAddress(), Utils.getName(mDevice), 0, |
| SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM); |
| SubscriptionInfo info = mSubscriptionManager |
| .getActiveSubscriptionInfoForIcc(mDevice.getAddress()); |
| if (info != null) { |
| mSubscriptionId = info.getSubscriptionId(); |
| } |
| |
| mContentObserver = new ContentObserver(null) { |
| @Override |
| public boolean deliverSelfNotifications() { |
| return false; |
| } |
| |
| @Override |
| public void onChange(boolean selfChange) { |
| logV("onChange"); |
| findChangeInDatabase(); |
| } |
| |
| @Override |
| public void onChange(boolean selfChange, Uri uri) { |
| logV("onChange" + uri.toString()); |
| findChangeInDatabase(); |
| } |
| }; |
| |
| clearMessages(mContext, mSubscriptionId); |
| mResolver.registerContentObserver(Sms.CONTENT_URI, true, mContentObserver); |
| mResolver.registerContentObserver(Mms.CONTENT_URI, true, mContentObserver); |
| mResolver.registerContentObserver(MmsSms.CONTENT_URI, true, mContentObserver); |
| } |
| |
| static void clearAllContent(Context context) { |
| SubscriptionManager subscriptionManager = |
| context.getSystemService(SubscriptionManager.class); |
| List<SubscriptionInfo> subscriptions = subscriptionManager.getActiveSubscriptionInfoList(); |
| if (subscriptions == null) { |
| Log.w(TAG, "Active subscription list is missing"); |
| return; |
| } |
| for (SubscriptionInfo info : subscriptions) { |
| if (info.getSubscriptionType() == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) { |
| clearMessages(context, info.getSubscriptionId()); |
| try { |
| subscriptionManager.removeSubscriptionInfoRecord(info.getIccId(), |
| SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM); |
| } catch (Exception e) { |
| Log.w(TAG, "cleanUp failed: " + e.toString()); |
| } |
| } |
| } |
| } |
| |
| private static void logD(String message) { |
| if (MapClientService.DBG) { |
| Log.d(TAG, message); |
| } |
| } |
| |
| private static void logV(String message) { |
| if (MapClientService.VDBG) { |
| Log.v(TAG, message); |
| } |
| } |
| |
| /** |
| * parseLocalNumber |
| * |
| * Determine the connected phone's number by extracting it from an inbound or outbound mms |
| * message. This number is necessary such that group messages can be displayed correctly. |
| */ |
| void parseLocalNumber(Bmessage message) { |
| if (mPhoneNumber != null) { |
| return; |
| } |
| if (INBOX_PATH.equals(message.getFolder())) { |
| ArrayList<VCardEntry> recipients = message.getRecipients(); |
| if (recipients != null && !recipients.isEmpty()) { |
| mPhoneNumber = PhoneNumberUtils.extractNetworkPortion( |
| getFirstRecipientNumber(message)); |
| } |
| } else { |
| mPhoneNumber = PhoneNumberUtils.extractNetworkPortion(getOriginatorNumber(message)); |
| } |
| |
| logV("Found phone number: " + mPhoneNumber); |
| } |
| |
| /** |
| * storeMessage |
| * |
| * Store a message in database with the associated handle and timestamp. |
| * The handle is used to associate the local message with the remote message. |
| */ |
| void storeMessage(Bmessage message, String handle, Long timestamp) { |
| switch (message.getType()) { |
| case MMS: |
| storeMms(message, handle, timestamp); |
| return; |
| case SMS_CDMA: |
| case SMS_GSM: |
| storeSms(message, handle, timestamp); |
| return; |
| default: |
| logD("Request to store unsupported message type: " + message.getType()); |
| } |
| } |
| |
| private void storeSms(Bmessage message, String handle, Long timestamp) { |
| logD("storeSms"); |
| logV(message.toString()); |
| VCardEntry originator = message.getOriginator(); |
| String recipients; |
| if (INBOX_PATH.equals(message.getFolder())) { |
| recipients = getOriginatorNumber(message); |
| } else { |
| recipients = getFirstRecipientNumber(message); |
| if (recipients == null) { |
| logD("invalid recipients"); |
| return; |
| } |
| } |
| logV("Received SMS from Number " + recipients); |
| String messageContent; |
| |
| Uri contentUri = INBOX_PATH.equalsIgnoreCase(message.getFolder()) ? Sms.Inbox.CONTENT_URI |
| : Sms.Sent.CONTENT_URI; |
| ContentValues values = new ContentValues(); |
| long threadId = getThreadId(message); |
| int readStatus = message.getStatus() == Bmessage.Status.READ ? 1 : 0; |
| |
| values.put(Sms.THREAD_ID, threadId); |
| values.put(Sms.ADDRESS, recipients); |
| values.put(Sms.BODY, message.getBodyContent()); |
| values.put(Sms.SUBSCRIPTION_ID, mSubscriptionId); |
| values.put(Sms.DATE, timestamp); |
| values.put(Sms.READ, readStatus); |
| |
| Uri results = mResolver.insert(contentUri, values); |
| mHandleToUriMap.put(handle, results); |
| mUriToHandleMap.put(results, new MessageStatus(handle, readStatus)); |
| logD("Map InsertedThread" + results); |
| } |
| |
| /** |
| * deleteMessage |
| * remove a message from the local provider based on a remote change |
| */ |
| void deleteMessage(String handle) { |
| logD("deleting handle" + handle); |
| Uri messageToChange = mHandleToUriMap.get(handle); |
| if (messageToChange != null) { |
| mResolver.delete(messageToChange, null); |
| } |
| } |
| |
| |
| /** |
| * markRead |
| * mark a message read in the local provider based on a remote change |
| */ |
| void markRead(String handle) { |
| logD("marking read " + handle); |
| Uri messageToChange = mHandleToUriMap.get(handle); |
| if (messageToChange != null) { |
| ContentValues values = new ContentValues(); |
| values.put(Sms.READ, 1); |
| mResolver.update(messageToChange, values, null); |
| } |
| } |
| |
| /** |
| * findChangeInDatabase |
| * compare the current state of the local content provider to the expected state and propagate |
| * changes to the remote. |
| */ |
| private void findChangeInDatabase() { |
| HashMap<Uri, MessageStatus> originalUriToHandleMap; |
| HashMap<Uri, MessageStatus> duplicateUriToHandleMap; |
| |
| originalUriToHandleMap = mUriToHandleMap; |
| duplicateUriToHandleMap = new HashMap<>(originalUriToHandleMap); |
| for (Uri uri : new Uri[]{Mms.CONTENT_URI, Sms.CONTENT_URI}) { |
| Cursor cursor = mResolver.query(uri, null, null, null, null); |
| while (cursor.moveToNext()) { |
| Uri index = Uri |
| .withAppendedPath(uri, cursor.getString(cursor.getColumnIndex("_id"))); |
| int readStatus = cursor.getInt(cursor.getColumnIndex(Sms.READ)); |
| MessageStatus currentMessage = duplicateUriToHandleMap.remove(index); |
| if (currentMessage != null && currentMessage.mRead != readStatus) { |
| logV(currentMessage.mHandle); |
| currentMessage.mRead = readStatus; |
| mCallbacks.onMessageStatusChanged(currentMessage.mHandle, |
| BluetoothMapClient.READ); |
| } |
| } |
| } |
| for (HashMap.Entry record : duplicateUriToHandleMap.entrySet()) { |
| logV("Deleted " + ((MessageStatus) record.getValue()).mHandle); |
| originalUriToHandleMap.remove(record.getKey()); |
| mCallbacks.onMessageStatusChanged(((MessageStatus) record.getValue()).mHandle, |
| BluetoothMapClient.DELETED); |
| } |
| } |
| |
| private void storeMms(Bmessage message, String handle, Long timestamp) { |
| logD("storeMms"); |
| logV(message.toString()); |
| try { |
| parseLocalNumber(message); |
| ContentValues values = new ContentValues(); |
| long threadId = getThreadId(message); |
| BluetoothMapbMessageMime mmsBmessage = new BluetoothMapbMessageMime(); |
| mmsBmessage.parseMsgPart(message.getBodyContent()); |
| int read = message.getStatus() == Bmessage.Status.READ ? 1 : 0; |
| Uri contentUri; |
| int messageBox; |
| if (INBOX_PATH.equalsIgnoreCase(message.getFolder())) { |
| contentUri = Mms.Inbox.CONTENT_URI; |
| messageBox = Mms.MESSAGE_BOX_INBOX; |
| } else { |
| contentUri = Mms.Sent.CONTENT_URI; |
| messageBox = Mms.MESSAGE_BOX_SENT; |
| } |
| logD("Parsed"); |
| values.put(Mms.SUBSCRIPTION_ID, mSubscriptionId); |
| values.put(Mms.THREAD_ID, threadId); |
| values.put(Mms.DATE, timestamp / 1000L); |
| values.put(Mms.TEXT_ONLY, true); |
| values.put(Mms.MESSAGE_BOX, messageBox); |
| values.put(Mms.READ, read); |
| values.put(Mms.SEEN, 0); |
| values.put(Mms.MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ); |
| values.put(Mms.MMS_VERSION, PduHeaders.CURRENT_MMS_VERSION); |
| values.put(Mms.PRIORITY, PduHeaders.PRIORITY_NORMAL); |
| values.put(Mms.READ_REPORT, PduHeaders.VALUE_NO); |
| values.put(Mms.TRANSACTION_ID, "T" + Long.toHexString(System.currentTimeMillis())); |
| values.put(Mms.DELIVERY_REPORT, PduHeaders.VALUE_NO); |
| values.put(Mms.LOCKED, 0); |
| values.put(Mms.CONTENT_TYPE, "application/vnd.wap.multipart.related"); |
| values.put(Mms.MESSAGE_CLASS, PduHeaders.MESSAGE_CLASS_PERSONAL_STR); |
| values.put(Mms.MESSAGE_SIZE, mmsBmessage.getSize()); |
| |
| Uri results = mResolver.insert(contentUri, values); |
| mHandleToUriMap.put(handle, results); |
| mUriToHandleMap.put(results, new MessageStatus(handle, read)); |
| |
| logD("Map InsertedThread" + results); |
| |
| for (MimePart part : mmsBmessage.getMimeParts()) { |
| storeMmsPart(part, results); |
| } |
| |
| storeAddressPart(message, results); |
| |
| String messageContent = mmsBmessage.getMessageAsText(); |
| |
| values.put(Mms.Part.CONTENT_TYPE, "plain/text"); |
| values.put(Mms.SUBSCRIPTION_ID, mSubscriptionId); |
| } catch (Exception e) { |
| Log.e(TAG, e.toString()); |
| throw e; |
| } |
| } |
| |
| private Uri storeMmsPart(MimePart messagePart, Uri messageUri) { |
| ContentValues values = new ContentValues(); |
| values.put(Mms.Part.CONTENT_TYPE, "text/plain"); |
| values.put(Mms.Part.CHARSET, DEFAULT_CHARSET); |
| values.put(Mms.Part.FILENAME, "text_1.txt"); |
| values.put(Mms.Part.NAME, "text_1.txt"); |
| values.put(Mms.Part.CONTENT_ID, messagePart.mContentId); |
| values.put(Mms.Part.CONTENT_LOCATION, messagePart.mContentLocation); |
| values.put(Mms.Part.TEXT, messagePart.getDataAsString()); |
| |
| Uri contentUri = Uri.parse(messageUri.toString() + "/part"); |
| Uri results = mResolver.insert(contentUri, values); |
| logD("Inserted" + results); |
| return results; |
| } |
| |
| private void storeAddressPart(Bmessage message, Uri messageUri) { |
| ContentValues values = new ContentValues(); |
| Uri contentUri = Uri.parse(messageUri.toString() + "/addr"); |
| String originator = getOriginatorNumber(message); |
| values.put(Mms.Addr.CHARSET, DEFAULT_CHARSET); |
| |
| values.put(Mms.Addr.ADDRESS, originator); |
| values.put(Mms.Addr.TYPE, ORIGINATOR_ADDRESS_TYPE); |
| mResolver.insert(contentUri, values); |
| |
| Set<String> messageContacts = new ArraySet<>(); |
| getRecipientsFromMessage(message, messageContacts); |
| for (String recipient : messageContacts) { |
| values.put(Mms.Addr.ADDRESS, recipient); |
| values.put(Mms.Addr.TYPE, RECIPIENT_ADDRESS_TYPE); |
| mResolver.insert(contentUri, values); |
| } |
| } |
| |
| private Uri insertIntoMmsTable(String subject) { |
| ContentValues mmsValues = new ContentValues(); |
| mmsValues.put(Mms.TEXT_ONLY, 1); |
| mmsValues.put(Mms.MESSAGE_TYPE, 128); |
| mmsValues.put(Mms.SUBJECT, subject); |
| return mResolver.insert(Mms.CONTENT_URI, mmsValues); |
| } |
| |
| /** |
| * cleanUp |
| * clear the subscription info and content on shutdown |
| */ |
| void cleanUp() { |
| mResolver.unregisterContentObserver(mContentObserver); |
| clearMessages(mContext, mSubscriptionId); |
| try { |
| mSubscriptionManager.removeSubscriptionInfoRecord(mDevice.getAddress(), |
| SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM); |
| } catch (Exception e) { |
| Log.w(TAG, "cleanUp failed: " + e.toString()); |
| } |
| } |
| |
| /** |
| * clearMessages |
| * clean up the content provider on startup |
| */ |
| private static void clearMessages(Context context, int subscriptionId) { |
| ContentResolver resolver = context.getContentResolver(); |
| String threads = new String(); |
| |
| Uri uri = Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(); |
| Cursor threadCursor = resolver.query(uri, null, null, null, null); |
| while (threadCursor.moveToNext()) { |
| threads += threadCursor.getInt(threadCursor.getColumnIndex(Threads._ID)) + ", "; |
| } |
| |
| resolver.delete(Sms.CONTENT_URI, Sms.SUBSCRIPTION_ID + " =? ", |
| new String[]{Integer.toString(subscriptionId)}); |
| resolver.delete(Mms.CONTENT_URI, Mms.SUBSCRIPTION_ID + " =? ", |
| new String[]{Integer.toString(subscriptionId)}); |
| if (threads.length() > 2) { |
| threads = threads.substring(0, threads.length() - 2); |
| resolver.delete(Threads.CONTENT_URI, Threads._ID + " IN (" + threads + ")", null); |
| } |
| } |
| |
| /** |
| * getThreadId |
| * utilize the originator and recipients to obtain the thread id |
| */ |
| private long getThreadId(Bmessage message) { |
| |
| Set<String> messageContacts = new ArraySet<>(); |
| String originator = PhoneNumberUtils.extractNetworkPortion(getOriginatorNumber(message)); |
| if (originator != null) { |
| messageContacts.add(originator); |
| } |
| getRecipientsFromMessage(message, messageContacts); |
| // If there is only one contact don't remove it. |
| if (messageContacts.isEmpty()) { |
| return Telephony.Threads.COMMON_THREAD; |
| } else if (messageContacts.size() > 1) { |
| messageContacts.removeIf(number -> (PhoneNumberUtils.areSamePhoneNumber(number, |
| mPhoneNumber, mTelephonyManager.getNetworkCountryIso()))); |
| } |
| |
| logV("Contacts = " + messageContacts.toString()); |
| return Telephony.Threads.getOrCreateThreadId(mContext, messageContacts); |
| } |
| |
| private void getRecipientsFromMessage(Bmessage message, Set<String> messageContacts) { |
| List<VCardEntry> recipients = message.getRecipients(); |
| for (VCardEntry recipient : recipients) { |
| List<VCardEntry.PhoneData> phoneData = recipient.getPhoneList(); |
| if (phoneData != null && !phoneData.isEmpty()) { |
| messageContacts |
| .add(PhoneNumberUtils.extractNetworkPortion(phoneData.get(0).getNumber())); |
| } |
| } |
| } |
| |
| private String getOriginatorNumber(Bmessage message) { |
| VCardEntry originator = message.getOriginator(); |
| if (originator == null) { |
| return null; |
| } |
| |
| List<VCardEntry.PhoneData> phoneData = originator.getPhoneList(); |
| if (phoneData == null || phoneData.isEmpty()) { |
| return null; |
| } |
| |
| return PhoneNumberUtils.extractNetworkPortion(phoneData.get(0).getNumber()); |
| } |
| |
| private String getFirstRecipientNumber(Bmessage message) { |
| List<VCardEntry> recipients = message.getRecipients(); |
| if (recipients == null || recipients.isEmpty()) { |
| return null; |
| } |
| |
| List<VCardEntry.PhoneData> phoneData = recipients.get(0).getPhoneList(); |
| if (phoneData == null || phoneData.isEmpty()) { |
| return null; |
| } |
| |
| return phoneData.get(0).getNumber(); |
| } |
| |
| /** |
| * addThreadContactToEntries |
| * utilizing the thread id fill in the appropriate fields of bmsg with the intended recipients |
| */ |
| boolean addThreadContactsToEntries(Bmessage bmsg, String thread) { |
| String threadId = Uri.parse(thread).getLastPathSegment(); |
| |
| logD("MATCHING THREAD" + threadId); |
| logD(MmsSms.CONTENT_CONVERSATIONS_URI + threadId + "/recipients"); |
| |
| Cursor cursor = mResolver |
| .query(Uri.withAppendedPath(MmsSms.CONTENT_CONVERSATIONS_URI, |
| threadId + "/recipients"), |
| null, null, |
| null, null); |
| |
| if (cursor.moveToNext()) { |
| logD("Columns" + Arrays.toString(cursor.getColumnNames())); |
| logV("CONTACT LIST: " + cursor.getString(cursor.getColumnIndex("recipient_ids"))); |
| addRecipientsToEntries(bmsg, |
| cursor.getString(cursor.getColumnIndex("recipient_ids")).split(" ")); |
| return true; |
| } else { |
| Log.w(TAG, "Thread Not Found"); |
| return false; |
| } |
| } |
| |
| |
| private void addRecipientsToEntries(Bmessage bmsg, String[] recipients) { |
| logV("CONTACT LIST: " + Arrays.toString(recipients)); |
| for (String recipient : recipients) { |
| Cursor cursor = mResolver |
| .query(Uri.parse("content://mms-sms/canonical-address/" + recipient), null, |
| null, null, |
| null); |
| while (cursor.moveToNext()) { |
| String number = cursor.getString(cursor.getColumnIndex(Mms.Addr.ADDRESS)); |
| logV("CONTACT number: " + number); |
| VCardEntry destEntry = new VCardEntry(); |
| VCardProperty destEntryPhone = new VCardProperty(); |
| destEntryPhone.setName(VCardConstants.PROPERTY_TEL); |
| destEntryPhone.addValues(number); |
| destEntry.addProperty(destEntryPhone); |
| bmsg.addRecipient(destEntry); |
| } |
| } |
| } |
| |
| /** |
| * MessageStatus |
| * |
| * Helper class to store associations between remote and local provider based on message handle |
| * and read status |
| */ |
| class MessageStatus { |
| |
| String mHandle; |
| int mRead; |
| |
| MessageStatus(String handle, int read) { |
| mHandle = handle; |
| mRead = read; |
| } |
| |
| @Override |
| public boolean equals(Object other) { |
| return ((other instanceof MessageStatus) && ((MessageStatus) other).mHandle |
| .equals(mHandle)); |
| } |
| } |
| } |