From e84ee5f9e285900dae8852de693313558c3c99cf Mon Sep 17 00:00:00 2001 From: Danning Chen Date: Wed, 29 Jan 2020 15:11:03 -0800 Subject: [PATCH] Listen to the new calls and SMS/MMS messages and store the derived events in the People Service event store Change-Id: I1b409fab255ba9d733e4ca482de0e329c523515a Test: Build and verify the behavior on a test device, and unit tests Bug: 146522621 --- .../people/data/CallLogQueryHelper.java | 121 ++++++++++++ .../server/people/data/DataManager.java | 154 +++++++++++++++- .../server/people/data/MmsQueryHelper.java | 151 +++++++++++++++ .../server/people/data/PackageData.java | 23 +-- .../server/people/data/SmsQueryHelper.java | 119 ++++++++++++ .../android/server/people/data/UserData.java | 49 +++-- .../com/android/server/people/data/Utils.java | 49 +++++ .../people/data/CallLogQueryHelperTest.java | 169 +++++++++++++++++ .../server/people/data/DataManagerTest.java | 153 +++++++++++++++ .../people/data/MmsQueryHelperTest.java | 174 ++++++++++++++++++ .../server/people/data/PackageDataTest.java | 10 +- .../people/data/SmsQueryHelperTest.java | 151 +++++++++++++++ 12 files changed, 1283 insertions(+), 40 deletions(-) create mode 100644 services/people/java/com/android/server/people/data/CallLogQueryHelper.java create mode 100644 services/people/java/com/android/server/people/data/MmsQueryHelper.java create mode 100644 services/people/java/com/android/server/people/data/SmsQueryHelper.java create mode 100644 services/people/java/com/android/server/people/data/Utils.java create mode 100644 services/tests/servicestests/src/com/android/server/people/data/CallLogQueryHelperTest.java create mode 100644 services/tests/servicestests/src/com/android/server/people/data/MmsQueryHelperTest.java create mode 100644 services/tests/servicestests/src/com/android/server/people/data/SmsQueryHelperTest.java diff --git a/services/people/java/com/android/server/people/data/CallLogQueryHelper.java b/services/people/java/com/android/server/people/data/CallLogQueryHelper.java new file mode 100644 index 0000000000000..d825b6b2bd8f0 --- /dev/null +++ b/services/people/java/com/android/server/people/data/CallLogQueryHelper.java @@ -0,0 +1,121 @@ +/* + * 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.server.people.data; + +import android.annotation.WorkerThread; +import android.content.Context; +import android.database.Cursor; +import android.provider.CallLog.Calls; +import android.text.TextUtils; +import android.util.Slog; +import android.util.SparseIntArray; + +import java.util.function.BiConsumer; + +/** A helper class that queries the call log database. */ +class CallLogQueryHelper { + + private static final String TAG = "CallLogQueryHelper"; + + private static final SparseIntArray CALL_TYPE_TO_EVENT_TYPE = new SparseIntArray(); + + static { + CALL_TYPE_TO_EVENT_TYPE.put(Calls.INCOMING_TYPE, Event.TYPE_CALL_INCOMING); + CALL_TYPE_TO_EVENT_TYPE.put(Calls.OUTGOING_TYPE, Event.TYPE_CALL_OUTGOING); + CALL_TYPE_TO_EVENT_TYPE.put(Calls.MISSED_TYPE, Event.TYPE_CALL_MISSED); + } + + private final Context mContext; + private final BiConsumer mEventConsumer; + private long mLastCallTimestamp; + + /** + * @param context Context for accessing the content resolver. + * @param eventConsumer Consumes the events created from the call log records. The first input + * param is the normalized phone number. + */ + CallLogQueryHelper(Context context, BiConsumer eventConsumer) { + mContext = context; + mEventConsumer = eventConsumer; + } + + /** + * Queries the call log database for the new data added since {@code sinceTime} and returns + * true if the query runs successfully and at least one call log entry is found. + */ + @WorkerThread + boolean querySince(long sinceTime) { + String[] projection = new String[] { + Calls.CACHED_NORMALIZED_NUMBER, Calls.DATE, Calls.DURATION, Calls.TYPE }; + String selection = Calls.DATE + " > ?"; + String[] selectionArgs = new String[] { Long.toString(sinceTime) }; + boolean hasResults = false; + try (Cursor cursor = mContext.getContentResolver().query( + Calls.CONTENT_URI, projection, selection, selectionArgs, + Calls.DEFAULT_SORT_ORDER)) { + if (cursor == null) { + Slog.w(TAG, "Cursor is null when querying call log."); + return false; + } + while (cursor.moveToNext()) { + // Phone number + int numberIndex = cursor.getColumnIndex(Calls.CACHED_NORMALIZED_NUMBER); + String phoneNumber = cursor.getString(numberIndex); + + // Date + int dateIndex = cursor.getColumnIndex(Calls.DATE); + long date = cursor.getLong(dateIndex); + + // Duration + int durationIndex = cursor.getColumnIndex(Calls.DURATION); + long durationSeconds = cursor.getLong(durationIndex); + + // Type + int typeIndex = cursor.getColumnIndex(Calls.TYPE); + int callType = cursor.getInt(typeIndex); + + mLastCallTimestamp = Math.max(mLastCallTimestamp, date); + if (addEvent(phoneNumber, date, durationSeconds, callType)) { + hasResults = true; + } + } + } + return hasResults; + } + + long getLastCallTimestamp() { + return mLastCallTimestamp; + } + + private boolean addEvent(String phoneNumber, long date, long durationSeconds, int callType) { + if (!validateEvent(phoneNumber, date, callType)) { + return false; + } + @Event.EventType int eventType = CALL_TYPE_TO_EVENT_TYPE.get(callType); + Event event = new Event.Builder(date, eventType) + .setCallDetails(new Event.CallDetails(durationSeconds)) + .build(); + mEventConsumer.accept(phoneNumber, event); + return true; + } + + private boolean validateEvent(String phoneNumber, long date, int callType) { + return !TextUtils.isEmpty(phoneNumber) + && date > 0L + && CALL_TYPE_TO_EVENT_TYPE.indexOfKey(callType) >= 0; + } +} diff --git a/services/people/java/com/android/server/people/data/DataManager.java b/services/people/java/com/android/server/people/data/DataManager.java index 13cce414ea7c0..7737d330b4cfb 100644 --- a/services/people/java/com/android/server/people/data/DataManager.java +++ b/services/people/java/com/android/server/people/data/DataManager.java @@ -45,7 +45,9 @@ import android.os.Process; import android.os.RemoteException; import android.os.UserHandle; import android.os.UserManager; +import android.provider.CallLog; import android.provider.ContactsContract.Contacts; +import android.provider.Telephony.MmsSms; import android.service.notification.NotificationListenerService; import android.service.notification.StatusBarNotification; import android.telecom.TelecomManager; @@ -66,6 +68,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.function.Consumer; /** @@ -77,7 +80,7 @@ public class DataManager { private static final String PLATFORM_PACKAGE_NAME = "android"; private static final int MY_UID = Process.myUid(); private static final int MY_PID = Process.myPid(); - private static final long USAGE_STATS_QUERY_MAX_EVENT_AGE_MS = DateUtils.DAY_IN_MILLIS; + private static final long QUERY_EVENTS_MAX_AGE_MS = DateUtils.DAY_IN_MILLIS; private static final long USAGE_STATS_QUERY_INTERVAL_SEC = 120L; private final Context mContext; @@ -90,6 +93,8 @@ public class DataManager { private final SparseArray> mUsageStatsQueryFutures = new SparseArray<>(); private final SparseArray mNotificationListeners = new SparseArray<>(); + private final ContentObserver mCallLogContentObserver; + private final ContentObserver mMmsSmsContentObserver; private ShortcutServiceInternal mShortcutServiceInternal; private UsageStatsManagerInternal mUsageStatsManagerInternal; @@ -97,9 +102,7 @@ public class DataManager { private UserManager mUserManager; public DataManager(Context context) { - mContext = context; - mInjector = new Injector(); - mUsageStatsQueryExecutor = mInjector.createScheduledExecutor(); + this(context, new Injector()); } @VisibleForTesting @@ -107,6 +110,10 @@ public class DataManager { mContext = context; mInjector = injector; mUsageStatsQueryExecutor = mInjector.createScheduledExecutor(); + mCallLogContentObserver = new CallLogContentObserver( + BackgroundThread.getHandler()); + mMmsSmsContentObserver = new MmsSmsContentObserver( + BackgroundThread.getHandler()); } /** Initialization. Called when the system services are up running. */ @@ -159,6 +166,18 @@ public class DataManager { } catch (RemoteException e) { // Should never occur for local calls. } + + if (userId == UserHandle.USER_SYSTEM) { + // The call log and MMS/SMS messages are shared across user profiles. So only need to + // register the content observers once for the primary user. + // TODO: Register observers after the conversations and events being loaded from disk. + mContext.getContentResolver().registerContentObserver( + CallLog.CONTENT_URI, /* notifyForDescendants= */ true, + mCallLogContentObserver, UserHandle.USER_SYSTEM); + mContext.getContentResolver().registerContentObserver( + MmsSms.CONTENT_URI, /* notifyForDescendants= */ false, + mMmsSmsContentObserver, UserHandle.USER_SYSTEM); + } } /** This method is called when a user is stopped. */ @@ -183,6 +202,10 @@ public class DataManager { // Should never occur for local calls. } } + if (userId == UserHandle.USER_SYSTEM) { + mContext.getContentResolver().unregisterContentObserver(mCallLogContentObserver); + mContext.getContentResolver().unregisterContentObserver(mMmsSmsContentObserver); + } } /** @@ -277,6 +300,15 @@ public class DataManager { userId, MY_PID, MY_UID); } + private void forAllUnlockedUsers(Consumer consumer) { + for (int i = 0; i < mUserDataArray.size(); i++) { + UserData userData = mUserDataArray.get(i); + if (userData.isUnlocked()) { + consumer.accept(userData); + } + } + } + @Nullable private UserData getUnlockedUserData(int userId) { UserData userData = mUserDataArray.get(userId); @@ -404,11 +436,26 @@ public class DataManager { return mContactsContentObservers.get(userId); } + @VisibleForTesting + ContentObserver getCallLogContentObserverForTesting() { + return mCallLogContentObserver; + } + + @VisibleForTesting + ContentObserver getMmsSmsContentObserverForTesting() { + return mMmsSmsContentObserver; + } + @VisibleForTesting NotificationListenerService getNotificationListenerServiceForTesting(@UserIdInt int userId) { return mNotificationListeners.get(userId); } + @VisibleForTesting + UserData getUserDataForTesting(@UserIdInt int userId) { + return mUserDataArray.get(userId); + } + /** Observer that observes the changes in the Contacts database. */ private class ContactsContentObserver extends ContentObserver { @@ -459,6 +506,88 @@ public class DataManager { } } + /** Observer that observes the changes in the call log database. */ + private class CallLogContentObserver extends ContentObserver implements + BiConsumer { + + private final CallLogQueryHelper mCallLogQueryHelper; + private long mLastCallTimestamp; + + private CallLogContentObserver(Handler handler) { + super(handler); + mCallLogQueryHelper = mInjector.createCallLogQueryHelper(mContext, this); + mLastCallTimestamp = System.currentTimeMillis() - QUERY_EVENTS_MAX_AGE_MS; + } + + @Override + public void onChange(boolean selfChange) { + if (mCallLogQueryHelper.querySince(mLastCallTimestamp)) { + mLastCallTimestamp = mCallLogQueryHelper.getLastCallTimestamp(); + } + } + + @Override + public void accept(String phoneNumber, Event event) { + forAllUnlockedUsers(userData -> { + PackageData defaultDialer = userData.getDefaultDialer(); + if (defaultDialer == null) { + return; + } + ConversationStore conversationStore = defaultDialer.getConversationStore(); + if (conversationStore.getConversationByPhoneNumber(phoneNumber) == null) { + return; + } + EventStore eventStore = defaultDialer.getEventStore(); + eventStore.getOrCreateCallEventHistory(phoneNumber).addEvent(event); + }); + } + } + + /** Observer that observes the changes in the MMS & SMS database. */ + private class MmsSmsContentObserver extends ContentObserver implements + BiConsumer { + + private final MmsQueryHelper mMmsQueryHelper; + private long mLastMmsTimestamp; + + private final SmsQueryHelper mSmsQueryHelper; + private long mLastSmsTimestamp; + + private MmsSmsContentObserver(Handler handler) { + super(handler); + mMmsQueryHelper = mInjector.createMmsQueryHelper(mContext, this); + mSmsQueryHelper = mInjector.createSmsQueryHelper(mContext, this); + mLastSmsTimestamp = mLastMmsTimestamp = + System.currentTimeMillis() - QUERY_EVENTS_MAX_AGE_MS; + } + + @Override + public void onChange(boolean selfChange) { + if (mMmsQueryHelper.querySince(mLastMmsTimestamp)) { + mLastMmsTimestamp = mMmsQueryHelper.getLastMessageTimestamp(); + } + if (mSmsQueryHelper.querySince(mLastSmsTimestamp)) { + mLastSmsTimestamp = mSmsQueryHelper.getLastMessageTimestamp(); + } + } + + @Override + public void accept(String phoneNumber, Event event) { + forAllUnlockedUsers(userData -> { + PackageData defaultSmsApp = userData.getDefaultSmsApp(); + if (defaultSmsApp == null) { + return; + } + ConversationStore conversationStore = defaultSmsApp.getConversationStore(); + if (conversationStore.getConversationByPhoneNumber(phoneNumber) == null) { + return; + } + EventStore eventStore = defaultSmsApp.getEventStore(); + eventStore.getOrCreateSmsEventHistory(phoneNumber).addEvent(event); + }); + } + } + /** Listener for the shortcut data changes. */ private class ShortcutServiceListener implements ShortcutServiceInternal.ShortcutChangeListener { @@ -504,7 +633,7 @@ public class DataManager { private UsageStatsQueryRunnable(int userId) { mUserId = userId; - mLastQueryTime = System.currentTimeMillis() - USAGE_STATS_QUERY_MAX_EVENT_AGE_MS; + mLastQueryTime = System.currentTimeMillis() - QUERY_EVENTS_MAX_AGE_MS; } @Override @@ -552,6 +681,21 @@ public class DataManager { return new ContactsQueryHelper(context); } + CallLogQueryHelper createCallLogQueryHelper(Context context, + BiConsumer eventConsumer) { + return new CallLogQueryHelper(context, eventConsumer); + } + + MmsQueryHelper createMmsQueryHelper(Context context, + BiConsumer eventConsumer) { + return new MmsQueryHelper(context, eventConsumer); + } + + SmsQueryHelper createSmsQueryHelper(Context context, + BiConsumer eventConsumer) { + return new SmsQueryHelper(context, eventConsumer); + } + int getCallingUserId() { return Binder.getCallingUserHandle().getIdentifier(); } diff --git a/services/people/java/com/android/server/people/data/MmsQueryHelper.java b/services/people/java/com/android/server/people/data/MmsQueryHelper.java new file mode 100644 index 0000000000000..1e485c082d189 --- /dev/null +++ b/services/people/java/com/android/server/people/data/MmsQueryHelper.java @@ -0,0 +1,151 @@ +/* + * 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.server.people.data; + +import android.annotation.Nullable; +import android.annotation.WorkerThread; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.Telephony.BaseMmsColumns; +import android.provider.Telephony.Mms; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Slog; +import android.util.SparseIntArray; + +import com.google.android.mms.pdu.PduHeaders; + +import java.util.function.BiConsumer; + +/** A helper class that queries the MMS database tables. */ +class MmsQueryHelper { + + private static final String TAG = "MmsQueryHelper"; + private static final long MILLIS_PER_SECONDS = 1000L; + private static final SparseIntArray MSG_BOX_TO_EVENT_TYPE = new SparseIntArray(); + + static { + MSG_BOX_TO_EVENT_TYPE.put(BaseMmsColumns.MESSAGE_BOX_INBOX, Event.TYPE_SMS_INCOMING); + MSG_BOX_TO_EVENT_TYPE.put(BaseMmsColumns.MESSAGE_BOX_SENT, Event.TYPE_SMS_OUTGOING); + } + + private final Context mContext; + private final BiConsumer mEventConsumer; + private long mLastMessageTimestamp; + private String mCurrentCountryIso; + + /** + * @param context Context for accessing the content resolver. + * @param eventConsumer Consumes the events created from the message records. The first input + * param is the normalized phone number. + */ + MmsQueryHelper(Context context, BiConsumer eventConsumer) { + mContext = context; + mEventConsumer = eventConsumer; + mCurrentCountryIso = Utils.getCurrentCountryIso(mContext); + } + + /** + * Queries the MMS database tables for the new data added since {@code sinceTime} (in millis) + * and returns true if the query runs successfully and at least one message entry is found. + */ + @WorkerThread + boolean querySince(long sinceTime) { + String[] projection = new String[] { Mms._ID, Mms.DATE, Mms.MESSAGE_BOX }; + String selection = Mms.DATE + " > ?"; + // NOTE: The field Mms.DATE is stored in seconds, not milliseconds. + String[] selectionArgs = new String[] { Long.toString(sinceTime / MILLIS_PER_SECONDS) }; + boolean hasResults = false; + try (Cursor cursor = mContext.getContentResolver().query( + Mms.CONTENT_URI, projection, selection, selectionArgs, null)) { + if (cursor == null) { + Slog.w(TAG, "Cursor is null when querying MMS table."); + return false; + } + while (cursor.moveToNext()) { + // ID + int msgIdIndex = cursor.getColumnIndex(Mms._ID); + String msgId = cursor.getString(msgIdIndex); + + // Date + int dateIndex = cursor.getColumnIndex(Mms.DATE); + long date = cursor.getLong(dateIndex) * MILLIS_PER_SECONDS; + + // Message box + int msgBoxIndex = cursor.getColumnIndex(Mms.MESSAGE_BOX); + int msgBox = cursor.getInt(msgBoxIndex); + + mLastMessageTimestamp = Math.max(mLastMessageTimestamp, date); + String address = getMmsAddress(msgId, msgBox); + if (address != null && addEvent(address, date, msgBox)) { + hasResults = true; + } + } + } + return hasResults; + } + + long getLastMessageTimestamp() { + return mLastMessageTimestamp; + } + + @Nullable + private String getMmsAddress(String msgId, int msgBox) { + Uri addressUri = Mms.Addr.getAddrUriForMessage(msgId); + String[] projection = new String[] { Mms.Addr.ADDRESS, Mms.Addr.TYPE }; + String address = null; + try (Cursor cursor = mContext.getContentResolver().query( + addressUri, projection, null, null, null)) { + if (cursor == null) { + Slog.w(TAG, "Cursor is null when querying MMS address table."); + return null; + } + while (cursor.moveToNext()) { + // Type + int typeIndex = cursor.getColumnIndex(Mms.Addr.TYPE); + int type = cursor.getInt(typeIndex); + + if ((msgBox == BaseMmsColumns.MESSAGE_BOX_INBOX && type == PduHeaders.FROM) + || (msgBox == BaseMmsColumns.MESSAGE_BOX_SENT && type == PduHeaders.TO)) { + // Address + int addrIndex = cursor.getColumnIndex(Mms.Addr.ADDRESS); + address = cursor.getString(addrIndex); + } + } + } + if (!Mms.isPhoneNumber(address)) { + return null; + } + return PhoneNumberUtils.formatNumberToE164(address, mCurrentCountryIso); + } + + private boolean addEvent(String phoneNumber, long date, int msgBox) { + if (!validateEvent(phoneNumber, date, msgBox)) { + return false; + } + @Event.EventType int eventType = MSG_BOX_TO_EVENT_TYPE.get(msgBox); + mEventConsumer.accept(phoneNumber, new Event(date, eventType)); + return true; + } + + private boolean validateEvent(String phoneNumber, long date, int msgBox) { + return !TextUtils.isEmpty(phoneNumber) + && date > 0L + && MSG_BOX_TO_EVENT_TYPE.indexOfKey(msgBox) >= 0; + } +} diff --git a/services/people/java/com/android/server/people/data/PackageData.java b/services/people/java/com/android/server/people/data/PackageData.java index 9c22a7f1c4842..4fb6d94b5630f 100644 --- a/services/people/java/com/android/server/people/data/PackageData.java +++ b/services/people/java/com/android/server/people/data/PackageData.java @@ -22,6 +22,7 @@ import android.content.LocusId; import android.text.TextUtils; import java.util.function.Consumer; +import java.util.function.Predicate; /** The data associated with a package. */ public class PackageData { @@ -37,15 +38,19 @@ public class PackageData { @NonNull private final EventStore mEventStore; - private boolean mIsDefaultDialer; + private final Predicate mIsDefaultDialerPredicate; - private boolean mIsDefaultSmsApp; + private final Predicate mIsDefaultSmsAppPredicate; - PackageData(@NonNull String packageName, @UserIdInt int userId) { + PackageData(@NonNull String packageName, @UserIdInt int userId, + @NonNull Predicate isDefaultDialerPredicate, + @NonNull Predicate isDefaultSmsAppPredicate) { mPackageName = packageName; mUserId = userId; mConversationStore = new ConversationStore(); mEventStore = new EventStore(); + mIsDefaultDialerPredicate = isDefaultDialerPredicate; + mIsDefaultSmsAppPredicate = isDefaultSmsAppPredicate; } @NonNull @@ -114,11 +119,11 @@ public class PackageData { } public boolean isDefaultDialer() { - return mIsDefaultDialer; + return mIsDefaultDialerPredicate.test(mPackageName); } public boolean isDefaultSmsApp() { - return mIsDefaultSmsApp; + return mIsDefaultSmsAppPredicate.test(mPackageName); } @NonNull @@ -131,14 +136,6 @@ public class PackageData { return mEventStore; } - void setIsDefaultDialer(boolean value) { - mIsDefaultDialer = value; - } - - void setIsDefaultSmsApp(boolean value) { - mIsDefaultSmsApp = value; - } - void onDestroy() { // TODO: STOPSHIP: Implements this method for the case of package being uninstalled. } diff --git a/services/people/java/com/android/server/people/data/SmsQueryHelper.java b/services/people/java/com/android/server/people/data/SmsQueryHelper.java new file mode 100644 index 0000000000000..c38c846bf461e --- /dev/null +++ b/services/people/java/com/android/server/people/data/SmsQueryHelper.java @@ -0,0 +1,119 @@ +/* + * 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.server.people.data; + +import android.annotation.WorkerThread; +import android.content.Context; +import android.database.Cursor; +import android.provider.Telephony.Sms; +import android.provider.Telephony.TextBasedSmsColumns; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Slog; +import android.util.SparseIntArray; + +import java.util.function.BiConsumer; + +/** A helper class that queries the SMS database table. */ +class SmsQueryHelper { + + private static final String TAG = "SmsQueryHelper"; + private static final SparseIntArray SMS_TYPE_TO_EVENT_TYPE = new SparseIntArray(); + + static { + SMS_TYPE_TO_EVENT_TYPE.put(TextBasedSmsColumns.MESSAGE_TYPE_INBOX, Event.TYPE_SMS_INCOMING); + SMS_TYPE_TO_EVENT_TYPE.put(TextBasedSmsColumns.MESSAGE_TYPE_SENT, Event.TYPE_SMS_OUTGOING); + } + + private final Context mContext; + private final BiConsumer mEventConsumer; + private final String mCurrentCountryIso; + private long mLastMessageTimestamp; + + /** + * @param context Context for accessing the content resolver. + * @param eventConsumer Consumes the events created from the message records. The first input + * param is the normalized phone number. + */ + SmsQueryHelper(Context context, BiConsumer eventConsumer) { + mContext = context; + mEventConsumer = eventConsumer; + mCurrentCountryIso = Utils.getCurrentCountryIso(mContext); + } + + /** + * Queries the SMS database tables for the new data added since {@code sinceTime} (in millis) + * and returns true if the query runs successfully and at least one message entry is found. + */ + @WorkerThread + boolean querySince(long sinceTime) { + String[] projection = new String[] { Sms._ID, Sms.DATE, Sms.TYPE, Sms.ADDRESS }; + String selection = Sms.DATE + " > ?"; + String[] selectionArgs = new String[] { Long.toString(sinceTime) }; + boolean hasResults = false; + try (Cursor cursor = mContext.getContentResolver().query( + Sms.CONTENT_URI, projection, selection, selectionArgs, null)) { + if (cursor == null) { + Slog.w(TAG, "Cursor is null when querying SMS table."); + return false; + } + while (cursor.moveToNext()) { + // ID + int msgIdIndex = cursor.getColumnIndex(Sms._ID); + String msgId = cursor.getString(msgIdIndex); + + // Date + int dateIndex = cursor.getColumnIndex(Sms.DATE); + long date = cursor.getLong(dateIndex); + + // Type + int typeIndex = cursor.getColumnIndex(Sms.TYPE); + int type = cursor.getInt(typeIndex); + + // Address + int addressIndex = cursor.getColumnIndex(Sms.ADDRESS); + String address = PhoneNumberUtils.formatNumberToE164( + cursor.getString(addressIndex), mCurrentCountryIso); + + mLastMessageTimestamp = Math.max(mLastMessageTimestamp, date); + if (address != null && addEvent(address, date, type)) { + hasResults = true; + } + } + } + return hasResults; + } + + long getLastMessageTimestamp() { + return mLastMessageTimestamp; + } + + private boolean addEvent(String phoneNumber, long date, int type) { + if (!validateEvent(phoneNumber, date, type)) { + return false; + } + @Event.EventType int eventType = SMS_TYPE_TO_EVENT_TYPE.get(type); + mEventConsumer.accept(phoneNumber, new Event(date, eventType)); + return true; + } + + private boolean validateEvent(String phoneNumber, long date, int type) { + return !TextUtils.isEmpty(phoneNumber) + && date > 0L + && SMS_TYPE_TO_EVENT_TYPE.indexOfKey(type) >= 0; + } +} diff --git a/services/people/java/com/android/server/people/data/UserData.java b/services/people/java/com/android/server/people/data/UserData.java index 2c16059e89bab..4e8fd16d05fd4 100644 --- a/services/people/java/com/android/server/people/data/UserData.java +++ b/services/people/java/com/android/server/people/data/UserData.java @@ -34,6 +34,12 @@ class UserData { private Map mPackageDataMap = new ArrayMap<>(); + @Nullable + private String mDefaultDialer; + + @Nullable + private String mDefaultSmsApp; + UserData(@UserIdInt int userId) { mUserId = userId; } @@ -66,8 +72,7 @@ class UserData { */ @NonNull PackageData getOrCreatePackageData(String packageName) { - return mPackageDataMap.computeIfAbsent( - packageName, key -> new PackageData(packageName, mUserId)); + return mPackageDataMap.computeIfAbsent(packageName, key -> createPackageData(packageName)); } /** @@ -80,24 +85,32 @@ class UserData { } void setDefaultDialer(@Nullable String packageName) { - for (PackageData packageData : mPackageDataMap.values()) { - if (packageData.isDefaultDialer()) { - packageData.setIsDefaultDialer(false); - } - if (TextUtils.equals(packageName, packageData.getPackageName())) { - packageData.setIsDefaultDialer(true); - } - } + mDefaultDialer = packageName; + } + + @Nullable + PackageData getDefaultDialer() { + return mDefaultDialer != null ? getPackageData(mDefaultDialer) : null; } void setDefaultSmsApp(@Nullable String packageName) { - for (PackageData packageData : mPackageDataMap.values()) { - if (packageData.isDefaultSmsApp()) { - packageData.setIsDefaultSmsApp(false); - } - if (TextUtils.equals(packageName, packageData.getPackageName())) { - packageData.setIsDefaultSmsApp(true); - } - } + mDefaultSmsApp = packageName; + } + + @Nullable + PackageData getDefaultSmsApp() { + return mDefaultSmsApp != null ? getPackageData(mDefaultSmsApp) : null; + } + + private PackageData createPackageData(String packageName) { + return new PackageData(packageName, mUserId, this::isDefaultDialer, this::isDefaultSmsApp); + } + + private boolean isDefaultDialer(String packageName) { + return TextUtils.equals(mDefaultDialer, packageName); + } + + private boolean isDefaultSmsApp(String packageName) { + return TextUtils.equals(mDefaultSmsApp, packageName); } } diff --git a/services/people/java/com/android/server/people/data/Utils.java b/services/people/java/com/android/server/people/data/Utils.java new file mode 100644 index 0000000000000..b7529605f462a --- /dev/null +++ b/services/people/java/com/android/server/people/data/Utils.java @@ -0,0 +1,49 @@ +/* + * 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.server.people.data; + +import android.content.Context; +import android.location.Country; +import android.location.CountryDetector; + +import java.util.Locale; + +/** The utilities static methods for people service data package. */ +class Utils { + + /** + * @return The ISO 3166-1 two letters country code of the country the user is in. + */ + static String getCurrentCountryIso(Context context) { + String countryIso = null; + CountryDetector detector = (CountryDetector) context.getSystemService( + Context.COUNTRY_DETECTOR); + if (detector != null) { + Country country = detector.detectCountry(); + if (country != null) { + countryIso = country.getCountryIso(); + } + } + if (countryIso == null) { + countryIso = Locale.getDefault().getCountry(); + } + return countryIso; + } + + private Utils() { + } +} diff --git a/services/tests/servicestests/src/com/android/server/people/data/CallLogQueryHelperTest.java b/services/tests/servicestests/src/com/android/server/people/data/CallLogQueryHelperTest.java new file mode 100644 index 0000000000000..7a16d171b4756 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/people/data/CallLogQueryHelperTest.java @@ -0,0 +1,169 @@ +/* + * 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.server.people.data; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.CallLog.Calls; +import android.test.mock.MockContentProvider; +import android.test.mock.MockContentResolver; +import android.test.mock.MockContext; +import android.util.ArrayMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +@RunWith(JUnit4.class) +public final class CallLogQueryHelperTest { + + private static final String CALL_LOG_AUTHORITY = "call_log"; + private static final String NORMALIZED_PHONE_NUMBER = "+16505551111"; + + private static final String[] CALL_LOG_COLUMNS = new String[] { + Calls.CACHED_NORMALIZED_NUMBER, Calls.DATE, Calls.DURATION, Calls.TYPE }; + + @Mock + private MockContext mContext; + + private MatrixCursor mCursor; + private EventConsumer mEventConsumer; + private CallLogQueryHelper mHelper; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mCursor = new MatrixCursor(CALL_LOG_COLUMNS); + + MockContentResolver contentResolver = new MockContentResolver(); + contentResolver.addProvider(CALL_LOG_AUTHORITY, new CallLogContentProvider()); + when(mContext.getContentResolver()).thenReturn(contentResolver); + + mEventConsumer = new EventConsumer(); + mHelper = new CallLogQueryHelper(mContext, mEventConsumer); + } + + @Test + public void testQueryNoCalls() { + assertFalse(mHelper.querySince(50L)); + assertFalse(mEventConsumer.mEventMap.containsKey(NORMALIZED_PHONE_NUMBER)); + } + + @Test + public void testQueryIncomingCall() { + mCursor.addRow(new Object[] { + NORMALIZED_PHONE_NUMBER, /* date= */ 100L, /* duration= */ 30L, + /* type= */ Calls.INCOMING_TYPE }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100L, mHelper.getLastCallTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_CALL_INCOMING, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + assertEquals(30L, events.get(0).getCallDetails().getDurationSeconds()); + } + + @Test + public void testQueryOutgoingCall() { + mCursor.addRow(new Object[] { + NORMALIZED_PHONE_NUMBER, /* date= */ 100L, /* duration= */ 40L, + /* type= */ Calls.OUTGOING_TYPE }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100L, mHelper.getLastCallTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_CALL_OUTGOING, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + assertEquals(40L, events.get(0).getCallDetails().getDurationSeconds()); + } + + @Test + public void testQueryMissedCall() { + mCursor.addRow(new Object[] { + NORMALIZED_PHONE_NUMBER, /* date= */ 100L, /* duration= */ 0L, + /* type= */ Calls.MISSED_TYPE }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100L, mHelper.getLastCallTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_CALL_MISSED, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + assertEquals(0L, events.get(0).getCallDetails().getDurationSeconds()); + } + + @Test + public void testQueryMultipleCalls() { + mCursor.addRow(new Object[] { + NORMALIZED_PHONE_NUMBER, /* date= */ 100L, /* duration= */ 0L, + /* type= */ Calls.MISSED_TYPE }); + mCursor.addRow(new Object[] { + NORMALIZED_PHONE_NUMBER, /* date= */ 110L, /* duration= */ 40L, + /* type= */ Calls.OUTGOING_TYPE }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(110L, mHelper.getLastCallTimestamp()); + assertEquals(2, events.size()); + assertEquals(Event.TYPE_CALL_MISSED, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + assertEquals(Event.TYPE_CALL_OUTGOING, events.get(1).getType()); + assertEquals(110L, events.get(1).getTimestamp()); + assertEquals(40L, events.get(1).getCallDetails().getDurationSeconds()); + } + + private class EventConsumer implements BiConsumer { + + private final Map> mEventMap = new ArrayMap<>(); + + @Override + public void accept(String phoneNumber, Event event) { + mEventMap.computeIfAbsent(phoneNumber, key -> new ArrayList<>()).add(event); + } + } + + private class CallLogContentProvider extends MockContentProvider { + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return mCursor; + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java index 9f3d656188e12..175c5dc9262de 100644 --- a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java +++ b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java @@ -18,6 +18,8 @@ package com.android.server.people.data; import static android.app.usage.UsageEvents.Event.SHORTCUT_INVOCATION; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -58,6 +60,7 @@ import android.os.UserManager; import android.provider.ContactsContract; import android.service.notification.NotificationListenerService; import android.service.notification.StatusBarNotification; +import android.telecom.TelecomManager; import android.telephony.TelephonyManager; import android.util.Range; @@ -79,6 +82,7 @@ import java.util.List; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; @RunWith(JUnit4.class) public final class DataManagerTest { @@ -90,6 +94,7 @@ public final class DataManagerTest { private static final String TEST_SHORTCUT_ID = "sc"; private static final String CONTACT_URI = "content://com.android.contacts/contacts/lookup/123"; private static final String PHONE_NUMBER = "+1234567890"; + private static final long MILLIS_PER_MINUTE = 1000L * 60L; @Mock private Context mContext; @Mock private ShortcutServiceInternal mShortcutServiceInternal; @@ -97,6 +102,7 @@ public final class DataManagerTest { @Mock private ShortcutManager mShortcutManager; @Mock private UserManager mUserManager; @Mock private TelephonyManager mTelephonyManager; + @Mock private TelecomManager mTelecomManager; @Mock private ContentResolver mContentResolver; @Mock private ScheduledExecutorService mExecutorService; @Mock private ScheduledFuture mScheduledFuture; @@ -117,6 +123,9 @@ public final class DataManagerTest { when(mContext.getMainLooper()).thenReturn(Looper.getMainLooper()); + Context originalContext = getInstrumentation().getTargetContext(); + when(mContext.getApplicationInfo()).thenReturn(originalContext.getApplicationInfo()); + when(mContext.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(mShortcutManager); when(mContext.getSystemServiceName(ShortcutManager.class)).thenReturn( Context.SHORTCUT_SERVICE); @@ -127,6 +136,11 @@ public final class DataManagerTest { when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephonyManager); + when(mContext.getSystemService(Context.TELECOM_SERVICE)).thenReturn(mTelecomManager); + when(mContext.getSystemServiceName(TelecomManager.class)).thenReturn( + Context.TELECOM_SERVICE); + when(mTelecomManager.getDefaultDialerPackage(anyInt())).thenReturn(TEST_PKG_NAME); + when(mExecutorService.scheduleAtFixedRate(any(Runnable.class), anyLong(), anyLong(), any( TimeUnit.class))).thenReturn(mScheduledFuture); @@ -351,6 +365,61 @@ public final class DataManagerTest { assertEquals(1, activeShortcutInvocationTimeSlots.size()); } + @Test + public void testCallLogContentObserver() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, + buildPerson()); + mDataManager.onShortcutAddedOrUpdated(shortcut); + + ContentObserver contentObserver = mDataManager.getCallLogContentObserverForTesting(); + contentObserver.onChange(false); + long currentTimestamp = System.currentTimeMillis(); + mInjector.mCallLogQueryHelper.mEventConsumer.accept(PHONE_NUMBER, + new Event(currentTimestamp - MILLIS_PER_MINUTE * 15L, Event.TYPE_CALL_OUTGOING)); + mInjector.mCallLogQueryHelper.mEventConsumer.accept(PHONE_NUMBER, + new Event(currentTimestamp - MILLIS_PER_MINUTE * 10L, Event.TYPE_CALL_INCOMING)); + mInjector.mCallLogQueryHelper.mEventConsumer.accept(PHONE_NUMBER, + new Event(currentTimestamp - MILLIS_PER_MINUTE * 5L, Event.TYPE_CALL_MISSED)); + + List> activeTimeSlots = new ArrayList<>(); + mDataManager.forAllPackages(packageData -> + activeTimeSlots.addAll( + packageData.getEventHistory(TEST_SHORTCUT_ID) + .getEventIndex(Event.CALL_EVENT_TYPES) + .getActiveTimeSlots())); + assertEquals(3, activeTimeSlots.size()); + } + + @Test + public void testMmsSmsContentObserver() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, + buildPerson()); + mDataManager.onShortcutAddedOrUpdated(shortcut); + mDataManager.getUserDataForTesting(USER_ID_PRIMARY).setDefaultSmsApp(TEST_PKG_NAME); + + ContentObserver contentObserver = mDataManager.getMmsSmsContentObserverForTesting(); + contentObserver.onChange(false); + long currentTimestamp = System.currentTimeMillis(); + Event outgoingSmsEvent = + new Event(currentTimestamp - MILLIS_PER_MINUTE * 10L, Event.TYPE_SMS_OUTGOING); + Event incomingSmsEvent = + new Event(currentTimestamp - MILLIS_PER_MINUTE * 5L, Event.TYPE_SMS_INCOMING); + mInjector.mMmsQueryHelper.mEventConsumer.accept(PHONE_NUMBER, outgoingSmsEvent); + mInjector.mSmsQueryHelper.mEventConsumer.accept(PHONE_NUMBER, incomingSmsEvent); + + List> activeTimeSlots = new ArrayList<>(); + mDataManager.forAllPackages(packageData -> + activeTimeSlots.addAll( + packageData.getEventHistory(TEST_SHORTCUT_ID) + .getEventIndex(Event.SMS_EVENT_TYPES) + .getActiveTimeSlots())); + assertEquals(2, activeTimeSlots.size()); + } + private static void addLocalServiceMock(Class clazz, T mock) { LocalServices.removeServiceForTest(clazz); LocalServices.addService(clazz, mock); @@ -428,10 +497,73 @@ public final class DataManagerTest { } } + private class TestCallLogQueryHelper extends CallLogQueryHelper { + + private final BiConsumer mEventConsumer; + + TestCallLogQueryHelper(Context context, BiConsumer eventConsumer) { + super(context, eventConsumer); + mEventConsumer = eventConsumer; + } + + @Override + boolean querySince(long sinceTime) { + return true; + } + + @Override + long getLastCallTimestamp() { + return 100L; + } + } + + private class TestSmsQueryHelper extends SmsQueryHelper { + + private final BiConsumer mEventConsumer; + + TestSmsQueryHelper(Context context, BiConsumer eventConsumer) { + super(context, eventConsumer); + mEventConsumer = eventConsumer; + } + + @Override + boolean querySince(long sinceTime) { + return true; + } + + @Override + long getLastMessageTimestamp() { + return 100L; + } + } + + private class TestMmsQueryHelper extends MmsQueryHelper { + + private final BiConsumer mEventConsumer; + + TestMmsQueryHelper(Context context, BiConsumer eventConsumer) { + super(context, eventConsumer); + mEventConsumer = eventConsumer; + } + + @Override + boolean querySince(long sinceTime) { + return true; + } + + @Override + long getLastMessageTimestamp() { + return 100L; + } + } + private class TestInjector extends DataManager.Injector { private final TestContactsQueryHelper mContactsQueryHelper = new TestContactsQueryHelper(mContext); + private TestCallLogQueryHelper mCallLogQueryHelper; + private TestMmsQueryHelper mMmsQueryHelper; + private TestSmsQueryHelper mSmsQueryHelper; @Override ScheduledExecutorService createScheduledExecutor() { @@ -443,6 +575,27 @@ public final class DataManagerTest { return mContactsQueryHelper; } + @Override + CallLogQueryHelper createCallLogQueryHelper(Context context, + BiConsumer eventConsumer) { + mCallLogQueryHelper = new TestCallLogQueryHelper(context, eventConsumer); + return mCallLogQueryHelper; + } + + @Override + MmsQueryHelper createMmsQueryHelper(Context context, + BiConsumer eventConsumer) { + mMmsQueryHelper = new TestMmsQueryHelper(context, eventConsumer); + return mMmsQueryHelper; + } + + @Override + SmsQueryHelper createSmsQueryHelper(Context context, + BiConsumer eventConsumer) { + mSmsQueryHelper = new TestSmsQueryHelper(context, eventConsumer); + return mSmsQueryHelper; + } + @Override int getCallingUserId() { return mCallingUserId; diff --git a/services/tests/servicestests/src/com/android/server/people/data/MmsQueryHelperTest.java b/services/tests/servicestests/src/com/android/server/people/data/MmsQueryHelperTest.java new file mode 100644 index 0000000000000..7730890e1486f --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/people/data/MmsQueryHelperTest.java @@ -0,0 +1,174 @@ +/* + * 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.server.people.data; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.Telephony.BaseMmsColumns; +import android.provider.Telephony.Mms; +import android.test.mock.MockContentProvider; +import android.test.mock.MockContentResolver; +import android.test.mock.MockContext; +import android.util.ArrayMap; + +import com.google.android.mms.pdu.PduHeaders; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +@RunWith(JUnit4.class) +public final class MmsQueryHelperTest { + + private static final String MMS_AUTHORITY = "mms"; + private static final String PHONE_NUMBER = "650-555-1111"; + private static final String NORMALIZED_PHONE_NUMBER = "+16505551111"; + private static final String OWN_PHONE_NUMBER = "650-555-9999"; + + private static final String[] MMS_COLUMNS = new String[] { Mms._ID, Mms.DATE, Mms.MESSAGE_BOX }; + private static final String[] ADDR_COLUMNS = new String[] { Mms.Addr.ADDRESS, Mms.Addr.TYPE }; + + @Mock + private MockContext mContext; + + private MatrixCursor mMmsCursor; + private final List mAddrCursors = new ArrayList<>(); + private EventConsumer mEventConsumer; + private MmsQueryHelper mHelper; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mMmsCursor = new MatrixCursor(MMS_COLUMNS); + mAddrCursors.add(new MatrixCursor(ADDR_COLUMNS)); + mAddrCursors.add(new MatrixCursor(ADDR_COLUMNS)); + + MockContentResolver contentResolver = new MockContentResolver(); + contentResolver.addProvider(MMS_AUTHORITY, new MmsContentProvider()); + when(mContext.getContentResolver()).thenReturn(contentResolver); + + mEventConsumer = new EventConsumer(); + mHelper = new MmsQueryHelper(mContext, mEventConsumer); + } + + @Test + public void testQueryNoMessages() { + assertFalse(mHelper.querySince(50_000L)); + assertFalse(mEventConsumer.mEventMap.containsKey(NORMALIZED_PHONE_NUMBER)); + } + + @Test + public void testQueryIncomingMessage() { + mMmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* msgBox= */ BaseMmsColumns.MESSAGE_BOX_INBOX }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ PHONE_NUMBER, /* type= */ PduHeaders.FROM }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ OWN_PHONE_NUMBER, /* type= */ PduHeaders.TO }); + + assertTrue(mHelper.querySince(50_000L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100_000L, mHelper.getLastMessageTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_SMS_INCOMING, events.get(0).getType()); + assertEquals(100_000L, events.get(0).getTimestamp()); + } + + @Test + public void testQueryOutgoingMessage() { + mMmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* msgBox= */ BaseMmsColumns.MESSAGE_BOX_SENT }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ OWN_PHONE_NUMBER, /* type= */ PduHeaders.FROM }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ PHONE_NUMBER, /* type= */ PduHeaders.TO }); + + assertTrue(mHelper.querySince(50_000L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100_000L, mHelper.getLastMessageTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_SMS_OUTGOING, events.get(0).getType()); + assertEquals(100_000L, events.get(0).getTimestamp()); + } + + @Test + public void testQueryMultipleMessages() { + mMmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* msgBox= */ BaseMmsColumns.MESSAGE_BOX_SENT }); + mMmsCursor.addRow(new Object[] { + /* id= */ 1, /* date= */ 110L, /* msgBox= */ BaseMmsColumns.MESSAGE_BOX_INBOX }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ OWN_PHONE_NUMBER, /* type= */ PduHeaders.FROM }); + mAddrCursors.get(0).addRow(new Object[] { + /* address= */ PHONE_NUMBER, /* type= */ PduHeaders.TO }); + mAddrCursors.get(1).addRow(new Object[] { + /* address= */ PHONE_NUMBER, /* type= */ PduHeaders.FROM }); + mAddrCursors.get(1).addRow(new Object[] { + /* address= */ OWN_PHONE_NUMBER, /* type= */ PduHeaders.TO }); + + assertTrue(mHelper.querySince(50_000L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(110_000L, mHelper.getLastMessageTimestamp()); + assertEquals(2, events.size()); + assertEquals(Event.TYPE_SMS_OUTGOING, events.get(0).getType()); + assertEquals(100_000L, events.get(0).getTimestamp()); + assertEquals(Event.TYPE_SMS_INCOMING, events.get(1).getType()); + assertEquals(110_000L, events.get(1).getTimestamp()); + } + + private class EventConsumer implements BiConsumer { + + private final Map> mEventMap = new ArrayMap<>(); + + @Override + public void accept(String phoneNumber, Event event) { + mEventMap.computeIfAbsent(phoneNumber, key -> new ArrayList<>()).add(event); + } + } + + private class MmsContentProvider extends MockContentProvider { + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + List segments = uri.getPathSegments(); + if (segments.size() == 2 && "addr".equals(segments.get(1))) { + int messageId = Integer.valueOf(segments.get(0)); + return mAddrCursors.get(messageId); + } + return mMmsCursor; + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java b/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java index 1b80d6fc3a2d2..ec4789ad0cdf9 100644 --- a/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java +++ b/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java @@ -16,7 +16,6 @@ package com.android.server.people.data; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -46,12 +45,15 @@ public final class PackageDataTest { private Event mE2; private Event mE3; private Event mE4; + private boolean mIsDefaultDialer; + private boolean mIsDefaultSmsApp; private PackageData mPackageData; @Before public void setUp() { - mPackageData = new PackageData(PACKAGE_NAME, USER_ID); + mPackageData = new PackageData( + PACKAGE_NAME, USER_ID, pkg -> mIsDefaultDialer, pkg -> mIsDefaultSmsApp); ConversationInfo conversationInfo = new ConversationInfo.Builder() .setShortcutId(SHORTCUT_ID) .setLocusId(LOCUS_ID) @@ -83,8 +85,8 @@ public final class PackageDataTest { @Test public void testGetEventHistoryDefaultDialerAndSmsApp() { - mPackageData.setIsDefaultDialer(true); - mPackageData.setIsDefaultSmsApp(true); + mIsDefaultDialer = true; + mIsDefaultSmsApp = true; EventStore eventStore = mPackageData.getEventStore(); eventStore.getOrCreateShortcutEventHistory(SHORTCUT_ID).addEvent(mE1); eventStore.getOrCreateCallEventHistory(PHONE_NUMBER).addEvent(mE3); diff --git a/services/tests/servicestests/src/com/android/server/people/data/SmsQueryHelperTest.java b/services/tests/servicestests/src/com/android/server/people/data/SmsQueryHelperTest.java new file mode 100644 index 0000000000000..5cb8cb4fe9f1c --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/people/data/SmsQueryHelperTest.java @@ -0,0 +1,151 @@ +/* + * 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.server.people.data; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.Telephony.Sms; +import android.provider.Telephony.TextBasedSmsColumns; +import android.test.mock.MockContentProvider; +import android.test.mock.MockContentResolver; +import android.test.mock.MockContext; +import android.util.ArrayMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +@RunWith(JUnit4.class) +public final class SmsQueryHelperTest { + + private static final String SMS_AUTHORITY = "sms"; + private static final String PHONE_NUMBER = "650-555-1111"; + private static final String NORMALIZED_PHONE_NUMBER = "+16505551111"; + + private static final String[] SMS_COLUMNS = new String[] { + Sms._ID, Sms.DATE, Sms.TYPE, Sms.ADDRESS }; + + @Mock + private MockContext mContext; + + private MatrixCursor mSmsCursor; + private EventConsumer mEventConsumer; + private SmsQueryHelper mHelper; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mSmsCursor = new MatrixCursor(SMS_COLUMNS); + + MockContentResolver contentResolver = new MockContentResolver(); + contentResolver.addProvider(SMS_AUTHORITY, new SmsContentProvider()); + when(mContext.getContentResolver()).thenReturn(contentResolver); + + mEventConsumer = new EventConsumer(); + mHelper = new SmsQueryHelper(mContext, mEventConsumer); + } + + @Test + public void testQueryNoMessages() { + assertFalse(mHelper.querySince(50_000L)); + assertFalse(mEventConsumer.mEventMap.containsKey(NORMALIZED_PHONE_NUMBER)); + } + + @Test + public void testQueryIncomingMessage() { + mSmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* type= */ TextBasedSmsColumns.MESSAGE_TYPE_INBOX, + /* address= */ PHONE_NUMBER }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100L, mHelper.getLastMessageTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_SMS_INCOMING, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + } + + @Test + public void testQueryOutgoingMessage() { + mSmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* type= */ TextBasedSmsColumns.MESSAGE_TYPE_SENT, + /* address= */ PHONE_NUMBER }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(100L, mHelper.getLastMessageTimestamp()); + assertEquals(1, events.size()); + assertEquals(Event.TYPE_SMS_OUTGOING, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + } + + @Test + public void testQueryMultipleMessages() { + mSmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 100L, /* type= */ TextBasedSmsColumns.MESSAGE_TYPE_SENT, + /* address= */ PHONE_NUMBER }); + mSmsCursor.addRow(new Object[] { + /* id= */ 0, /* date= */ 110L, /* type= */ TextBasedSmsColumns.MESSAGE_TYPE_INBOX, + /* address= */ PHONE_NUMBER }); + + assertTrue(mHelper.querySince(50L)); + List events = mEventConsumer.mEventMap.get(NORMALIZED_PHONE_NUMBER); + + assertEquals(110L, mHelper.getLastMessageTimestamp()); + assertEquals(2, events.size()); + assertEquals(Event.TYPE_SMS_OUTGOING, events.get(0).getType()); + assertEquals(100L, events.get(0).getTimestamp()); + assertEquals(Event.TYPE_SMS_INCOMING, events.get(1).getType()); + assertEquals(110L, events.get(1).getTimestamp()); + } + + private class EventConsumer implements BiConsumer { + + private final Map> mEventMap = new ArrayMap<>(); + + @Override + public void accept(String phoneNumber, Event event) { + mEventMap.computeIfAbsent(phoneNumber, key -> new ArrayList<>()).add(event); + } + } + + private class SmsContentProvider extends MockContentProvider { + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return mSmsCursor; + } + } +}