summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--services/people/java/com/android/server/people/PeopleService.java25
-rw-r--r--services/people/java/com/android/server/people/SessionInfo.java5
-rw-r--r--services/people/java/com/android/server/people/data/AggregateEventHistoryImpl.java95
-rw-r--r--services/people/java/com/android/server/people/data/ContactsQueryHelper.java182
-rw-r--r--services/people/java/com/android/server/people/data/ConversationInfo.java372
-rw-r--r--services/people/java/com/android/server/people/data/ConversationStore.java109
-rw-r--r--services/people/java/com/android/server/people/data/DataManager.java559
-rw-r--r--services/people/java/com/android/server/people/data/Event.java195
-rw-r--r--services/people/java/com/android/server/people/data/EventHistory.java44
-rw-r--r--services/people/java/com/android/server/people/data/EventHistoryImpl.java88
-rw-r--r--services/people/java/com/android/server/people/data/EventIndex.java377
-rw-r--r--services/people/java/com/android/server/people/data/EventList.java103
-rw-r--r--services/people/java/com/android/server/people/data/EventStore.java114
-rw-r--r--services/people/java/com/android/server/people/data/PackageData.java145
-rw-r--r--services/people/java/com/android/server/people/data/UserData.java103
-rw-r--r--services/people/java/com/android/server/people/prediction/ConversationData.java55
-rw-r--r--services/people/java/com/android/server/people/prediction/ConversationPredictor.java69
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/AggregateEventHistoryImplTest.java131
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/ContactsQueryHelperTest.java187
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java130
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java153
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java451
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/EventHistoryImplTest.java118
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/EventIndexTest.java176
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/EventListTest.java137
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java122
-rw-r--r--services/tests/servicestests/src/com/android/server/people/data/TestUtils.java42
27 files changed, 4278 insertions, 9 deletions
diff --git a/services/people/java/com/android/server/people/PeopleService.java b/services/people/java/com/android/server/people/PeopleService.java
index 9569c6e8be89..2d18a2994135 100644
--- a/services/people/java/com/android/server/people/PeopleService.java
+++ b/services/people/java/com/android/server/people/PeopleService.java
@@ -16,6 +16,7 @@
package com.android.server.people;
+import android.annotation.NonNull;
import android.app.prediction.AppPredictionContext;
import android.app.prediction.AppPredictionSessionId;
import android.app.prediction.AppTarget;
@@ -29,6 +30,7 @@ import android.util.Slog;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.SystemService;
+import com.android.server.people.data.DataManager;
import java.util.List;
import java.util.Map;
@@ -41,6 +43,8 @@ public class PeopleService extends SystemService {
private static final String TAG = "PeopleService";
+ private final DataManager mDataManager;
+
/**
* Initializes the system service.
*
@@ -48,6 +52,15 @@ public class PeopleService extends SystemService {
*/
public PeopleService(Context context) {
super(context);
+
+ mDataManager = new DataManager(context);
+ }
+
+ @Override
+ public void onBootPhase(int phase) {
+ if (phase == PHASE_SYSTEM_SERVICES_READY) {
+ mDataManager.initialize();
+ }
}
@Override
@@ -55,6 +68,16 @@ public class PeopleService extends SystemService {
publishLocalService(PeopleServiceInternal.class, new LocalService());
}
+ @Override
+ public void onUnlockUser(@NonNull TargetUser targetUser) {
+ mDataManager.onUserUnlocked(targetUser.getUserIdentifier());
+ }
+
+ @Override
+ public void onStopUser(@NonNull TargetUser targetUser) {
+ mDataManager.onUserStopped(targetUser.getUserIdentifier());
+ }
+
@VisibleForTesting
final class LocalService extends PeopleServiceInternal {
@@ -63,7 +86,7 @@ public class PeopleService extends SystemService {
@Override
public void onCreatePredictionSession(AppPredictionContext context,
AppPredictionSessionId sessionId) {
- mSessions.put(sessionId, new SessionInfo(context));
+ mSessions.put(sessionId, new SessionInfo(context, mDataManager));
}
@Override
diff --git a/services/people/java/com/android/server/people/SessionInfo.java b/services/people/java/com/android/server/people/SessionInfo.java
index df7cedf7626f..eb08e03c14de 100644
--- a/services/people/java/com/android/server/people/SessionInfo.java
+++ b/services/people/java/com/android/server/people/SessionInfo.java
@@ -24,6 +24,7 @@ import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Slog;
+import com.android.server.people.data.DataManager;
import com.android.server.people.prediction.ConversationPredictor;
import java.util.List;
@@ -37,9 +38,9 @@ class SessionInfo {
private final RemoteCallbackList<IPredictionCallback> mCallbacks =
new RemoteCallbackList<>();
- SessionInfo(AppPredictionContext predictionContext) {
+ SessionInfo(AppPredictionContext predictionContext, DataManager dataManager) {
mConversationPredictor = new ConversationPredictor(predictionContext,
- this::updatePredictions);
+ this::updatePredictions, dataManager);
}
void addCallback(IPredictionCallback callback) {
diff --git a/services/people/java/com/android/server/people/data/AggregateEventHistoryImpl.java b/services/people/java/com/android/server/people/data/AggregateEventHistoryImpl.java
new file mode 100644
index 000000000000..4ac346b51b22
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/AggregateEventHistoryImpl.java
@@ -0,0 +1,95 @@
+/*
+ * 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.NonNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/** An {@link EventHistory} that aggregates multiple {@link EventHistory}. */
+class AggregateEventHistoryImpl implements EventHistory {
+
+ private final List<EventHistory> mEventHistoryList = new ArrayList<>();
+
+ @NonNull
+ @Override
+ public EventIndex getEventIndex(int eventType) {
+ for (EventHistory eventHistory : mEventHistoryList) {
+ EventIndex eventIndex = eventHistory.getEventIndex(eventType);
+ if (!eventIndex.isEmpty()) {
+ return eventIndex;
+ }
+ }
+ return EventIndex.EMPTY;
+ }
+
+ @NonNull
+ @Override
+ public EventIndex getEventIndex(Set<Integer> eventTypes) {
+ EventIndex merged = new EventIndex();
+ for (EventHistory eventHistory : mEventHistoryList) {
+ EventIndex eventIndex = eventHistory.getEventIndex(eventTypes);
+ if (!eventIndex.isEmpty()) {
+ merged = EventIndex.combine(merged, eventIndex);
+ }
+ }
+ return merged;
+ }
+
+ @NonNull
+ @Override
+ public List<Event> queryEvents(Set<Integer> eventTypes, long startTime, long endTime) {
+ List<Event> results = new ArrayList<>();
+ for (EventHistory eventHistory : mEventHistoryList) {
+ EventIndex eventIndex = eventHistory.getEventIndex(eventTypes);
+ if (eventIndex.isEmpty()) {
+ continue;
+ }
+ List<Event> queryResults = eventHistory.queryEvents(eventTypes, startTime, endTime);
+ results = combineEventLists(results, queryResults);
+ }
+ return results;
+ }
+
+ void addEventHistory(EventHistory eventHistory) {
+ mEventHistoryList.add(eventHistory);
+ }
+
+ /**
+ * Combines the sorted events (in chronological order) from the given 2 lists {@code lhs}
+ * and {@code rhs} and preserves the order.
+ */
+ private List<Event> combineEventLists(List<Event> lhs, List<Event> rhs) {
+ List<Event> results = new ArrayList<>();
+ int i = 0, j = 0;
+ while (i < lhs.size() && j < rhs.size()) {
+ if (lhs.get(i).getTimestamp() < rhs.get(j).getTimestamp()) {
+ results.add(lhs.get(i++));
+ } else {
+ results.add(rhs.get(j++));
+ }
+ }
+ if (i < lhs.size()) {
+ results.addAll(lhs.subList(i, lhs.size()));
+ } else if (j < rhs.size()) {
+ results.addAll(rhs.subList(j, rhs.size()));
+ }
+ return results;
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/ContactsQueryHelper.java b/services/people/java/com/android/server/people/data/ContactsQueryHelper.java
new file mode 100644
index 000000000000..8a3a44ae9f35
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/ContactsQueryHelper.java
@@ -0,0 +1,182 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.annotation.WorkerThread;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.text.TextUtils;
+import android.util.Slog;
+
+/** A helper class that queries the Contacts database. */
+class ContactsQueryHelper {
+
+ private static final String TAG = "ContactsQueryHelper";
+
+ private final Context mContext;
+ private Uri mContactUri;
+ private boolean mIsStarred;
+ private String mPhoneNumber;
+ private long mLastUpdatedTimestamp;
+
+ ContactsQueryHelper(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Queries the Contacts database with the given contact URI and returns whether the query runs
+ * successfully.
+ */
+ @WorkerThread
+ boolean query(@NonNull String contactUri) {
+ if (TextUtils.isEmpty(contactUri)) {
+ return false;
+ }
+ Uri uri = Uri.parse(contactUri);
+ if ("tel".equals(uri.getScheme())) {
+ return queryWithPhoneNumber(uri.getSchemeSpecificPart());
+ } else if ("mailto".equals(uri.getScheme())) {
+ return queryWithEmail(uri.getSchemeSpecificPart());
+ } else if (contactUri.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
+ return queryWithUri(uri);
+ }
+ return false;
+ }
+
+ /** Queries the Contacts database and read the most recently updated contact. */
+ @WorkerThread
+ boolean querySince(long sinceTime) {
+ final String[] projection = new String[] {
+ Contacts._ID, Contacts.LOOKUP_KEY, Contacts.STARRED, Contacts.HAS_PHONE_NUMBER,
+ Contacts.CONTACT_LAST_UPDATED_TIMESTAMP };
+ String selection = Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?";
+ String[] selectionArgs = new String[] {Long.toString(sinceTime)};
+ return queryContact(Contacts.CONTENT_URI, projection, selection, selectionArgs);
+ }
+
+ @Nullable
+ Uri getContactUri() {
+ return mContactUri;
+ }
+
+ boolean isStarred() {
+ return mIsStarred;
+ }
+
+ @Nullable
+ String getPhoneNumber() {
+ return mPhoneNumber;
+ }
+
+ long getLastUpdatedTimestamp() {
+ return mLastUpdatedTimestamp;
+ }
+
+ private boolean queryWithPhoneNumber(String phoneNumber) {
+ Uri phoneUri = Uri.withAppendedPath(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(phoneNumber));
+ return queryWithUri(phoneUri);
+ }
+
+ private boolean queryWithEmail(String email) {
+ Uri emailUri = Uri.withAppendedPath(
+ ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, Uri.encode(email));
+ return queryWithUri(emailUri);
+ }
+
+ private boolean queryWithUri(@NonNull Uri uri) {
+ final String[] projection = new String[] {
+ Contacts._ID, Contacts.LOOKUP_KEY, Contacts.STARRED, Contacts.HAS_PHONE_NUMBER };
+ return queryContact(uri, projection, /* selection= */ null, /* selectionArgs= */ null);
+ }
+
+ private boolean queryContact(@NonNull Uri uri, @NonNull String[] projection,
+ @Nullable String selection, @Nullable String[] selectionArgs) {
+ long contactId;
+ String lookupKey = null;
+ boolean hasPhoneNumber = false;
+ boolean found = false;
+ try (Cursor cursor = mContext.getContentResolver().query(
+ uri, projection, selection, selectionArgs, /* sortOrder= */ null)) {
+ if (cursor == null) {
+ Slog.w(TAG, "Cursor is null when querying contact.");
+ return false;
+ }
+ while (cursor.moveToNext()) {
+ // Contact ID
+ int idIndex = cursor.getColumnIndex(Contacts._ID);
+ contactId = cursor.getLong(idIndex);
+
+ // Lookup key
+ int lookupKeyIndex = cursor.getColumnIndex(Contacts.LOOKUP_KEY);
+ lookupKey = cursor.getString(lookupKeyIndex);
+
+ mContactUri = Contacts.getLookupUri(contactId, lookupKey);
+
+ // Starred
+ int starredIndex = cursor.getColumnIndex(Contacts.STARRED);
+ mIsStarred = cursor.getInt(starredIndex) != 0;
+
+ // Has phone number
+ int hasPhoneNumIndex = cursor.getColumnIndex(Contacts.HAS_PHONE_NUMBER);
+ hasPhoneNumber = cursor.getInt(hasPhoneNumIndex) != 0;
+
+ // Last updated timestamp
+ int lastUpdatedTimestampIndex = cursor.getColumnIndex(
+ Contacts.CONTACT_LAST_UPDATED_TIMESTAMP);
+ if (lastUpdatedTimestampIndex >= 0) {
+ mLastUpdatedTimestamp = cursor.getLong(lastUpdatedTimestampIndex);
+ }
+
+ found = true;
+ }
+ }
+ if (found && lookupKey != null && hasPhoneNumber) {
+ return queryPhoneNumber(lookupKey);
+ }
+ return found;
+ }
+
+ private boolean queryPhoneNumber(String lookupKey) {
+ String[] projection = new String[] {
+ ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER };
+ String selection = Contacts.LOOKUP_KEY + " = ?";
+ String[] selectionArgs = new String[] { lookupKey };
+ try (Cursor cursor = mContext.getContentResolver().query(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, selection,
+ selectionArgs, /* sortOrder= */ null)) {
+ if (cursor == null) {
+ Slog.w(TAG, "Cursor is null when querying contact phone number.");
+ return false;
+ }
+ while (cursor.moveToNext()) {
+ // Phone number
+ int phoneNumIdx = cursor.getColumnIndex(
+ ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER);
+ if (phoneNumIdx >= 0) {
+ mPhoneNumber = cursor.getString(phoneNumIdx);
+ }
+ }
+ }
+ return true;
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/ConversationInfo.java b/services/people/java/com/android/server/people/data/ConversationInfo.java
new file mode 100644
index 000000000000..bb97533b3222
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/ConversationInfo.java
@@ -0,0 +1,372 @@
+/*
+ * 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.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.LocusId;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutInfo.ShortcutFlags;
+import android.net.Uri;
+
+import com.android.internal.util.Preconditions;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/**
+ * Represents a conversation that is provided by the app based on {@link ShortcutInfo}.
+ */
+public class ConversationInfo {
+
+ private static final int FLAG_VIP = 1;
+
+ private static final int FLAG_NOTIFICATION_SILENCED = 1 << 1;
+
+ private static final int FLAG_BUBBLED = 1 << 2;
+
+ private static final int FLAG_PERSON_IMPORTANT = 1 << 3;
+
+ private static final int FLAG_PERSON_BOT = 1 << 4;
+
+ private static final int FLAG_CONTACT_STARRED = 1 << 5;
+
+ private static final int FLAG_DEMOTED = 1 << 6;
+
+ @IntDef(flag = true, prefix = {"FLAG_"}, value = {
+ FLAG_VIP,
+ FLAG_NOTIFICATION_SILENCED,
+ FLAG_BUBBLED,
+ FLAG_PERSON_IMPORTANT,
+ FLAG_PERSON_BOT,
+ FLAG_CONTACT_STARRED,
+ FLAG_DEMOTED,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ private @interface ConversationFlags {
+ }
+
+ @NonNull
+ private String mShortcutId;
+
+ @Nullable
+ private LocusId mLocusId;
+
+ @Nullable
+ private Uri mContactUri;
+
+ @Nullable
+ private String mContactPhoneNumber;
+
+ @Nullable
+ private String mNotificationChannelId;
+
+ @ShortcutFlags
+ private int mShortcutFlags;
+
+ @ConversationFlags
+ private int mConversationFlags;
+
+ private ConversationInfo(Builder builder) {
+ mShortcutId = builder.mShortcutId;
+ mLocusId = builder.mLocusId;
+ mContactUri = builder.mContactUri;
+ mContactPhoneNumber = builder.mContactPhoneNumber;
+ mNotificationChannelId = builder.mNotificationChannelId;
+ mShortcutFlags = builder.mShortcutFlags;
+ mConversationFlags = builder.mConversationFlags;
+ }
+
+ @NonNull
+ public String getShortcutId() {
+ return mShortcutId;
+ }
+
+ @Nullable
+ LocusId getLocusId() {
+ return mLocusId;
+ }
+
+ /** The URI to look up the entry in the contacts data provider. */
+ @Nullable
+ Uri getContactUri() {
+ return mContactUri;
+ }
+
+ /** The phone number of the associated contact. */
+ @Nullable
+ String getContactPhoneNumber() {
+ return mContactPhoneNumber;
+ }
+
+ /**
+ * ID of the {@link android.app.NotificationChannel} where the notifications for this
+ * conversation are posted.
+ */
+ @Nullable
+ String getNotificationChannelId() {
+ return mNotificationChannelId;
+ }
+
+ /** Whether the shortcut for this conversation is set long-lived by the app. */
+ public boolean isShortcutLongLived() {
+ return hasShortcutFlags(ShortcutInfo.FLAG_LONG_LIVED);
+ }
+
+ /** Whether this conversation is marked as VIP by the user. */
+ public boolean isVip() {
+ return hasConversationFlags(FLAG_VIP);
+ }
+
+ /** Whether the notifications for this conversation should be silenced. */
+ public boolean isNotificationSilenced() {
+ return hasConversationFlags(FLAG_NOTIFICATION_SILENCED);
+ }
+
+ /** Whether the notifications for this conversation should show in bubbles. */
+ public boolean isBubbled() {
+ return hasConversationFlags(FLAG_BUBBLED);
+ }
+
+ /**
+ * Whether this conversation is demoted by the user. New notifications for the demoted
+ * conversation will not show in the conversation space.
+ */
+ public boolean isDemoted() {
+ return hasConversationFlags(FLAG_DEMOTED);
+ }
+
+ /** Whether the associated person is marked as important by the app. */
+ public boolean isPersonImportant() {
+ return hasConversationFlags(FLAG_PERSON_IMPORTANT);
+ }
+
+ /** Whether the associated person is marked as a bot by the app. */
+ public boolean isPersonBot() {
+ return hasConversationFlags(FLAG_PERSON_BOT);
+ }
+
+ /** Whether the associated contact is marked as starred by the user. */
+ public boolean isContactStarred() {
+ return hasConversationFlags(FLAG_CONTACT_STARRED);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof ConversationInfo)) {
+ return false;
+ }
+ ConversationInfo other = (ConversationInfo) obj;
+ return Objects.equals(mShortcutId, other.mShortcutId)
+ && Objects.equals(mLocusId, other.mLocusId)
+ && Objects.equals(mContactUri, other.mContactUri)
+ && Objects.equals(mContactPhoneNumber, other.mContactPhoneNumber)
+ && Objects.equals(mNotificationChannelId, other.mNotificationChannelId)
+ && mShortcutFlags == other.mShortcutFlags
+ && mConversationFlags == other.mConversationFlags;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mShortcutId, mLocusId, mContactUri, mContactPhoneNumber,
+ mNotificationChannelId, mShortcutFlags, mConversationFlags);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("ConversationInfo {");
+ sb.append("shortcutId=").append(mShortcutId);
+ sb.append(", locusId=").append(mLocusId);
+ sb.append(", contactUri=").append(mContactUri);
+ sb.append(", phoneNumber=").append(mContactPhoneNumber);
+ sb.append(", notificationChannelId=").append(mNotificationChannelId);
+ sb.append(", shortcutFlags=0x").append(Integer.toHexString(mShortcutFlags));
+ sb.append(" [");
+ if (isShortcutLongLived()) {
+ sb.append("Liv");
+ }
+ sb.append("]");
+ sb.append(", conversationFlags=0x").append(Integer.toHexString(mConversationFlags));
+ sb.append(" [");
+ if (isVip()) {
+ sb.append("Vip");
+ }
+ if (isNotificationSilenced()) {
+ sb.append("Sil");
+ }
+ if (isBubbled()) {
+ sb.append("Bub");
+ }
+ if (isDemoted()) {
+ sb.append("Dem");
+ }
+ if (isPersonImportant()) {
+ sb.append("Imp");
+ }
+ if (isPersonBot()) {
+ sb.append("Bot");
+ }
+ if (isContactStarred()) {
+ sb.append("Sta");
+ }
+ sb.append("]}");
+ return sb.toString();
+ }
+
+ private boolean hasShortcutFlags(@ShortcutFlags int flags) {
+ return (mShortcutFlags & flags) == flags;
+ }
+
+ private boolean hasConversationFlags(@ConversationFlags int flags) {
+ return (mConversationFlags & flags) == flags;
+ }
+
+ /**
+ * Builder class for {@link ConversationInfo} objects.
+ */
+ static class Builder {
+
+ private String mShortcutId;
+
+ @Nullable
+ private LocusId mLocusId;
+
+ @Nullable
+ private Uri mContactUri;
+
+ @Nullable
+ private String mContactPhoneNumber;
+
+ @Nullable
+ private String mNotificationChannelId;
+
+ @ShortcutFlags
+ private int mShortcutFlags;
+
+ @ConversationFlags
+ private int mConversationFlags;
+
+ Builder() {
+ }
+
+ Builder(@NonNull ConversationInfo conversationInfo) {
+ if (mShortcutId == null) {
+ mShortcutId = conversationInfo.mShortcutId;
+ } else {
+ Preconditions.checkArgument(mShortcutId.equals(conversationInfo.mShortcutId));
+ }
+ mLocusId = conversationInfo.mLocusId;
+ mContactUri = conversationInfo.mContactUri;
+ mContactPhoneNumber = conversationInfo.mContactPhoneNumber;
+ mNotificationChannelId = conversationInfo.mNotificationChannelId;
+ mShortcutFlags = conversationInfo.mShortcutFlags;
+ mConversationFlags = conversationInfo.mConversationFlags;
+ }
+
+ Builder setShortcutId(@NonNull String shortcutId) {
+ mShortcutId = shortcutId;
+ return this;
+ }
+
+ Builder setLocusId(LocusId locusId) {
+ mLocusId = locusId;
+ return this;
+ }
+
+ Builder setContactUri(Uri contactUri) {
+ mContactUri = contactUri;
+ return this;
+ }
+
+ Builder setContactPhoneNumber(String phoneNumber) {
+ mContactPhoneNumber = phoneNumber;
+ return this;
+ }
+
+ Builder setNotificationChannelId(String notificationChannelId) {
+ mNotificationChannelId = notificationChannelId;
+ return this;
+ }
+
+ Builder setShortcutFlags(@ShortcutFlags int shortcutFlags) {
+ mShortcutFlags = shortcutFlags;
+ return this;
+ }
+
+ Builder setConversationFlags(@ConversationFlags int conversationFlags) {
+ mConversationFlags = conversationFlags;
+ return this;
+ }
+
+ Builder setVip(boolean value) {
+ return setConversationFlag(FLAG_VIP, value);
+ }
+
+ Builder setNotificationSilenced(boolean value) {
+ return setConversationFlag(FLAG_NOTIFICATION_SILENCED, value);
+ }
+
+ Builder setBubbled(boolean value) {
+ return setConversationFlag(FLAG_BUBBLED, value);
+ }
+
+ Builder setDemoted(boolean value) {
+ return setConversationFlag(FLAG_DEMOTED, value);
+ }
+
+ Builder setPersonImportant(boolean value) {
+ return setConversationFlag(FLAG_PERSON_IMPORTANT, value);
+ }
+
+ Builder setPersonBot(boolean value) {
+ return setConversationFlag(FLAG_PERSON_BOT, value);
+ }
+
+ Builder setContactStarred(boolean value) {
+ return setConversationFlag(FLAG_CONTACT_STARRED, value);
+ }
+
+ private Builder setConversationFlag(@ConversationFlags int flags, boolean value) {
+ if (value) {
+ return addConversationFlags(flags);
+ } else {
+ return removeConversationFlags(flags);
+ }
+ }
+
+ private Builder addConversationFlags(@ConversationFlags int flags) {
+ mConversationFlags |= flags;
+ return this;
+ }
+
+ private Builder removeConversationFlags(@ConversationFlags int flags) {
+ mConversationFlags &= ~flags;
+ return this;
+ }
+
+ ConversationInfo build() {
+ Objects.requireNonNull(mShortcutId);
+ return new ConversationInfo(this);
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/ConversationStore.java b/services/people/java/com/android/server/people/data/ConversationStore.java
new file mode 100644
index 000000000000..f17e1b91cb5d
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/ConversationStore.java
@@ -0,0 +1,109 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.content.LocusId;
+import android.net.Uri;
+import android.util.ArrayMap;
+
+import java.util.Map;
+import java.util.function.Consumer;
+
+/** The store that stores and accesses the conversations data for a package. */
+class ConversationStore {
+
+ // Shortcut ID -> Conversation Info
+ private final Map<String, ConversationInfo> mConversationInfoMap = new ArrayMap<>();
+
+ // Locus ID -> Shortcut ID
+ private final Map<LocusId, String> mLocusIdToShortcutIdMap = new ArrayMap<>();
+
+ // Contact URI -> Shortcut ID
+ private final Map<Uri, String> mContactUriToShortcutIdMap = new ArrayMap<>();
+
+ // Phone Number -> Shortcut ID
+ private final Map<String, String> mPhoneNumberToShortcutIdMap = new ArrayMap<>();
+
+ void addOrUpdate(@NonNull ConversationInfo conversationInfo) {
+ mConversationInfoMap.put(conversationInfo.getShortcutId(), conversationInfo);
+
+ LocusId locusId = conversationInfo.getLocusId();
+ if (locusId != null) {
+ mLocusIdToShortcutIdMap.put(locusId, conversationInfo.getShortcutId());
+ }
+
+ Uri contactUri = conversationInfo.getContactUri();
+ if (contactUri != null) {
+ mContactUriToShortcutIdMap.put(contactUri, conversationInfo.getShortcutId());
+ }
+
+ String phoneNumber = conversationInfo.getContactPhoneNumber();
+ if (phoneNumber != null) {
+ mPhoneNumberToShortcutIdMap.put(phoneNumber, conversationInfo.getShortcutId());
+ }
+ }
+
+ void deleteConversation(@NonNull String shortcutId) {
+ ConversationInfo conversationInfo = mConversationInfoMap.remove(shortcutId);
+ if (conversationInfo == null) {
+ return;
+ }
+
+ LocusId locusId = conversationInfo.getLocusId();
+ if (locusId != null) {
+ mLocusIdToShortcutIdMap.remove(locusId);
+ }
+
+ Uri contactUri = conversationInfo.getContactUri();
+ if (contactUri != null) {
+ mContactUriToShortcutIdMap.remove(contactUri);
+ }
+
+ String phoneNumber = conversationInfo.getContactPhoneNumber();
+ if (phoneNumber != null) {
+ mPhoneNumberToShortcutIdMap.remove(phoneNumber);
+ }
+ }
+
+ void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
+ for (ConversationInfo ci : mConversationInfoMap.values()) {
+ consumer.accept(ci);
+ }
+ }
+
+ @Nullable
+ ConversationInfo getConversation(@Nullable String shortcutId) {
+ return shortcutId != null ? mConversationInfoMap.get(shortcutId) : null;
+ }
+
+ @Nullable
+ ConversationInfo getConversationByLocusId(@NonNull LocusId locusId) {
+ return getConversation(mLocusIdToShortcutIdMap.get(locusId));
+ }
+
+ @Nullable
+ ConversationInfo getConversationByContactUri(@NonNull Uri contactUri) {
+ return getConversation(mContactUriToShortcutIdMap.get(contactUri));
+ }
+
+ @Nullable
+ ConversationInfo getConversationByPhoneNumber(@NonNull String phoneNumber) {
+ return getConversation(mPhoneNumberToShortcutIdMap.get(phoneNumber));
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/DataManager.java b/services/people/java/com/android/server/people/data/DataManager.java
new file mode 100644
index 000000000000..13cce414ea7c
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/DataManager.java
@@ -0,0 +1,559 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.annotation.WorkerThread;
+import android.app.Notification;
+import android.app.Person;
+import android.app.prediction.AppTarget;
+import android.app.prediction.AppTargetEvent;
+import android.app.usage.UsageEvents;
+import android.app.usage.UsageStatsManagerInternal;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.LauncherApps.ShortcutQuery;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.content.pm.ShortcutManager.ShareShortcutInfo;
+import android.content.pm.ShortcutServiceInternal;
+import android.content.pm.UserInfo;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.provider.ContactsContract.Contacts;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import android.telecom.TelecomManager;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.ChooserActivity;
+import com.android.internal.os.BackgroundThread;
+import com.android.internal.telephony.SmsApplication;
+import com.android.server.LocalServices;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * A class manages the lifecycle of the conversations and associated data, and exposes the methods
+ * to access the data in People Service and other system services.
+ */
+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 USAGE_STATS_QUERY_INTERVAL_SEC = 120L;
+
+ private final Context mContext;
+ private final Injector mInjector;
+ private final ScheduledExecutorService mUsageStatsQueryExecutor;
+
+ private final SparseArray<UserData> mUserDataArray = new SparseArray<>();
+ private final SparseArray<BroadcastReceiver> mBroadcastReceivers = new SparseArray<>();
+ private final SparseArray<ContentObserver> mContactsContentObservers = new SparseArray<>();
+ private final SparseArray<ScheduledFuture<?>> mUsageStatsQueryFutures = new SparseArray<>();
+ private final SparseArray<NotificationListenerService> mNotificationListeners =
+ new SparseArray<>();
+
+ private ShortcutServiceInternal mShortcutServiceInternal;
+ private UsageStatsManagerInternal mUsageStatsManagerInternal;
+ private ShortcutManager mShortcutManager;
+ private UserManager mUserManager;
+
+ public DataManager(Context context) {
+ mContext = context;
+ mInjector = new Injector();
+ mUsageStatsQueryExecutor = mInjector.createScheduledExecutor();
+ }
+
+ @VisibleForTesting
+ DataManager(Context context, Injector injector) {
+ mContext = context;
+ mInjector = injector;
+ mUsageStatsQueryExecutor = mInjector.createScheduledExecutor();
+ }
+
+ /** Initialization. Called when the system services are up running. */
+ public void initialize() {
+ mShortcutServiceInternal = LocalServices.getService(ShortcutServiceInternal.class);
+ mUsageStatsManagerInternal = LocalServices.getService(UsageStatsManagerInternal.class);
+ mShortcutManager = mContext.getSystemService(ShortcutManager.class);
+ mUserManager = mContext.getSystemService(UserManager.class);
+
+ mShortcutServiceInternal.addListener(new ShortcutServiceListener());
+ }
+
+ /** This method is called when a user is unlocked. */
+ public void onUserUnlocked(int userId) {
+ UserData userData = mUserDataArray.get(userId);
+ if (userData == null) {
+ userData = new UserData(userId);
+ mUserDataArray.put(userId, userData);
+ }
+ userData.setUserUnlocked();
+ updateDefaultDialer(userData);
+ updateDefaultSmsApp(userData);
+
+ ScheduledFuture<?> scheduledFuture = mUsageStatsQueryExecutor.scheduleAtFixedRate(
+ new UsageStatsQueryRunnable(userId), 1L, USAGE_STATS_QUERY_INTERVAL_SEC,
+ TimeUnit.SECONDS);
+ mUsageStatsQueryFutures.put(userId, scheduledFuture);
+
+ IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(TelecomManager.ACTION_DEFAULT_DIALER_CHANGED);
+ intentFilter.addAction(SmsApplication.ACTION_DEFAULT_SMS_PACKAGE_CHANGED_INTERNAL);
+ BroadcastReceiver broadcastReceiver = new PerUserBroadcastReceiver(userId);
+ mBroadcastReceivers.put(userId, broadcastReceiver);
+ mContext.registerReceiverAsUser(
+ broadcastReceiver, UserHandle.of(userId), intentFilter, null, null);
+
+ ContentObserver contactsContentObserver = new ContactsContentObserver(
+ BackgroundThread.getHandler());
+ mContactsContentObservers.put(userId, contactsContentObserver);
+ mContext.getContentResolver().registerContentObserver(
+ Contacts.CONTENT_URI, /* notifyForDescendants= */ true,
+ contactsContentObserver, userId);
+
+ NotificationListener notificationListener = new NotificationListener();
+ mNotificationListeners.put(userId, notificationListener);
+ try {
+ notificationListener.registerAsSystemService(mContext,
+ new ComponentName(PLATFORM_PACKAGE_NAME, getClass().getSimpleName()),
+ UserHandle.myUserId());
+ } catch (RemoteException e) {
+ // Should never occur for local calls.
+ }
+ }
+
+ /** This method is called when a user is stopped. */
+ public void onUserStopped(int userId) {
+ if (mUserDataArray.indexOfKey(userId) >= 0) {
+ mUserDataArray.get(userId).setUserStopped();
+ }
+ if (mUsageStatsQueryFutures.indexOfKey(userId) >= 0) {
+ mUsageStatsQueryFutures.valueAt(userId).cancel(true);
+ }
+ if (mBroadcastReceivers.indexOfKey(userId) >= 0) {
+ mContext.unregisterReceiver(mBroadcastReceivers.get(userId));
+ }
+ if (mContactsContentObservers.indexOfKey(userId) >= 0) {
+ mContext.getContentResolver().unregisterContentObserver(
+ mContactsContentObservers.get(userId));
+ }
+ if (mNotificationListeners.indexOfKey(userId) >= 0) {
+ try {
+ mNotificationListeners.get(userId).unregisterAsSystemService();
+ } catch (RemoteException e) {
+ // Should never occur for local calls.
+ }
+ }
+ }
+
+ /**
+ * Iterates through all the {@link PackageData}s owned by the unlocked users who are in the
+ * same profile group as the calling user.
+ */
+ public void forAllPackages(Consumer<PackageData> consumer) {
+ List<UserInfo> users = mUserManager.getEnabledProfiles(mInjector.getCallingUserId());
+ for (UserInfo userInfo : users) {
+ UserData userData = getUnlockedUserData(userInfo.id);
+ if (userData != null) {
+ userData.forAllPackages(consumer);
+ }
+ }
+ }
+
+ /** Gets the {@link ShortcutInfo} for the given shortcut ID. */
+ @Nullable
+ public ShortcutInfo getShortcut(@NonNull String packageName, @UserIdInt int userId,
+ @NonNull String shortcutId) {
+ List<ShortcutInfo> shortcuts = getShortcuts(packageName, userId,
+ Collections.singletonList(shortcutId));
+ if (shortcuts != null && !shortcuts.isEmpty()) {
+ return shortcuts.get(0);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the conversation {@link ShareShortcutInfo}s from all packages owned by the calling user
+ * that match the specified {@link IntentFilter}.
+ */
+ public List<ShareShortcutInfo> getConversationShareTargets(
+ @NonNull IntentFilter intentFilter) {
+ List<ShareShortcutInfo> shareShortcuts = mShortcutManager.getShareTargets(intentFilter);
+ List<ShareShortcutInfo> result = new ArrayList<>();
+ for (ShareShortcutInfo shareShortcut : shareShortcuts) {
+ ShortcutInfo si = shareShortcut.getShortcutInfo();
+ if (getConversationInfo(si.getPackage(), si.getUserId(), si.getId()) != null) {
+ result.add(shareShortcut);
+ }
+ }
+ return result;
+ }
+
+ /** Reports the {@link AppTargetEvent} from App Prediction Manager. */
+ public void reportAppTargetEvent(@NonNull AppTargetEvent event,
+ @Nullable IntentFilter intentFilter) {
+ AppTarget appTarget = event.getTarget();
+ ShortcutInfo shortcutInfo = appTarget != null ? appTarget.getShortcutInfo() : null;
+ if (shortcutInfo == null || event.getAction() != AppTargetEvent.ACTION_LAUNCH) {
+ return;
+ }
+ PackageData packageData = getPackageData(appTarget.getPackageName(),
+ appTarget.getUser().getIdentifier());
+ if (packageData == null) {
+ return;
+ }
+ if (ChooserActivity.LAUNCH_LOCATON_DIRECT_SHARE.equals(event.getLaunchLocation())) {
+ String mimeType = intentFilter != null ? intentFilter.getDataType(0) : null;
+ String shortcutId = shortcutInfo.getId();
+ if (packageData.getConversationStore().getConversation(shortcutId) == null
+ || TextUtils.isEmpty(mimeType)) {
+ return;
+ }
+ EventHistoryImpl eventHistory =
+ packageData.getEventStore().getOrCreateShortcutEventHistory(
+ shortcutInfo.getId());
+ @Event.EventType int eventType;
+ if (mimeType.startsWith("text/")) {
+ eventType = Event.TYPE_SHARE_TEXT;
+ } else if (mimeType.startsWith("image/")) {
+ eventType = Event.TYPE_SHARE_IMAGE;
+ } else if (mimeType.startsWith("video/")) {
+ eventType = Event.TYPE_SHARE_VIDEO;
+ } else {
+ eventType = Event.TYPE_SHARE_OTHER;
+ }
+ eventHistory.addEvent(new Event(System.currentTimeMillis(), eventType));
+ }
+ }
+
+ /** Gets a list of {@link ShortcutInfo}s with the given shortcut IDs. */
+ private List<ShortcutInfo> getShortcuts(
+ @NonNull String packageName, @UserIdInt int userId,
+ @Nullable List<String> shortcutIds) {
+ @ShortcutQuery.QueryFlags int queryFlags = ShortcutQuery.FLAG_MATCH_DYNAMIC
+ | ShortcutQuery.FLAG_MATCH_PINNED | ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER;
+ return mShortcutServiceInternal.getShortcuts(
+ mInjector.getCallingUserId(), /*callingPackage=*/ PLATFORM_PACKAGE_NAME,
+ /*changedSince=*/ 0, packageName, shortcutIds, /*componentName=*/ null, queryFlags,
+ userId, MY_PID, MY_UID);
+ }
+
+ @Nullable
+ private UserData getUnlockedUserData(int userId) {
+ UserData userData = mUserDataArray.get(userId);
+ return userData != null && userData.isUnlocked() ? userData : null;
+ }
+
+ @Nullable
+ private PackageData getPackageData(@NonNull String packageName, int userId) {
+ UserData userData = getUnlockedUserData(userId);
+ return userData != null ? userData.getPackageData(packageName) : null;
+ }
+
+ @Nullable
+ private ConversationInfo getConversationInfo(@NonNull String packageName, @UserIdInt int userId,
+ @NonNull String shortcutId) {
+ PackageData packageData = getPackageData(packageName, userId);
+ return packageData != null ? packageData.getConversationStore().getConversation(shortcutId)
+ : null;
+ }
+
+ private void updateDefaultDialer(@NonNull UserData userData) {
+ TelecomManager telecomManager = mContext.getSystemService(TelecomManager.class);
+ String defaultDialer = telecomManager != null
+ ? telecomManager.getDefaultDialerPackage(userData.getUserId()) : null;
+ userData.setDefaultDialer(defaultDialer);
+ }
+
+ private void updateDefaultSmsApp(@NonNull UserData userData) {
+ ComponentName component = SmsApplication.getDefaultSmsApplicationAsUser(
+ mContext, /* updateIfNeeded= */ false, userData.getUserId());
+ String defaultSmsApp = component != null ? component.getPackageName() : null;
+ userData.setDefaultSmsApp(defaultSmsApp);
+ }
+
+ @Nullable
+ private EventHistoryImpl getEventHistoryIfEligible(StatusBarNotification sbn) {
+ Notification notification = sbn.getNotification();
+ String shortcutId = notification.getShortcutId();
+ if (shortcutId == null) {
+ return null;
+ }
+ PackageData packageData = getPackageData(sbn.getPackageName(),
+ sbn.getUser().getIdentifier());
+ if (packageData == null
+ || packageData.getConversationStore().getConversation(shortcutId) == null) {
+ return null;
+ }
+ return packageData.getEventStore().getOrCreateShortcutEventHistory(shortcutId);
+ }
+
+ @VisibleForTesting
+ @WorkerThread
+ void onShortcutAddedOrUpdated(@NonNull ShortcutInfo shortcutInfo) {
+ if (shortcutInfo.getPersons() == null || shortcutInfo.getPersons().length == 0) {
+ return;
+ }
+ UserData userData = getUnlockedUserData(shortcutInfo.getUserId());
+ if (userData == null) {
+ return;
+ }
+ PackageData packageData = userData.getOrCreatePackageData(shortcutInfo.getPackage());
+ ConversationStore conversationStore = packageData.getConversationStore();
+ ConversationInfo oldConversationInfo =
+ conversationStore.getConversation(shortcutInfo.getId());
+ ConversationInfo.Builder builder = oldConversationInfo != null
+ ? new ConversationInfo.Builder(oldConversationInfo)
+ : new ConversationInfo.Builder();
+
+ builder.setShortcutId(shortcutInfo.getId());
+ builder.setLocusId(shortcutInfo.getLocusId());
+ builder.setShortcutFlags(shortcutInfo.getFlags());
+
+ Person person = shortcutInfo.getPersons()[0];
+ builder.setPersonImportant(person.isImportant());
+ builder.setPersonBot(person.isBot());
+ String contactUri = person.getUri();
+ if (contactUri != null) {
+ ContactsQueryHelper helper = mInjector.createContactsQueryHelper(mContext);
+ if (helper.query(contactUri)) {
+ builder.setContactUri(helper.getContactUri());
+ builder.setContactStarred(helper.isStarred());
+ builder.setContactPhoneNumber(helper.getPhoneNumber());
+ }
+ } else {
+ builder.setContactUri(null);
+ builder.setContactPhoneNumber(null);
+ builder.setContactStarred(false);
+ }
+
+ conversationStore.addOrUpdate(builder.build());
+ }
+
+ @VisibleForTesting
+ @WorkerThread
+ void queryUsageStatsService(@UserIdInt int userId, long currentTime, long lastQueryTime) {
+ UsageEvents usageEvents = mUsageStatsManagerInternal.queryEventsForUser(
+ userId, lastQueryTime, currentTime, false);
+ if (usageEvents == null) {
+ return;
+ }
+ while (usageEvents.hasNextEvent()) {
+ UsageEvents.Event e = new UsageEvents.Event();
+ usageEvents.getNextEvent(e);
+
+ String packageName = e.getPackageName();
+ PackageData packageData = getPackageData(packageName, userId);
+ if (packageData == null) {
+ continue;
+ }
+ if (e.getEventType() == UsageEvents.Event.SHORTCUT_INVOCATION) {
+ String shortcutId = e.getShortcutId();
+ if (packageData.getConversationStore().getConversation(shortcutId) != null) {
+ EventHistoryImpl eventHistory =
+ packageData.getEventStore().getOrCreateShortcutEventHistory(
+ shortcutId);
+ eventHistory.addEvent(
+ new Event(e.getTimeStamp(), Event.TYPE_SHORTCUT_INVOCATION));
+ }
+ }
+ }
+ }
+
+ @VisibleForTesting
+ ContentObserver getContactsContentObserverForTesting(@UserIdInt int userId) {
+ return mContactsContentObservers.get(userId);
+ }
+
+ @VisibleForTesting
+ NotificationListenerService getNotificationListenerServiceForTesting(@UserIdInt int userId) {
+ return mNotificationListeners.get(userId);
+ }
+
+ /** Observer that observes the changes in the Contacts database. */
+ private class ContactsContentObserver extends ContentObserver {
+
+ private long mLastUpdatedTimestamp;
+
+ private ContactsContentObserver(Handler handler) {
+ super(handler);
+ mLastUpdatedTimestamp = System.currentTimeMillis();
+ }
+
+ @Override
+ public void onChange(boolean selfChange, Uri uri, @UserIdInt int userId) {
+ ContactsQueryHelper helper = mInjector.createContactsQueryHelper(mContext);
+ if (!helper.querySince(mLastUpdatedTimestamp)) {
+ return;
+ }
+ Uri contactUri = helper.getContactUri();
+
+ final ConversationSelector conversationSelector = new ConversationSelector();
+ UserData userData = getUnlockedUserData(userId);
+ if (userData == null) {
+ return;
+ }
+ userData.forAllPackages(packageData -> {
+ ConversationInfo ci =
+ packageData.getConversationStore().getConversationByContactUri(contactUri);
+ if (ci != null) {
+ conversationSelector.mConversationStore =
+ packageData.getConversationStore();
+ conversationSelector.mConversationInfo = ci;
+ }
+ });
+ if (conversationSelector.mConversationInfo == null) {
+ return;
+ }
+
+ ConversationInfo.Builder builder =
+ new ConversationInfo.Builder(conversationSelector.mConversationInfo);
+ builder.setContactStarred(helper.isStarred());
+ builder.setContactPhoneNumber(helper.getPhoneNumber());
+ conversationSelector.mConversationStore.addOrUpdate(builder.build());
+ mLastUpdatedTimestamp = helper.getLastUpdatedTimestamp();
+ }
+
+ private class ConversationSelector {
+ private ConversationStore mConversationStore = null;
+ private ConversationInfo mConversationInfo = null;
+ }
+ }
+
+ /** Listener for the shortcut data changes. */
+ private class ShortcutServiceListener implements
+ ShortcutServiceInternal.ShortcutChangeListener {
+
+ @Override
+ public void onShortcutChanged(@NonNull String packageName, int userId) {
+ BackgroundThread.getExecutor().execute(() -> {
+ List<ShortcutInfo> shortcuts = getShortcuts(packageName, userId,
+ /*shortcutIds=*/ null);
+ for (ShortcutInfo shortcut : shortcuts) {
+ onShortcutAddedOrUpdated(shortcut);
+ }
+ });
+ }
+ }
+
+ /** Listener for the notifications and their settings changes. */
+ private class NotificationListener extends NotificationListenerService {
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
+ int reason) {
+ if (reason != REASON_CLICK) {
+ return;
+ }
+ EventHistoryImpl eventHistory = getEventHistoryIfEligible(sbn);
+ if (eventHistory == null) {
+ return;
+ }
+ long currentTime = System.currentTimeMillis();
+ eventHistory.addEvent(new Event(currentTime, Event.TYPE_NOTIFICATION_OPENED));
+ }
+ }
+
+ /**
+ * A {@link Runnable} that queries the Usage Stats Service for recent events for a specified
+ * user.
+ */
+ private class UsageStatsQueryRunnable implements Runnable {
+
+ private final int mUserId;
+ private long mLastQueryTime;
+
+ private UsageStatsQueryRunnable(int userId) {
+ mUserId = userId;
+ mLastQueryTime = System.currentTimeMillis() - USAGE_STATS_QUERY_MAX_EVENT_AGE_MS;
+ }
+
+ @Override
+ public void run() {
+ long currentTime = System.currentTimeMillis();
+ queryUsageStatsService(mUserId, currentTime, mLastQueryTime);
+ mLastQueryTime = currentTime;
+ }
+ }
+
+ /** A {@link BroadcastReceiver} that receives the intents for a specified user. */
+ private class PerUserBroadcastReceiver extends BroadcastReceiver {
+
+ private final int mUserId;
+
+ private PerUserBroadcastReceiver(int userId) {
+ mUserId = userId;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ UserData userData = getUnlockedUserData(mUserId);
+ if (userData == null) {
+ return;
+ }
+ if (TelecomManager.ACTION_DEFAULT_DIALER_CHANGED.equals(intent.getAction())) {
+ String defaultDialer = intent.getStringExtra(
+ TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME);
+ userData.setDefaultDialer(defaultDialer);
+ } else if (SmsApplication.ACTION_DEFAULT_SMS_PACKAGE_CHANGED_INTERNAL.equals(
+ intent.getAction())) {
+ updateDefaultSmsApp(userData);
+ }
+ }
+ }
+
+ @VisibleForTesting
+ static class Injector {
+
+ ScheduledExecutorService createScheduledExecutor() {
+ return Executors.newSingleThreadScheduledExecutor();
+ }
+
+ ContactsQueryHelper createContactsQueryHelper(Context context) {
+ return new ContactsQueryHelper(context);
+ }
+
+ int getCallingUserId() {
+ return Binder.getCallingUserHandle().getIdentifier();
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/Event.java b/services/people/java/com/android/server/people/data/Event.java
new file mode 100644
index 000000000000..c2364a295e30
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/Event.java
@@ -0,0 +1,195 @@
+/*
+ * 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.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.text.format.DateFormat;
+import android.util.ArraySet;
+
+import com.android.internal.util.Preconditions;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Set;
+
+/** An event representing the interaction with a specific conversation or app. */
+public class Event {
+
+ public static final int TYPE_SHORTCUT_INVOCATION = 1;
+
+ public static final int TYPE_NOTIFICATION_POSTED = 2;
+
+ public static final int TYPE_NOTIFICATION_OPENED = 3;
+
+ public static final int TYPE_SHARE_TEXT = 4;
+
+ public static final int TYPE_SHARE_IMAGE = 5;
+
+ public static final int TYPE_SHARE_VIDEO = 6;
+
+ public static final int TYPE_SHARE_OTHER = 7;
+
+ public static final int TYPE_SMS_OUTGOING = 8;
+
+ public static final int TYPE_SMS_INCOMING = 9;
+
+ public static final int TYPE_CALL_OUTGOING = 10;
+
+ public static final int TYPE_CALL_INCOMING = 11;
+
+ public static final int TYPE_CALL_MISSED = 12;
+
+ @IntDef(prefix = { "TYPE_" }, value = {
+ TYPE_SHORTCUT_INVOCATION,
+ TYPE_NOTIFICATION_POSTED,
+ TYPE_NOTIFICATION_OPENED,
+ TYPE_SHARE_TEXT,
+ TYPE_SHARE_IMAGE,
+ TYPE_SHARE_VIDEO,
+ TYPE_SHARE_OTHER,
+ TYPE_SMS_OUTGOING,
+ TYPE_SMS_INCOMING,
+ TYPE_CALL_OUTGOING,
+ TYPE_CALL_INCOMING,
+ TYPE_CALL_MISSED,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface EventType {}
+
+ public static final Set<Integer> NOTIFICATION_EVENT_TYPES = new ArraySet<>();
+ public static final Set<Integer> SHARE_EVENT_TYPES = new ArraySet<>();
+ public static final Set<Integer> SMS_EVENT_TYPES = new ArraySet<>();
+ public static final Set<Integer> CALL_EVENT_TYPES = new ArraySet<>();
+ public static final Set<Integer> ALL_EVENT_TYPES = new ArraySet<>();
+
+ static {
+ NOTIFICATION_EVENT_TYPES.add(TYPE_NOTIFICATION_POSTED);
+ NOTIFICATION_EVENT_TYPES.add(TYPE_NOTIFICATION_OPENED);
+
+ SHARE_EVENT_TYPES.add(TYPE_SHARE_TEXT);
+ SHARE_EVENT_TYPES.add(TYPE_SHARE_IMAGE);
+ SHARE_EVENT_TYPES.add(TYPE_SHARE_VIDEO);
+ SHARE_EVENT_TYPES.add(TYPE_SHARE_OTHER);
+
+ SMS_EVENT_TYPES.add(TYPE_SMS_INCOMING);
+ SMS_EVENT_TYPES.add(TYPE_SMS_OUTGOING);
+
+ CALL_EVENT_TYPES.add(TYPE_CALL_INCOMING);
+ CALL_EVENT_TYPES.add(TYPE_CALL_OUTGOING);
+ CALL_EVENT_TYPES.add(TYPE_CALL_MISSED);
+
+ ALL_EVENT_TYPES.add(TYPE_SHORTCUT_INVOCATION);
+ ALL_EVENT_TYPES.addAll(NOTIFICATION_EVENT_TYPES);
+ ALL_EVENT_TYPES.addAll(SHARE_EVENT_TYPES);
+ ALL_EVENT_TYPES.addAll(SMS_EVENT_TYPES);
+ ALL_EVENT_TYPES.addAll(CALL_EVENT_TYPES);
+ }
+
+ private final long mTimestamp;
+
+ private final int mType;
+
+ private final CallDetails mCallDetails;
+
+ Event(long timestamp, @EventType int type) {
+ mTimestamp = timestamp;
+ mType = type;
+ mCallDetails = null;
+ }
+
+ private Event(@NonNull Builder builder) {
+ mTimestamp = builder.mTimestamp;
+ mType = builder.mType;
+ mCallDetails = builder.mCallDetails;
+ }
+
+ public long getTimestamp() {
+ return mTimestamp;
+ }
+
+ public @EventType int getType() {
+ return mType;
+ }
+
+ /**
+ * Gets the {@link CallDetails} of the event. It is only available if the event type is one of
+ * {@code CALL_EVENT_TYPES}, otherwise, it's always {@code null}.
+ */
+ @Nullable
+ public CallDetails getCallDetails() {
+ return mCallDetails;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Event {");
+ sb.append("timestamp=").append(DateFormat.format("yyyy-MM-dd HH:mm:ss", mTimestamp));
+ sb.append(", type=").append(mType);
+ if (mCallDetails != null) {
+ sb.append(", callDetails=").append(mCallDetails);
+ }
+ sb.append("}");
+ return sb.toString();
+ }
+
+ /** Type-specific details of a call event. */
+ public static class CallDetails {
+
+ private final long mDurationSeconds;
+
+ CallDetails(long durationSeconds) {
+ mDurationSeconds = durationSeconds;
+ }
+
+ public long getDurationSeconds() {
+ return mDurationSeconds;
+ }
+
+ @Override
+ public String toString() {
+ return "CallDetails {durationSeconds=" + mDurationSeconds + "}";
+ }
+ }
+
+ /** Builder class for {@link Event} objects. */
+ static class Builder {
+
+ private final long mTimestamp;
+
+ private final int mType;
+
+ private CallDetails mCallDetails;
+
+ Builder(long timestamp, @EventType int type) {
+ mTimestamp = timestamp;
+ mType = type;
+ }
+
+ Builder setCallDetails(CallDetails callDetails) {
+ Preconditions.checkArgument(CALL_EVENT_TYPES.contains(mType));
+ mCallDetails = callDetails;
+ return this;
+ }
+
+ Event build() {
+ return new Event(this);
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/EventHistory.java b/services/people/java/com/android/server/people/data/EventHistory.java
new file mode 100644
index 000000000000..5b11fd0caf05
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/EventHistory.java
@@ -0,0 +1,44 @@
+/*
+ * 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.NonNull;
+
+import java.util.List;
+import java.util.Set;
+
+/** The interface for querying the event time distribution and details. */
+public interface EventHistory {
+
+ /** Gets the {@link EventIndex} for the specified event type. */
+ @NonNull
+ EventIndex getEventIndex(@Event.EventType int eventType);
+
+ /** Gets the combined {@link EventIndex} for a set of event types. */
+ @NonNull
+ EventIndex getEventIndex(Set<Integer> eventTypes);
+
+ /**
+ * Returns a {@link List} of {@link Event}s those timestamps are between the specified {@code
+ * fromTimestamp}, inclusive, and {@code toTimestamp} exclusive, and match the specified event
+ * types.
+ *
+ * @return a list of matched events in chronological order.
+ */
+ @NonNull
+ List<Event> queryEvents(Set<Integer> eventTypes, long fromTimestamp, long toTimestamp);
+}
diff --git a/services/people/java/com/android/server/people/data/EventHistoryImpl.java b/services/people/java/com/android/server/people/data/EventHistoryImpl.java
new file mode 100644
index 000000000000..6b6bd7e3cfb0
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/EventHistoryImpl.java
@@ -0,0 +1,88 @@
+/*
+ * 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.NonNull;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.List;
+import java.util.Set;
+
+class EventHistoryImpl implements EventHistory {
+
+ private final Injector mInjector;
+
+ // Event Type -> Event Index
+ private final SparseArray<EventIndex> mEventIndexArray = new SparseArray<>();
+
+ private final EventList mRecentEvents = new EventList();
+
+ EventHistoryImpl() {
+ mInjector = new Injector();
+ }
+
+ @VisibleForTesting
+ EventHistoryImpl(Injector injector) {
+ mInjector = injector;
+ }
+
+ @Override
+ @NonNull
+ public EventIndex getEventIndex(@Event.EventType int eventType) {
+ EventIndex eventIndex = mEventIndexArray.get(eventType);
+ return eventIndex != null ? new EventIndex(eventIndex) : mInjector.createEventIndex();
+ }
+
+ @Override
+ @NonNull
+ public EventIndex getEventIndex(Set<Integer> eventTypes) {
+ EventIndex combined = mInjector.createEventIndex();
+ for (@Event.EventType int eventType : eventTypes) {
+ EventIndex eventIndex = mEventIndexArray.get(eventType);
+ if (eventIndex != null) {
+ combined = EventIndex.combine(combined, eventIndex);
+ }
+ }
+ return combined;
+ }
+
+ @Override
+ @NonNull
+ public List<Event> queryEvents(Set<Integer> eventTypes, long startTime, long endTime) {
+ return mRecentEvents.queryEvents(eventTypes, startTime, endTime);
+ }
+
+ void addEvent(Event event) {
+ EventIndex eventIndex = mEventIndexArray.get(event.getType());
+ if (eventIndex == null) {
+ eventIndex = mInjector.createEventIndex();
+ mEventIndexArray.put(event.getType(), eventIndex);
+ }
+ eventIndex.addEvent(event.getTimestamp());
+ mRecentEvents.add(event);
+ }
+
+ @VisibleForTesting
+ static class Injector {
+
+ EventIndex createEventIndex() {
+ return new EventIndex();
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/EventIndex.java b/services/people/java/com/android/server/people/data/EventIndex.java
new file mode 100644
index 000000000000..b74a3fae98a5
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/EventIndex.java
@@ -0,0 +1,377 @@
+/*
+ * 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.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.text.format.DateFormat;
+import android.util.Range;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimeZone;
+import java.util.function.Function;
+
+/**
+ * The index of {@link Event}s. It is used for quickly looking up the time distribution of
+ * {@link Event}s based on {@code Event#getTimestamp()}.
+ *
+ * <p>The 64-bits {code long} is used as the bitmap index. Each bit is to denote whether there are
+ * any events in a specified time slot. The least significant bit is for the most recent time slot.
+ * And the most significant bit is for the oldest time slot.
+ *
+ * <p>Multiple {code long}s are used to index the events in different time grains. For the recent
+ * events, the fine-grained bitmap index can provide the narrower time range. For the older events,
+ * the coarse-grained bitmap index can cover longer period but can only provide wider time range.
+ *
+ * <p>E.g. the below chart shows how the bitmap indexes index the events in the past 24 hours:
+ * <pre>
+ * 2020/1/3 2020/1/4
+ * 0:00 4:00 8:00 12:00 16:00 20:00 0:00
+ * --+-----------------------------------------------------------------------+- 1 day per bit
+ * --+-----------+-----------+-----------+-----------+-----------+-----------+- 4 hours per bit
+ * --+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+- 1 hour per bit
+ * +++++++++ 2 minutes per bit
+ * </pre>
+ */
+public class EventIndex {
+
+ private static final int LONG_SIZE_BITS = 64;
+
+ private static final int TIME_SLOT_ONE_DAY = 0;
+
+ private static final int TIME_SLOT_FOUR_HOURS = 1;
+
+ private static final int TIME_SLOT_ONE_HOUR = 2;
+
+ private static final int TIME_SLOT_TWO_MINUTES = 3;
+
+ @IntDef(prefix = {"TIME_SLOT_"}, value = {
+ TIME_SLOT_ONE_DAY,
+ TIME_SLOT_FOUR_HOURS,
+ TIME_SLOT_ONE_HOUR,
+ TIME_SLOT_TWO_MINUTES,
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ private @interface TimeSlotType {
+ }
+
+ private static final int TIME_SLOT_TYPES_COUNT = 4;
+
+ static final EventIndex EMPTY = new EventIndex();
+
+ private static final List<Function<Long, Range<Long>>> TIME_SLOT_FACTORIES =
+ Collections.unmodifiableList(
+ Arrays.asList(
+ EventIndex::createOneDayLongTimeSlot,
+ EventIndex::createFourHoursLongTimeSlot,
+ EventIndex::createOneHourLongTimeSlot,
+ EventIndex::createTwoMinutesLongTimeSlot
+ )
+ );
+
+ /** Combines the two {@link EventIndex} objects and returns the combined result. */
+ static EventIndex combine(EventIndex lhs, EventIndex rhs) {
+ EventIndex older = lhs.mLastUpdatedTime < rhs.mLastUpdatedTime ? lhs : rhs;
+ EventIndex younger = lhs.mLastUpdatedTime >= rhs.mLastUpdatedTime ? lhs : rhs;
+
+ EventIndex combined = new EventIndex(older);
+ combined.updateEventBitmaps(younger.mLastUpdatedTime);
+
+ for (int slotType = 0; slotType < TIME_SLOT_TYPES_COUNT; slotType++) {
+ combined.mEventBitmaps[slotType] |= younger.mEventBitmaps[slotType];
+ }
+ return combined;
+ }
+
+ private final long[] mEventBitmaps;
+
+ private long mLastUpdatedTime;
+
+ private final Object mLock = new Object();
+
+ private final Injector mInjector;
+
+ EventIndex() {
+ mInjector = new Injector();
+ mEventBitmaps = new long[]{0L, 0L, 0L, 0L};
+ mLastUpdatedTime = mInjector.currentTimeMillis();
+ }
+
+ EventIndex(EventIndex from) {
+ mInjector = new Injector();
+ mEventBitmaps = Arrays.copyOf(from.mEventBitmaps, TIME_SLOT_TYPES_COUNT);
+ mLastUpdatedTime = from.mLastUpdatedTime;
+ }
+
+ @VisibleForTesting
+ EventIndex(Injector injector) {
+ mInjector = injector;
+ mEventBitmaps = new long[]{0L, 0L, 0L, 0L};
+ mLastUpdatedTime = mInjector.currentTimeMillis();
+ }
+
+ /**
+ * Gets the most recent active time slot. A time slot is active if there is at least one event
+ * occurred in that time slot.
+ */
+ @Nullable
+ public Range<Long> getMostRecentActiveTimeSlot() {
+ synchronized (mLock) {
+ for (int slotType = TIME_SLOT_TYPES_COUNT - 1; slotType >= 0; slotType--) {
+ if (mEventBitmaps[slotType] == 0L) {
+ continue;
+ }
+ Range<Long> lastTimeSlot =
+ TIME_SLOT_FACTORIES.get(slotType).apply(mLastUpdatedTime);
+ int numberOfTrailingZeros = Long.numberOfTrailingZeros(mEventBitmaps[slotType]);
+ long offset = getDuration(lastTimeSlot) * numberOfTrailingZeros;
+ return Range.create(lastTimeSlot.getLower() - offset,
+ lastTimeSlot.getUpper() - offset);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Gets the active time slots. A time slot is active if there is at least one event occurred
+ * in that time slot.
+ *
+ * @return active time slots in chronological order.
+ */
+ @NonNull
+ public List<Range<Long>> getActiveTimeSlots() {
+ List<Range<Long>> activeTimeSlots = new ArrayList<>();
+ synchronized (mLock) {
+ for (int slotType = 0; slotType < TIME_SLOT_TYPES_COUNT; slotType++) {
+ activeTimeSlots = combineTimeSlotLists(activeTimeSlots,
+ getActiveTimeSlotsForType(slotType));
+ }
+ }
+ Collections.reverse(activeTimeSlots);
+ return activeTimeSlots;
+ }
+
+ /** Returns whether this {@link EventIndex} instance is empty. */
+ public boolean isEmpty() {
+ synchronized (mLock) {
+ for (int slotType = 0; slotType < TIME_SLOT_TYPES_COUNT; slotType++) {
+ if (mEventBitmaps[slotType] != 0L) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Adds an event to this index with the given event time. Before the new event is recorded, the
+ * index is updated first with the current timestamp.
+ */
+ void addEvent(long eventTime) {
+ if (EMPTY == this) {
+ throw new IllegalStateException("EMPTY instance is immutable");
+ }
+ synchronized (mLock) {
+ long currentTime = mInjector.currentTimeMillis();
+ updateEventBitmaps(currentTime);
+ for (int slotType = 0; slotType < TIME_SLOT_TYPES_COUNT; slotType++) {
+ int offset = diffTimeSlots(slotType, eventTime, currentTime);
+ if (offset < LONG_SIZE_BITS) {
+ mEventBitmaps[slotType] |= (1L << offset);
+ }
+ }
+ }
+ }
+
+ /** Updates to make all bitmaps up to date. */
+ void update() {
+ updateEventBitmaps(mInjector.currentTimeMillis());
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("EventIndex {");
+ sb.append("perDayEventBitmap=0b");
+ sb.append(Long.toBinaryString(mEventBitmaps[TIME_SLOT_ONE_DAY]));
+ sb.append(", perFourHoursEventBitmap=0b");
+ sb.append(Long.toBinaryString(mEventBitmaps[TIME_SLOT_FOUR_HOURS]));
+ sb.append(", perHourEventBitmap=0b");
+ sb.append(Long.toBinaryString(mEventBitmaps[TIME_SLOT_ONE_HOUR]));
+ sb.append(", perTwoMinutesEventBitmap=0b");
+ sb.append(Long.toBinaryString(mEventBitmaps[TIME_SLOT_TWO_MINUTES]));
+ sb.append(", lastUpdatedTime=");
+ sb.append(DateFormat.format("yyyy-MM-dd HH:mm:ss", mLastUpdatedTime));
+ sb.append("}");
+ return sb.toString();
+ }
+
+ /** Shifts the event bitmaps to make them up-to-date. */
+ private void updateEventBitmaps(long currentTimeMillis) {
+ for (int slotType = 0; slotType < TIME_SLOT_TYPES_COUNT; slotType++) {
+ int offset = diffTimeSlots(slotType, mLastUpdatedTime, currentTimeMillis);
+ if (offset < LONG_SIZE_BITS) {
+ mEventBitmaps[slotType] <<= offset;
+ } else {
+ mEventBitmaps[slotType] = 0L;
+ }
+ }
+ mLastUpdatedTime = currentTimeMillis;
+ }
+
+ private static LocalDateTime toLocalDateTime(long epochMilli) {
+ return LocalDateTime.ofInstant(
+ Instant.ofEpochMilli(epochMilli), TimeZone.getDefault().toZoneId());
+ }
+
+ private static long toEpochMilli(LocalDateTime localDateTime) {
+ return localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+
+ private static long getDuration(Range<Long> timeSlot) {
+ return timeSlot.getUpper() - timeSlot.getLower();
+ }
+
+ /**
+ * Finds the time slots for the given two timestamps and returns the distance (in the number
+ * of time slots) between these two time slots.
+ */
+ private static int diffTimeSlots(@TimeSlotType int timeSlotType, long fromTime, long toTime) {
+ Function<Long, Range<Long>> timeSlotFactory = TIME_SLOT_FACTORIES.get(timeSlotType);
+ Range<Long> fromSlot = timeSlotFactory.apply(fromTime);
+ Range<Long> toSlot = timeSlotFactory.apply(toTime);
+ return (int) ((toSlot.getLower() - fromSlot.getLower()) / getDuration(fromSlot));
+ }
+
+ /**
+ * Returns the active time slots for a specified type. The returned time slots are in
+ * reverse-chronological order.
+ */
+ private List<Range<Long>> getActiveTimeSlotsForType(@TimeSlotType int timeSlotType) {
+ long eventBitmap = mEventBitmaps[timeSlotType];
+ Range<Long> latestTimeSlot = TIME_SLOT_FACTORIES.get(timeSlotType).apply(mLastUpdatedTime);
+ long startTime = latestTimeSlot.getLower();
+ final long duration = getDuration(latestTimeSlot);
+ List<Range<Long>> timeSlots = new ArrayList<>();
+ while (eventBitmap != 0) {
+ int trailingZeros = Long.numberOfTrailingZeros(eventBitmap);
+ if (trailingZeros > 0) {
+ startTime -= duration * trailingZeros;
+ eventBitmap >>>= trailingZeros;
+ }
+ if (eventBitmap != 0) {
+ timeSlots.add(Range.create(startTime, startTime + duration));
+ startTime -= duration;
+ eventBitmap >>>= 1;
+ }
+ }
+ return timeSlots;
+ }
+
+ /**
+ * Combines two lists of time slots into one. If one longer time slot covers one or multiple
+ * shorter time slots, the smaller time slot(s) will be added to the result and the longer one
+ * will be dropped. This ensures the returned list does not contain any overlapping time slots.
+ */
+ private static List<Range<Long>> combineTimeSlotLists(List<Range<Long>> longerSlots,
+ List<Range<Long>> shorterSlots) {
+ List<Range<Long>> result = new ArrayList<>();
+ int i = 0;
+ int j = 0;
+ while (i < longerSlots.size() && j < shorterSlots.size()) {
+ Range<Long> longerSlot = longerSlots.get(i);
+ Range<Long> shorterSlot = shorterSlots.get(j);
+ if (longerSlot.contains(shorterSlot)) {
+ result.add(shorterSlot);
+ i++;
+ j++;
+ } else if (longerSlot.getLower() < shorterSlot.getLower()) {
+ result.add(shorterSlot);
+ j++;
+ } else {
+ result.add(longerSlot);
+ i++;
+ }
+ }
+ if (i < longerSlots.size()) {
+ result.addAll(longerSlots.subList(i, longerSlots.size()));
+ } else if (j < shorterSlots.size()) {
+ result.addAll(shorterSlots.subList(j, shorterSlots.size()));
+ }
+ return result;
+ }
+
+ /**
+ * Finds and creates the time slot (duration = 1 day) that the given time falls into.
+ */
+ @NonNull
+ private static Range<Long> createOneDayLongTimeSlot(long time) {
+ LocalDateTime beginTime = toLocalDateTime(time).truncatedTo(ChronoUnit.DAYS);
+ return Range.create(toEpochMilli(beginTime), toEpochMilli(beginTime.plusDays(1)));
+ }
+
+ /**
+ * Finds and creates the time slot (duration = 4 hours) that the given time falls into.
+ */
+ @NonNull
+ private static Range<Long> createFourHoursLongTimeSlot(long time) {
+ int hourOfDay = toLocalDateTime(time).getHour();
+ LocalDateTime beginTime =
+ toLocalDateTime(time).truncatedTo(ChronoUnit.HOURS).minusHours(hourOfDay % 4);
+ return Range.create(toEpochMilli(beginTime), toEpochMilli(beginTime.plusHours(4)));
+ }
+
+ /**
+ * Finds and creates the time slot (duration = 1 hour) that the given time falls into.
+ */
+ @NonNull
+ private static Range<Long> createOneHourLongTimeSlot(long time) {
+ LocalDateTime beginTime = toLocalDateTime(time).truncatedTo(ChronoUnit.HOURS);
+ return Range.create(toEpochMilli(beginTime), toEpochMilli(beginTime.plusHours(1)));
+ }
+
+ /**
+ * Finds and creates the time slot (duration = 2 minutes) that the given time falls into.
+ */
+ @NonNull
+ private static Range<Long> createTwoMinutesLongTimeSlot(long time) {
+ int minuteOfHour = toLocalDateTime(time).getMinute();
+ LocalDateTime beginTime = toLocalDateTime(time).truncatedTo(
+ ChronoUnit.MINUTES).minusMinutes(minuteOfHour % 2);
+ return Range.create(toEpochMilli(beginTime), toEpochMilli(beginTime.plusMinutes(2)));
+ }
+
+ @VisibleForTesting
+ static class Injector {
+ /** This should be the only way to get the current timestamp in {@code EventIndex}. */
+ long currentTimeMillis() {
+ return System.currentTimeMillis();
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/EventList.java b/services/people/java/com/android/server/people/data/EventList.java
new file mode 100644
index 000000000000..b267d667b422
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/EventList.java
@@ -0,0 +1,103 @@
+/*
+ * 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.NonNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/** A container that holds a list of {@link Event}s in chronological order. */
+class EventList {
+
+ private final List<Event> mEvents = new ArrayList<>();
+
+ /**
+ * Adds an event to the list unless there is an existing event with the same timestamp and
+ * type.
+ */
+ void add(@NonNull Event event) {
+ int index = firstIndexOnOrAfter(event.getTimestamp());
+ if (index < mEvents.size()
+ && mEvents.get(index).getTimestamp() == event.getTimestamp()
+ && isDuplicate(event, index)) {
+ return;
+ }
+ mEvents.add(index, event);
+ }
+
+ /**
+ * Returns a {@link List} of {@link Event}s whose timestamps are between the specified {@code
+ * fromTimestamp}, inclusive, and {@code toTimestamp} exclusive, and match the specified event
+ * types.
+ *
+ * @return a {@link List} of matched {@link Event}s in chronological order.
+ */
+ @NonNull
+ List<Event> queryEvents(@NonNull Set<Integer> eventTypes, long fromTimestamp,
+ long toTimestamp) {
+ int fromIndex = firstIndexOnOrAfter(fromTimestamp);
+ if (fromIndex == mEvents.size()) {
+ return new ArrayList<>();
+ }
+ int toIndex = firstIndexOnOrAfter(toTimestamp);
+ if (toIndex < fromIndex) {
+ return new ArrayList<>();
+ }
+ List<Event> result = new ArrayList<>();
+ for (int i = fromIndex; i < toIndex; i++) {
+ Event e = mEvents.get(i);
+ if (eventTypes.contains(e.getType())) {
+ result.add(e);
+ }
+ }
+ return result;
+ }
+
+ /** Returns the first index whose timestamp is greater or equal to the provided timestamp. */
+ private int firstIndexOnOrAfter(long timestamp) {
+ int result = mEvents.size();
+ int low = 0;
+ int high = mEvents.size() - 1;
+ while (low <= high) {
+ int mid = (low + high) >>> 1;
+ if (mEvents.get(mid).getTimestamp() >= timestamp) {
+ high = mid - 1;
+ result = mid;
+ } else {
+ low = mid + 1;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Checks whether the {@link Event} is duplicate with one of the existing events. The checking
+ * starts from the {@code startIndex}.
+ */
+ private boolean isDuplicate(Event event, int startIndex) {
+ int size = mEvents.size();
+ int index = startIndex;
+ while (index < size && mEvents.get(index).getTimestamp() <= event.getTimestamp()) {
+ if (mEvents.get(index++).getType() == event.getType()) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/EventStore.java b/services/people/java/com/android/server/people/data/EventStore.java
new file mode 100644
index 000000000000..d6b7a863ca2d
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/EventStore.java
@@ -0,0 +1,114 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.content.LocusId;
+import android.util.ArrayMap;
+
+import java.util.Map;
+
+/** The store that stores and accesses the events data for a package. */
+class EventStore {
+
+ private final EventHistoryImpl mPackageEventHistory = new EventHistoryImpl();
+
+ // Shortcut ID -> Event History
+ private final Map<String, EventHistoryImpl> mShortcutEventHistoryMap = new ArrayMap<>();
+
+ // Locus ID -> Event History
+ private final Map<LocusId, EventHistoryImpl> mLocusEventHistoryMap = new ArrayMap<>();
+
+ // Phone Number -> Event History
+ private final Map<String, EventHistoryImpl> mCallEventHistoryMap = new ArrayMap<>();
+
+ // Phone Number -> Event History
+ private final Map<String, EventHistoryImpl> mSmsEventHistoryMap = new ArrayMap<>();
+
+ /** Gets the package level {@link EventHistory}. */
+ @NonNull
+ EventHistory getPackageEventHistory() {
+ return mPackageEventHistory;
+ }
+
+ /** Gets the {@link EventHistory} for the specified {@code shortcutId} if exists. */
+ @Nullable
+ EventHistory getShortcutEventHistory(String shortcutId) {
+ return mShortcutEventHistoryMap.get(shortcutId);
+ }
+
+ /** Gets the {@link EventHistory} for the specified {@code locusId} if exists. */
+ @Nullable
+ EventHistory getLocusEventHistory(LocusId locusId) {
+ return mLocusEventHistoryMap.get(locusId);
+ }
+
+ /** Gets the phone call {@link EventHistory} for the specified {@code phoneNumber} if exists. */
+ @Nullable
+ EventHistory getCallEventHistory(String phoneNumber) {
+ return mCallEventHistoryMap.get(phoneNumber);
+ }
+
+ /** Gets the SMS {@link EventHistory} for the specified {@code phoneNumber} if exists. */
+ @Nullable
+ EventHistory getSmsEventHistory(String phoneNumber) {
+ return mSmsEventHistoryMap.get(phoneNumber);
+ }
+
+ /**
+ * Gets the {@link EventHistoryImpl} for the specified {@code shortcutId} or creates a new
+ * instance and put it into the store if not exists. The caller needs to verify if a
+ * conversation with this shortcut ID exists before calling this method.
+ */
+ @NonNull
+ EventHistoryImpl getOrCreateShortcutEventHistory(String shortcutId) {
+ return mShortcutEventHistoryMap.computeIfAbsent(shortcutId, key -> new EventHistoryImpl());
+ }
+
+ /**
+ * Gets the {@link EventHistoryImpl} for the specified {@code locusId} or creates a new
+ * instance and put it into the store if not exists. The caller needs to ensure a conversation
+ * with this locus ID exists before calling this method.
+ */
+ @NonNull
+ EventHistoryImpl getOrCreateLocusEventHistory(LocusId locusId) {
+ return mLocusEventHistoryMap.computeIfAbsent(locusId, key -> new EventHistoryImpl());
+ }
+
+ /**
+ * Gets the {@link EventHistoryImpl} for the specified {@code phoneNumber} for call events
+ * or creates a new instance and put it into the store if not exists. The caller needs to ensure
+ * a conversation with this phone number exists and this package is the default dialer
+ * before calling this method.
+ */
+ @NonNull
+ EventHistoryImpl getOrCreateCallEventHistory(String phoneNumber) {
+ return mCallEventHistoryMap.computeIfAbsent(phoneNumber, key -> new EventHistoryImpl());
+ }
+
+ /**
+ * Gets the {@link EventHistoryImpl} for the specified {@code phoneNumber} for SMS events
+ * or creates a new instance and put it into the store if not exists. The caller needs to ensure
+ * a conversation with this phone number exists and this package is the default SMS app
+ * before calling this method.
+ */
+ @NonNull
+ EventHistoryImpl getOrCreateSmsEventHistory(String phoneNumber) {
+ return mSmsEventHistoryMap.computeIfAbsent(phoneNumber, key -> new EventHistoryImpl());
+ }
+}
diff --git a/services/people/java/com/android/server/people/data/PackageData.java b/services/people/java/com/android/server/people/data/PackageData.java
new file mode 100644
index 000000000000..9c22a7f1c484
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/PackageData.java
@@ -0,0 +1,145 @@
+/*
+ * 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.NonNull;
+import android.annotation.UserIdInt;
+import android.content.LocusId;
+import android.text.TextUtils;
+
+import java.util.function.Consumer;
+
+/** The data associated with a package. */
+public class PackageData {
+
+ @NonNull
+ private final String mPackageName;
+
+ private final @UserIdInt int mUserId;
+
+ @NonNull
+ private final ConversationStore mConversationStore;
+
+ @NonNull
+ private final EventStore mEventStore;
+
+ private boolean mIsDefaultDialer;
+
+ private boolean mIsDefaultSmsApp;
+
+ PackageData(@NonNull String packageName, @UserIdInt int userId) {
+ mPackageName = packageName;
+ mUserId = userId;
+ mConversationStore = new ConversationStore();
+ mEventStore = new EventStore();
+ }
+
+ @NonNull
+ public String getPackageName() {
+ return mPackageName;
+ }
+
+ public @UserIdInt int getUserId() {
+ return mUserId;
+ }
+
+ /** Iterates over all the conversations in this package. */
+ public void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
+ mConversationStore.forAllConversations(consumer);
+ }
+
+ @NonNull
+ public EventHistory getPackageLevelEventHistory() {
+ return getEventStore().getPackageEventHistory();
+ }
+
+ /**
+ * Gets the combined {@link EventHistory} for a given shortcut ID. This returned {@link
+ * EventHistory} has events of all types, no matter whether they're annotated with shortcut ID,
+ * Locus ID, or phone number etc.
+ */
+ @NonNull
+ public EventHistory getEventHistory(@NonNull String shortcutId) {
+ AggregateEventHistoryImpl result = new AggregateEventHistoryImpl();
+
+ ConversationInfo conversationInfo = mConversationStore.getConversation(shortcutId);
+ if (conversationInfo == null) {
+ return result;
+ }
+
+ EventHistory shortcutEventHistory = getEventStore().getShortcutEventHistory(shortcutId);
+ if (shortcutEventHistory != null) {
+ result.addEventHistory(shortcutEventHistory);
+ }
+
+ LocusId locusId = conversationInfo.getLocusId();
+ if (locusId != null) {
+ EventHistory locusEventHistory = getEventStore().getLocusEventHistory(locusId);
+ if (locusEventHistory != null) {
+ result.addEventHistory(locusEventHistory);
+ }
+ }
+
+ String phoneNumber = conversationInfo.getContactPhoneNumber();
+ if (TextUtils.isEmpty(phoneNumber)) {
+ return result;
+ }
+ if (isDefaultDialer()) {
+ EventHistory callEventHistory = getEventStore().getCallEventHistory(phoneNumber);
+ if (callEventHistory != null) {
+ result.addEventHistory(callEventHistory);
+ }
+ }
+ if (isDefaultSmsApp()) {
+ EventHistory smsEventHistory = getEventStore().getSmsEventHistory(phoneNumber);
+ if (smsEventHistory != null) {
+ result.addEventHistory(smsEventHistory);
+ }
+ }
+ return result;
+ }
+
+ public boolean isDefaultDialer() {
+ return mIsDefaultDialer;
+ }
+
+ public boolean isDefaultSmsApp() {
+ return mIsDefaultSmsApp;
+ }
+
+ @NonNull
+ ConversationStore getConversationStore() {
+ return mConversationStore;
+ }
+
+ @NonNull
+ EventStore getEventStore() {
+ 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/UserData.java b/services/people/java/com/android/server/people/data/UserData.java
new file mode 100644
index 000000000000..2c16059e89ba
--- /dev/null
+++ b/services/people/java/com/android/server/people/data/UserData.java
@@ -0,0 +1,103 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+
+import java.util.Map;
+import java.util.function.Consumer;
+
+/** The data associated with a user profile. */
+class UserData {
+
+ private final @UserIdInt int mUserId;
+
+ private boolean mIsUnlocked;
+
+ private Map<String, PackageData> mPackageDataMap = new ArrayMap<>();
+
+ UserData(@UserIdInt int userId) {
+ mUserId = userId;
+ }
+
+ @UserIdInt int getUserId() {
+ return mUserId;
+ }
+
+ void forAllPackages(@NonNull Consumer<PackageData> consumer) {
+ for (PackageData packageData : mPackageDataMap.values()) {
+ consumer.accept(packageData);
+ }
+ }
+
+ void setUserUnlocked() {
+ mIsUnlocked = true;
+ }
+
+ void setUserStopped() {
+ mIsUnlocked = false;
+ }
+
+ boolean isUnlocked() {
+ return mIsUnlocked;
+ }
+
+ /**
+ * Gets the {@link PackageData} for the specified {@code packageName} if exists; otherwise
+ * creates a new instance and returns it.
+ */
+ @NonNull
+ PackageData getOrCreatePackageData(String packageName) {
+ return mPackageDataMap.computeIfAbsent(
+ packageName, key -> new PackageData(packageName, mUserId));
+ }
+
+ /**
+ * Gets the {@link PackageData} for the specified {@code packageName} if exists; otherwise
+ * returns {@code null}.
+ */
+ @Nullable
+ PackageData getPackageData(@NonNull String packageName) {
+ return mPackageDataMap.get(packageName);
+ }
+
+ 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);
+ }
+ }
+ }
+
+ 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);
+ }
+ }
+ }
+}
diff --git a/services/people/java/com/android/server/people/prediction/ConversationData.java b/services/people/java/com/android/server/people/prediction/ConversationData.java
new file mode 100644
index 000000000000..0cc763329764
--- /dev/null
+++ b/services/people/java/com/android/server/people/prediction/ConversationData.java
@@ -0,0 +1,55 @@
+/*
+ * 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.prediction;
+
+import android.annotation.NonNull;
+
+import com.android.server.people.data.ConversationInfo;
+import com.android.server.people.data.EventHistory;
+
+/** The conversation data which is used for scoring and then ranking the conversations. */
+class ConversationData {
+
+ private final String mPackageName;
+ private final int mUserId;
+ private final ConversationInfo mConversationInfo;
+ private final EventHistory mEventHistory;
+
+ ConversationData(@NonNull String packageName, int userId,
+ @NonNull ConversationInfo conversationInfo, @NonNull EventHistory eventHistory) {
+ mPackageName = packageName;
+ mUserId = userId;
+ mConversationInfo = conversationInfo;
+ mEventHistory = eventHistory;
+ }
+
+ String getPackageName() {
+ return mPackageName;
+ }
+
+ int getUserId() {
+ return mUserId;
+ }
+
+ ConversationInfo getConversationInfo() {
+ return mConversationInfo;
+ }
+
+ EventHistory getEventHistory() {
+ return mEventHistory;
+ }
+}
diff --git a/services/people/java/com/android/server/people/prediction/ConversationPredictor.java b/services/people/java/com/android/server/people/prediction/ConversationPredictor.java
index de71d292ff7d..ed8a56bb6435 100644
--- a/services/people/java/com/android/server/people/prediction/ConversationPredictor.java
+++ b/services/people/java/com/android/server/people/prediction/ConversationPredictor.java
@@ -17,12 +17,20 @@
package com.android.server.people.prediction;
import android.annotation.MainThread;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
import android.app.prediction.AppPredictionContext;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
import android.app.prediction.AppTargetId;
+import android.content.IntentFilter;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager.ShareShortcutInfo;
import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.ChooserActivity;
+import com.android.server.people.data.DataManager;
+import com.android.server.people.data.EventHistory;
import java.util.ArrayList;
import java.util.List;
@@ -35,15 +43,28 @@ import java.util.function.Consumer;
*/
public class ConversationPredictor {
+ private static final String UI_SURFACE_SHARE = "share";
+
private final AppPredictionContext mPredictionContext;
private final Consumer<List<AppTarget>> mUpdatePredictionsMethod;
+ private final DataManager mDataManager;
private final ExecutorService mCallbackExecutor;
+ @Nullable
+ private final IntentFilter mIntentFilter;
- public ConversationPredictor(AppPredictionContext predictionContext,
- Consumer<List<AppTarget>> updatePredictionsMethod) {
+ public ConversationPredictor(@NonNull AppPredictionContext predictionContext,
+ @NonNull Consumer<List<AppTarget>> updatePredictionsMethod,
+ @NonNull DataManager dataManager) {
mPredictionContext = predictionContext;
mUpdatePredictionsMethod = updatePredictionsMethod;
+ mDataManager = dataManager;
mCallbackExecutor = Executors.newSingleThreadExecutor();
+ if (UI_SURFACE_SHARE.equals(mPredictionContext.getUiSurface())) {
+ mIntentFilter = mPredictionContext.getExtras().getParcelable(
+ ChooserActivity.APP_PREDICTION_INTENT_FILTER_KEY);
+ } else {
+ mIntentFilter = null;
+ }
}
/**
@@ -51,14 +72,14 @@ public class ConversationPredictor {
*/
@MainThread
public void onAppTargetEvent(AppTargetEvent event) {
+ mDataManager.reportAppTargetEvent(event, mIntentFilter);
}
/**
* Called by the client app to indicate a particular location has been shown to the user.
*/
@MainThread
- public void onLaunchLocationShown(String launchLocation, List<AppTargetId> targetIds) {
- }
+ public void onLaunchLocationShown(String launchLocation, List<AppTargetId> targetIds) {}
/**
* Called by the client app to request sorting of the provided targets based on the prediction
@@ -74,8 +95,44 @@ public class ConversationPredictor {
*/
@MainThread
public void onRequestPredictionUpdate() {
- List<AppTarget> targets = new ArrayList<>();
- mCallbackExecutor.execute(() -> mUpdatePredictionsMethod.accept(targets));
+ // TODO: Re-route the call to different ranking classes for different surfaces.
+ mCallbackExecutor.execute(() -> {
+ List<AppTarget> targets = new ArrayList<>();
+ if (mIntentFilter != null) {
+ List<ShareShortcutInfo> shareShortcuts =
+ mDataManager.getConversationShareTargets(mIntentFilter);
+ for (ShareShortcutInfo shareShortcut : shareShortcuts) {
+ ShortcutInfo shortcutInfo = shareShortcut.getShortcutInfo();
+ AppTargetId appTargetId = new AppTargetId(shortcutInfo.getId());
+ String shareTargetClass = shareShortcut.getTargetComponent().getClassName();
+ targets.add(new AppTarget.Builder(appTargetId, shortcutInfo)
+ .setClassName(shareTargetClass)
+ .build());
+ }
+ } else {
+ List<ConversationData> conversationDataList = new ArrayList<>();
+ mDataManager.forAllPackages(packageData ->
+ packageData.forAllConversations(conversationInfo -> {
+ EventHistory eventHistory = packageData.getEventHistory(
+ conversationInfo.getShortcutId());
+ ConversationData conversationData = new ConversationData(
+ packageData.getPackageName(), packageData.getUserId(),
+ conversationInfo, eventHistory);
+ conversationDataList.add(conversationData);
+ }));
+ for (ConversationData conversationData : conversationDataList) {
+ String shortcutId = conversationData.getConversationInfo().getShortcutId();
+ ShortcutInfo shortcut = mDataManager.getShortcut(
+ conversationData.getPackageName(), conversationData.getUserId(),
+ shortcutId);
+ if (shortcut != null) {
+ AppTargetId appTargetId = new AppTargetId(shortcut.getId());
+ targets.add(new AppTarget.Builder(appTargetId, shortcut).build());
+ }
+ }
+ }
+ mUpdatePredictionsMethod.accept(targets);
+ });
}
@VisibleForTesting
diff --git a/services/tests/servicestests/src/com/android/server/people/data/AggregateEventHistoryImplTest.java b/services/tests/servicestests/src/com/android/server/people/data/AggregateEventHistoryImplTest.java
new file mode 100644
index 000000000000..b614a4f91d28
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/AggregateEventHistoryImplTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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 com.android.server.people.data.TestUtils.timestamp;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class AggregateEventHistoryImplTest {
+
+ private static final long CURRENT_TIMESTAMP = timestamp("01-30 18:50");
+
+ private static final Event E1 = new Event(timestamp("01-06 05:26"),
+ Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E2 = new Event(timestamp("01-27 18:41"),
+ Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E3 = new Event(timestamp("01-30 03:06"),
+ Event.TYPE_SMS_OUTGOING);
+ private static final Event E4 = new Event(timestamp("01-30 18:14"),
+ Event.TYPE_SMS_INCOMING);
+
+ private EventHistoryImpl mEventHistory1;
+ private EventHistoryImpl mEventHistory2;
+
+ private AggregateEventHistoryImpl mAggEventHistory;
+
+ private EventIndex.Injector mInjector = new EventIndex.Injector() {
+ @Override
+ long currentTimeMillis() {
+ return CURRENT_TIMESTAMP;
+ }
+ };
+
+ @Before
+ public void setUp() {
+ mAggEventHistory = new AggregateEventHistoryImpl();
+
+ EventHistoryImpl.Injector injector = new EventHistoryImplInjector();
+
+ mEventHistory1 = new EventHistoryImpl(injector);
+ mEventHistory1.addEvent(E1);
+ mEventHistory1.addEvent(E2);
+
+ mEventHistory2 = new EventHistoryImpl(injector);
+ mEventHistory2.addEvent(E3);
+ mEventHistory2.addEvent(E4);
+ }
+
+ @Test
+ public void testEmptyAggregateEventHistory() {
+ assertTrue(mAggEventHistory.getEventIndex(Event.TYPE_SHORTCUT_INVOCATION).isEmpty());
+ assertTrue(mAggEventHistory.getEventIndex(Event.ALL_EVENT_TYPES).isEmpty());
+ assertTrue(mAggEventHistory.queryEvents(
+ Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE).isEmpty());
+ }
+
+ @Test
+ public void testQueryEventIndexForSingleEventType() {
+ mAggEventHistory.addEventHistory(mEventHistory1);
+ mAggEventHistory.addEventHistory(mEventHistory2);
+
+ EventIndex eventIndex;
+
+ eventIndex = mAggEventHistory.getEventIndex(Event.TYPE_NOTIFICATION_OPENED);
+ assertEquals(2, eventIndex.getActiveTimeSlots().size());
+
+ eventIndex = mAggEventHistory.getEventIndex(Event.TYPE_SMS_OUTGOING);
+ assertEquals(1, eventIndex.getActiveTimeSlots().size());
+
+ eventIndex = mAggEventHistory.getEventIndex(Event.TYPE_SHORTCUT_INVOCATION);
+ assertTrue(eventIndex.isEmpty());
+ }
+
+ @Test
+ public void testQueryEventIndexForMultipleEventTypes() {
+ mAggEventHistory.addEventHistory(mEventHistory1);
+ mAggEventHistory.addEventHistory(mEventHistory2);
+
+ EventIndex eventIndex;
+
+ eventIndex = mAggEventHistory.getEventIndex(Event.SMS_EVENT_TYPES);
+ assertEquals(2, eventIndex.getActiveTimeSlots().size());
+
+ eventIndex = mAggEventHistory.getEventIndex(Event.ALL_EVENT_TYPES);
+ assertEquals(4, eventIndex.getActiveTimeSlots().size());
+ }
+
+ @Test
+ public void testQueryEvents() {
+ mAggEventHistory.addEventHistory(mEventHistory1);
+ mAggEventHistory.addEventHistory(mEventHistory2);
+
+ List<Event> events;
+
+ events = mAggEventHistory.queryEvents(Event.NOTIFICATION_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(2, events.size());
+
+ events = mAggEventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(4, events.size());
+ }
+
+ private class EventHistoryImplInjector extends EventHistoryImpl.Injector {
+
+ EventIndex createEventIndex() {
+ return new EventIndex(mInjector);
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/ContactsQueryHelperTest.java b/services/tests/servicestests/src/com/android/server/people/data/ContactsQueryHelperTest.java
new file mode 100644
index 000000000000..96302b954e75
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/ContactsQueryHelperTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.Assert.assertNull;
+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.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+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.Map;
+
+@RunWith(JUnit4.class)
+public final class ContactsQueryHelperTest {
+
+ private static final String CONTACT_LOOKUP_KEY = "123";
+ private static final String PHONE_NUMBER = "+1234567890";
+
+ private static final String[] CONTACTS_COLUMNS = new String[] {
+ Contacts._ID, Contacts.LOOKUP_KEY, Contacts.STARRED, Contacts.HAS_PHONE_NUMBER,
+ Contacts.CONTACT_LAST_UPDATED_TIMESTAMP };
+ private static final String[] CONTACTS_LOOKUP_COLUMNS = new String[] {
+ Contacts._ID, Contacts.LOOKUP_KEY, Contacts.STARRED, Contacts.HAS_PHONE_NUMBER };
+ private static final String[] PHONE_COLUMNS = new String[] {
+ ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER };
+
+ @Mock
+ private MockContext mContext;
+
+ private MatrixCursor mContactsCursor;
+ private MatrixCursor mContactsLookupCursor;
+ private MatrixCursor mPhoneCursor;
+ private ContactsQueryHelper mHelper;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ mContactsCursor = new MatrixCursor(CONTACTS_COLUMNS);
+ mContactsLookupCursor = new MatrixCursor(CONTACTS_LOOKUP_COLUMNS);
+ mPhoneCursor = new MatrixCursor(PHONE_COLUMNS);
+
+ MockContentResolver contentResolver = new MockContentResolver();
+ ContactsContentProvider contentProvider = new ContactsContentProvider();
+ contentProvider.registerCursor(Contacts.CONTENT_URI, mContactsCursor);
+ contentProvider.registerCursor(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI, mContactsLookupCursor);
+ contentProvider.registerCursor(
+ ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, mContactsLookupCursor);
+ contentProvider.registerCursor(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI, mPhoneCursor);
+
+ contentResolver.addProvider(ContactsContract.AUTHORITY, contentProvider);
+ when(mContext.getContentResolver()).thenReturn(contentResolver);
+
+ mHelper = new ContactsQueryHelper(mContext);
+ }
+
+ @Test
+ public void testQueryWithUri() {
+ mContactsCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 1, /* hasPhoneNumber= */ 1,
+ /* lastUpdatedTimestamp= */ 100L });
+ mPhoneCursor.addRow(new String[] { PHONE_NUMBER });
+ Uri contactUri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, CONTACT_LOOKUP_KEY);
+ assertTrue(mHelper.query(contactUri.toString()));
+ assertNotNull(mHelper.getContactUri());
+ assertEquals(PHONE_NUMBER, mHelper.getPhoneNumber());
+ assertEquals(100L, mHelper.getLastUpdatedTimestamp());
+ assertTrue(mHelper.isStarred());
+ }
+
+ @Test
+ public void testQueryWithUriNotStarredNoPhoneNumber() {
+ mContactsCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 0, /* hasPhoneNumber= */ 0,
+ /* lastUpdatedTimestamp= */ 100L });
+ Uri contactUri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, CONTACT_LOOKUP_KEY);
+ assertTrue(mHelper.query(contactUri.toString()));
+ assertNotNull(mHelper.getContactUri());
+ assertNull(mHelper.getPhoneNumber());
+ assertFalse(mHelper.isStarred());
+ assertEquals(100L, mHelper.getLastUpdatedTimestamp());
+ }
+
+ @Test
+ public void testQueryWithUriNotFound() {
+ Uri contactUri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, CONTACT_LOOKUP_KEY);
+ assertFalse(mHelper.query(contactUri.toString()));
+ }
+
+ @Test
+ public void testQueryWithPhoneNumber() {
+ mContactsLookupCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 1, /* hasPhoneNumber= */ 1 });
+ mPhoneCursor.addRow(new String[] { PHONE_NUMBER });
+ String contactUri = "tel:" + PHONE_NUMBER;
+ assertTrue(mHelper.query(contactUri));
+ assertNotNull(mHelper.getContactUri());
+ assertEquals(PHONE_NUMBER, mHelper.getPhoneNumber());
+ assertTrue(mHelper.isStarred());
+ }
+
+ @Test
+ public void testQueryWithEmail() {
+ mContactsLookupCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 1, /* hasPhoneNumber= */ 0 });
+ String contactUri = "mailto:test@gmail.com";
+ assertTrue(mHelper.query(contactUri));
+ assertNotNull(mHelper.getContactUri());
+ assertNull(mHelper.getPhoneNumber());
+ assertTrue(mHelper.isStarred());
+ }
+
+ @Test
+ public void testQueryUpdatedContactSinceTime() {
+ mContactsCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 1, /* hasPhoneNumber= */ 0,
+ /* lastUpdatedTimestamp= */ 100L });
+ assertTrue(mHelper.querySince(50L));
+ assertNotNull(mHelper.getContactUri());
+ assertNull(mHelper.getPhoneNumber());
+ assertTrue(mHelper.isStarred());
+ assertEquals(100L, mHelper.getLastUpdatedTimestamp());
+ }
+
+ @Test
+ public void testQueryWithUnsupportedScheme() {
+ mContactsLookupCursor.addRow(new Object[] {
+ /* id= */ 11, CONTACT_LOOKUP_KEY, /* starred= */ 1, /* hasPhoneNumber= */ 1 });
+ mPhoneCursor.addRow(new String[] { PHONE_NUMBER });
+ String contactUri = "unknown:test";
+ assertFalse(mHelper.query(contactUri));
+ }
+
+ private class ContactsContentProvider extends MockContentProvider {
+
+ private Map<Uri, Cursor> mUriPrefixToCursorMap = new ArrayMap<>();
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ for (Uri prefixUri : mUriPrefixToCursorMap.keySet()) {
+ if (uri.isPathPrefixMatch(prefixUri)) {
+ return mUriPrefixToCursorMap.get(prefixUri);
+ }
+ }
+ return mUriPrefixToCursorMap.get(uri);
+ }
+
+ private void registerCursor(Uri uriPrefix, Cursor cursor) {
+ mUriPrefixToCursorMap.put(uriPrefix, cursor);
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java b/services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java
new file mode 100644
index 000000000000..05a9a80e262c
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.LocusId;
+import android.content.pm.ShortcutInfo;
+import android.net.Uri;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ConversationInfoTest {
+
+ private static final String SHORTCUT_ID = "abc";
+ private static final LocusId LOCUS_ID = new LocusId("def");
+ private static final Uri CONTACT_URI = Uri.parse("tel:+1234567890");
+ private static final String PHONE_NUMBER = "+1234567890";
+ private static final String NOTIFICATION_CHANNEL_ID = "test : abc";
+
+ @Test
+ public void testBuild() {
+ ConversationInfo conversationInfo = new ConversationInfo.Builder()
+ .setShortcutId(SHORTCUT_ID)
+ .setLocusId(LOCUS_ID)
+ .setContactUri(CONTACT_URI)
+ .setContactPhoneNumber(PHONE_NUMBER)
+ .setNotificationChannelId(NOTIFICATION_CHANNEL_ID)
+ .setShortcutFlags(ShortcutInfo.FLAG_LONG_LIVED)
+ .setVip(true)
+ .setNotificationSilenced(true)
+ .setBubbled(true)
+ .setDemoted(true)
+ .setPersonImportant(true)
+ .setPersonBot(true)
+ .setContactStarred(true)
+ .build();
+
+ assertEquals(SHORTCUT_ID, conversationInfo.getShortcutId());
+ assertEquals(LOCUS_ID, conversationInfo.getLocusId());
+ assertEquals(CONTACT_URI, conversationInfo.getContactUri());
+ assertEquals(PHONE_NUMBER, conversationInfo.getContactPhoneNumber());
+ assertEquals(NOTIFICATION_CHANNEL_ID, conversationInfo.getNotificationChannelId());
+ assertTrue(conversationInfo.isShortcutLongLived());
+ assertTrue(conversationInfo.isVip());
+ assertTrue(conversationInfo.isNotificationSilenced());
+ assertTrue(conversationInfo.isBubbled());
+ assertTrue(conversationInfo.isDemoted());
+ assertTrue(conversationInfo.isPersonImportant());
+ assertTrue(conversationInfo.isPersonBot());
+ assertTrue(conversationInfo.isContactStarred());
+ }
+
+ @Test
+ public void testBuildEmpty() {
+ ConversationInfo conversationInfo = new ConversationInfo.Builder()
+ .setShortcutId(SHORTCUT_ID)
+ .build();
+
+ assertEquals(SHORTCUT_ID, conversationInfo.getShortcutId());
+ assertNull(conversationInfo.getLocusId());
+ assertNull(conversationInfo.getContactUri());
+ assertNull(conversationInfo.getContactPhoneNumber());
+ assertNull(conversationInfo.getNotificationChannelId());
+ assertFalse(conversationInfo.isShortcutLongLived());
+ assertFalse(conversationInfo.isVip());
+ assertFalse(conversationInfo.isNotificationSilenced());
+ assertFalse(conversationInfo.isBubbled());
+ assertFalse(conversationInfo.isDemoted());
+ assertFalse(conversationInfo.isPersonImportant());
+ assertFalse(conversationInfo.isPersonBot());
+ assertFalse(conversationInfo.isContactStarred());
+ }
+
+ @Test
+ public void testBuildFromAnotherConversationInfo() {
+ ConversationInfo source = new ConversationInfo.Builder()
+ .setShortcutId(SHORTCUT_ID)
+ .setLocusId(LOCUS_ID)
+ .setContactUri(CONTACT_URI)
+ .setContactPhoneNumber(PHONE_NUMBER)
+ .setNotificationChannelId(NOTIFICATION_CHANNEL_ID)
+ .setShortcutFlags(ShortcutInfo.FLAG_LONG_LIVED)
+ .setVip(true)
+ .setNotificationSilenced(true)
+ .setBubbled(true)
+ .setPersonImportant(true)
+ .setPersonBot(true)
+ .setContactStarred(true)
+ .build();
+
+ ConversationInfo destination = new ConversationInfo.Builder(source)
+ .setVip(false)
+ .setContactStarred(false)
+ .build();
+
+ assertEquals(SHORTCUT_ID, destination.getShortcutId());
+ assertEquals(LOCUS_ID, destination.getLocusId());
+ assertEquals(CONTACT_URI, destination.getContactUri());
+ assertEquals(PHONE_NUMBER, destination.getContactPhoneNumber());
+ assertEquals(NOTIFICATION_CHANNEL_ID, destination.getNotificationChannelId());
+ assertTrue(destination.isShortcutLongLived());
+ assertFalse(destination.isVip());
+ assertTrue(destination.isNotificationSilenced());
+ assertTrue(destination.isBubbled());
+ assertTrue(destination.isPersonImportant());
+ assertTrue(destination.isPersonBot());
+ assertFalse(destination.isContactStarred());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java b/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java
new file mode 100644
index 000000000000..a40c6ab90197
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/ConversationStoreTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.LocusId;
+import android.content.pm.ShortcutInfo;
+import android.net.Uri;
+import android.util.ArraySet;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Set;
+
+@RunWith(JUnit4.class)
+public final class ConversationStoreTest {
+
+ private static final String SHORTCUT_ID = "abc";
+ private static final LocusId LOCUS_ID = new LocusId("def");
+ private static final Uri CONTACT_URI = Uri.parse("tel:+1234567890");
+ private static final String PHONE_NUMBER = "+1234567890";
+
+ private ConversationStore mConversationStore;
+
+ @Before
+ public void setUp() {
+ mConversationStore = new ConversationStore();
+ }
+
+ @Test
+ public void testAddConversation() {
+ mConversationStore.addOrUpdate(buildConversationInfo(SHORTCUT_ID));
+
+ ConversationInfo out = mConversationStore.getConversation(SHORTCUT_ID);
+ assertNotNull(out);
+ assertEquals(SHORTCUT_ID, out.getShortcutId());
+ }
+
+ @Test
+ public void testUpdateConversation() {
+ ConversationInfo original =
+ buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI, PHONE_NUMBER);
+ mConversationStore.addOrUpdate(original);
+ assertEquals(LOCUS_ID, mConversationStore.getConversation(SHORTCUT_ID).getLocusId());
+
+ LocusId newLocusId = new LocusId("ghi");
+ ConversationInfo update = buildConversationInfo(
+ SHORTCUT_ID, newLocusId, CONTACT_URI, PHONE_NUMBER);
+ mConversationStore.addOrUpdate(update);
+ assertEquals(newLocusId, mConversationStore.getConversation(SHORTCUT_ID).getLocusId());
+ }
+
+ @Test
+ public void testDeleteConversation() {
+ mConversationStore.addOrUpdate(buildConversationInfo(SHORTCUT_ID));
+ assertNotNull(mConversationStore.getConversation(SHORTCUT_ID));
+
+ mConversationStore.deleteConversation(SHORTCUT_ID);
+ assertNull(mConversationStore.getConversation(SHORTCUT_ID));
+ }
+
+ @Test
+ public void testForAllConversations() {
+ mConversationStore.addOrUpdate(buildConversationInfo("a"));
+ mConversationStore.addOrUpdate(buildConversationInfo("b"));
+ mConversationStore.addOrUpdate(buildConversationInfo("c"));
+
+ Set<String> shortcutIds = new ArraySet<>();
+
+ mConversationStore.forAllConversations(
+ conversationInfo -> shortcutIds.add(conversationInfo.getShortcutId()));
+ assertTrue(shortcutIds.contains("a"));
+ assertTrue(shortcutIds.contains("b"));
+ assertTrue(shortcutIds.contains("c"));
+ }
+
+ @Test
+ public void testGetConversationByLocusId() {
+ ConversationInfo in =
+ buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI, PHONE_NUMBER);
+ mConversationStore.addOrUpdate(in);
+ ConversationInfo out = mConversationStore.getConversationByLocusId(LOCUS_ID);
+ assertNotNull(out);
+ assertEquals(SHORTCUT_ID, out.getShortcutId());
+
+ mConversationStore.deleteConversation(SHORTCUT_ID);
+ assertNull(mConversationStore.getConversationByLocusId(LOCUS_ID));
+ }
+
+ @Test
+ public void testGetConversationByContactUri() {
+ ConversationInfo in =
+ buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI, PHONE_NUMBER);
+ mConversationStore.addOrUpdate(in);
+ ConversationInfo out = mConversationStore.getConversationByContactUri(CONTACT_URI);
+ assertNotNull(out);
+ assertEquals(SHORTCUT_ID, out.getShortcutId());
+
+ mConversationStore.deleteConversation(SHORTCUT_ID);
+ assertNull(mConversationStore.getConversationByContactUri(CONTACT_URI));
+ }
+
+ @Test
+ public void testGetConversationByPhoneNumber() {
+ ConversationInfo in =
+ buildConversationInfo(SHORTCUT_ID, LOCUS_ID, CONTACT_URI, PHONE_NUMBER);
+ mConversationStore.addOrUpdate(in);
+ ConversationInfo out = mConversationStore.getConversationByPhoneNumber(PHONE_NUMBER);
+ assertNotNull(out);
+ assertEquals(SHORTCUT_ID, out.getShortcutId());
+
+ mConversationStore.deleteConversation(SHORTCUT_ID);
+ assertNull(mConversationStore.getConversationByPhoneNumber(PHONE_NUMBER));
+ }
+
+ private static ConversationInfo buildConversationInfo(String shortcutId) {
+ return buildConversationInfo(shortcutId, null, null, null);
+ }
+
+ private static ConversationInfo buildConversationInfo(
+ String shortcutId, LocusId locusId, Uri contactUri, String phoneNumber) {
+ return new ConversationInfo.Builder()
+ .setShortcutId(shortcutId)
+ .setLocusId(locusId)
+ .setContactUri(contactUri)
+ .setContactPhoneNumber(phoneNumber)
+ .setShortcutFlags(ShortcutInfo.FLAG_LONG_LIVED)
+ .setVip(true)
+ .setBubbled(true)
+ .build();
+ }
+}
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
new file mode 100644
index 000000000000..9f3d656188e1
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java
@@ -0,0 +1,451 @@
+/*
+ * 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 android.app.usage.UsageEvents.Event.SHORTCUT_INVOCATION;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.Notification;
+import android.app.Person;
+import android.app.prediction.AppTarget;
+import android.app.prediction.AppTargetEvent;
+import android.app.prediction.AppTargetId;
+import android.app.usage.UsageEvents;
+import android.app.usage.UsageStatsManagerInternal;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.content.pm.ShortcutManager.ShareShortcutInfo;
+import android.content.pm.ShortcutServiceInternal;
+import android.content.pm.UserInfo;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Looper;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.provider.ContactsContract;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import android.telephony.TelephonyManager;
+import android.util.Range;
+
+import com.android.internal.app.ChooserActivity;
+import com.android.server.LocalServices;
+
+import org.junit.After;
+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.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(JUnit4.class)
+public final class DataManagerTest {
+
+ private static final int USER_ID_PRIMARY = 0;
+ private static final int USER_ID_PRIMARY_MANAGED = 10;
+ private static final int USER_ID_SECONDARY = 11;
+ private static final String TEST_PKG_NAME = "pkg";
+ 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";
+
+ @Mock private Context mContext;
+ @Mock private ShortcutServiceInternal mShortcutServiceInternal;
+ @Mock private UsageStatsManagerInternal mUsageStatsManagerInternal;
+ @Mock private ShortcutManager mShortcutManager;
+ @Mock private UserManager mUserManager;
+ @Mock private TelephonyManager mTelephonyManager;
+ @Mock private ContentResolver mContentResolver;
+ @Mock private ScheduledExecutorService mExecutorService;
+ @Mock private ScheduledFuture mScheduledFuture;
+ @Mock private StatusBarNotification mStatusBarNotification;
+ @Mock private Notification mNotification;
+
+ private DataManager mDataManager;
+ private int mCallingUserId;
+ private TestInjector mInjector;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ addLocalServiceMock(ShortcutServiceInternal.class, mShortcutServiceInternal);
+
+ addLocalServiceMock(UsageStatsManagerInternal.class, mUsageStatsManagerInternal);
+
+ when(mContext.getMainLooper()).thenReturn(Looper.getMainLooper());
+
+ when(mContext.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(mShortcutManager);
+ when(mContext.getSystemServiceName(ShortcutManager.class)).thenReturn(
+ Context.SHORTCUT_SERVICE);
+
+ when(mContext.getSystemService(Context.USER_SERVICE)).thenReturn(mUserManager);
+ when(mContext.getSystemServiceName(UserManager.class)).thenReturn(
+ Context.USER_SERVICE);
+
+ when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephonyManager);
+
+ when(mExecutorService.scheduleAtFixedRate(any(Runnable.class), anyLong(), anyLong(), any(
+ TimeUnit.class))).thenReturn(mScheduledFuture);
+
+ when(mUserManager.getEnabledProfiles(USER_ID_PRIMARY))
+ .thenReturn(Arrays.asList(
+ buildUserInfo(USER_ID_PRIMARY),
+ buildUserInfo(USER_ID_PRIMARY_MANAGED)));
+ when(mUserManager.getEnabledProfiles(USER_ID_SECONDARY))
+ .thenReturn(Collections.singletonList(buildUserInfo(USER_ID_SECONDARY)));
+
+ when(mContext.getContentResolver()).thenReturn(mContentResolver);
+
+ when(mStatusBarNotification.getNotification()).thenReturn(mNotification);
+ when(mStatusBarNotification.getPackageName()).thenReturn(TEST_PKG_NAME);
+ when(mStatusBarNotification.getUser()).thenReturn(UserHandle.of(USER_ID_PRIMARY));
+ when(mNotification.getShortcutId()).thenReturn(TEST_SHORTCUT_ID);
+
+ mCallingUserId = USER_ID_PRIMARY;
+
+ mInjector = new TestInjector();
+ mDataManager = new DataManager(mContext, mInjector);
+ mDataManager.initialize();
+ }
+
+ @After
+ public void tearDown() {
+ LocalServices.removeServiceForTest(ShortcutServiceInternal.class);
+ LocalServices.removeServiceForTest(UsageStatsManagerInternal.class);
+ }
+
+ @Test
+ public void testAccessConversationFromTheSameProfileGroup() {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY_MANAGED);
+ mDataManager.onUserUnlocked(USER_ID_SECONDARY);
+
+ mDataManager.onShortcutAddedOrUpdated(
+ buildShortcutInfo("pkg_1", USER_ID_PRIMARY, "sc_1",
+ buildPerson(true, false)));
+ mDataManager.onShortcutAddedOrUpdated(
+ buildShortcutInfo("pkg_2", USER_ID_PRIMARY_MANAGED, "sc_2",
+ buildPerson(false, true)));
+ mDataManager.onShortcutAddedOrUpdated(
+ buildShortcutInfo("pkg_3", USER_ID_SECONDARY, "sc_3", buildPerson()));
+
+ List<ConversationInfo> conversations = new ArrayList<>();
+ mDataManager.forAllPackages(
+ packageData -> packageData.forAllConversations(conversations::add));
+
+ // USER_ID_SECONDARY is not in the same profile group as USER_ID_PRIMARY.
+ assertEquals(2, conversations.size());
+
+ assertEquals("sc_1", conversations.get(0).getShortcutId());
+ assertTrue(conversations.get(0).isPersonImportant());
+ assertFalse(conversations.get(0).isPersonBot());
+ assertFalse(conversations.get(0).isContactStarred());
+ assertEquals(PHONE_NUMBER, conversations.get(0).getContactPhoneNumber());
+
+ assertEquals("sc_2", conversations.get(1).getShortcutId());
+ assertFalse(conversations.get(1).isPersonImportant());
+ assertTrue(conversations.get(1).isPersonBot());
+ assertFalse(conversations.get(0).isContactStarred());
+ assertEquals(PHONE_NUMBER, conversations.get(0).getContactPhoneNumber());
+ }
+
+ @Test
+ public void testAccessConversationForUnlockedUsersOnly() {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+ mDataManager.onShortcutAddedOrUpdated(
+ buildShortcutInfo("pkg_1", USER_ID_PRIMARY, "sc_1", buildPerson()));
+ mDataManager.onShortcutAddedOrUpdated(
+ buildShortcutInfo("pkg_2", USER_ID_PRIMARY_MANAGED, "sc_2", buildPerson()));
+
+ List<ConversationInfo> conversations = new ArrayList<>();
+ mDataManager.forAllPackages(
+ packageData -> packageData.forAllConversations(conversations::add));
+
+ // USER_ID_PRIMARY_MANAGED is not locked, so only USER_ID_PRIMARY's conversation is stored.
+ assertEquals(1, conversations.size());
+ assertEquals("sc_1", conversations.get(0).getShortcutId());
+
+ mDataManager.onUserStopped(USER_ID_PRIMARY);
+ conversations.clear();
+ mDataManager.forAllPackages(
+ packageData -> packageData.forAllConversations(conversations::add));
+ assertTrue(conversations.isEmpty());
+ }
+
+ @Test
+ public void testGetShortcut() {
+ mDataManager.getShortcut(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID);
+ verify(mShortcutServiceInternal).getShortcuts(anyInt(), anyString(), anyLong(),
+ eq(TEST_PKG_NAME), eq(Collections.singletonList(TEST_SHORTCUT_ID)),
+ eq(null), anyInt(), eq(USER_ID_PRIMARY), anyInt(), anyInt());
+ }
+
+ @Test
+ public void testGetShareTargets() {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+
+ ShortcutInfo shortcut1 =
+ buildShortcutInfo("pkg_1", USER_ID_PRIMARY, "sc_1", buildPerson());
+ ShareShortcutInfo shareShortcut1 =
+ new ShareShortcutInfo(shortcut1, new ComponentName("pkg_1", "activity"));
+
+ ShortcutInfo shortcut2 =
+ buildShortcutInfo("pkg_2", USER_ID_PRIMARY, "sc_2", buildPerson());
+ ShareShortcutInfo shareShortcut2 =
+ new ShareShortcutInfo(shortcut2, new ComponentName("pkg_2", "activity"));
+ mDataManager.onShortcutAddedOrUpdated(shortcut2);
+
+ when(mShortcutManager.getShareTargets(any(IntentFilter.class)))
+ .thenReturn(Arrays.asList(shareShortcut1, shareShortcut2));
+
+ List<ShareShortcutInfo> shareShortcuts =
+ mDataManager.getConversationShareTargets(new IntentFilter());
+ // Only "sc_2" is stored as a conversation.
+ assertEquals(1, shareShortcuts.size());
+ assertEquals("sc_2", shareShortcuts.get(0).getShortcutInfo().getId());
+ }
+
+ @Test
+ public void testReportAppTargetEvent() throws IntentFilter.MalformedMimeTypeException {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+ ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+ buildPerson());
+ mDataManager.onShortcutAddedOrUpdated(shortcut);
+
+ AppTarget appTarget = new AppTarget.Builder(new AppTargetId(TEST_SHORTCUT_ID), shortcut)
+ .build();
+ AppTargetEvent appTargetEvent =
+ new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH)
+ .setLaunchLocation(ChooserActivity.LAUNCH_LOCATON_DIRECT_SHARE)
+ .build();
+ IntentFilter intentFilter = new IntentFilter(Intent.ACTION_SEND, "image/jpg");
+ mDataManager.reportAppTargetEvent(appTargetEvent, intentFilter);
+
+ List<Range<Long>> activeShareTimeSlots = new ArrayList<>();
+ mDataManager.forAllPackages(packageData ->
+ activeShareTimeSlots.addAll(
+ packageData.getEventHistory(TEST_SHORTCUT_ID)
+ .getEventIndex(Event.TYPE_SHARE_IMAGE)
+ .getActiveTimeSlots()));
+ assertEquals(1, activeShareTimeSlots.size());
+ }
+
+ @Test
+ public void testContactsChanged() {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+
+ ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+ buildPerson());
+ mDataManager.onShortcutAddedOrUpdated(shortcut);
+
+ final String newPhoneNumber = "+1000000000";
+ mInjector.mContactsQueryHelper.mIsStarred = true;
+ mInjector.mContactsQueryHelper.mPhoneNumber = newPhoneNumber;
+
+ ContentObserver contentObserver = mDataManager.getContactsContentObserverForTesting(
+ USER_ID_PRIMARY);
+ contentObserver.onChange(false, ContactsContract.Contacts.CONTENT_URI, USER_ID_PRIMARY);
+
+ List<ConversationInfo> conversations = new ArrayList<>();
+ mDataManager.forAllPackages(
+ packageData -> packageData.forAllConversations(conversations::add));
+ assertEquals(1, conversations.size());
+
+ assertEquals(TEST_SHORTCUT_ID, conversations.get(0).getShortcutId());
+ assertTrue(conversations.get(0).isContactStarred());
+ assertEquals(newPhoneNumber, conversations.get(0).getContactPhoneNumber());
+ }
+
+ @Test
+ public void testNotificationListener() {
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+
+ ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+ buildPerson());
+ mDataManager.onShortcutAddedOrUpdated(shortcut);
+
+ NotificationListenerService listenerService =
+ mDataManager.getNotificationListenerServiceForTesting(USER_ID_PRIMARY);
+
+ listenerService.onNotificationRemoved(mStatusBarNotification, null,
+ NotificationListenerService.REASON_CLICK);
+
+ List<Range<Long>> activeNotificationOpenTimeSlots = new ArrayList<>();
+ mDataManager.forAllPackages(packageData ->
+ activeNotificationOpenTimeSlots.addAll(
+ packageData.getEventHistory(TEST_SHORTCUT_ID)
+ .getEventIndex(Event.TYPE_NOTIFICATION_OPENED)
+ .getActiveTimeSlots()));
+ assertEquals(1, activeNotificationOpenTimeSlots.size());
+ }
+
+ @Test
+ public void testQueryUsageStatsService() {
+ UsageEvents.Event e = new UsageEvents.Event(SHORTCUT_INVOCATION,
+ System.currentTimeMillis());
+ e.mPackage = TEST_PKG_NAME;
+ e.mShortcutId = TEST_SHORTCUT_ID;
+ List<UsageEvents.Event> events = new ArrayList<>();
+ events.add(e);
+ UsageEvents usageEvents = new UsageEvents(events, new String[]{});
+ when(mUsageStatsManagerInternal.queryEventsForUser(anyInt(), anyLong(), anyLong(),
+ anyBoolean())).thenReturn(usageEvents);
+
+ mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+
+ ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+ buildPerson());
+ mDataManager.onShortcutAddedOrUpdated(shortcut);
+
+ mDataManager.queryUsageStatsService(USER_ID_PRIMARY, 0L, Long.MAX_VALUE);
+
+ List<Range<Long>> activeShortcutInvocationTimeSlots = new ArrayList<>();
+ mDataManager.forAllPackages(packageData ->
+ activeShortcutInvocationTimeSlots.addAll(
+ packageData.getEventHistory(TEST_SHORTCUT_ID)
+ .getEventIndex(Event.TYPE_SHORTCUT_INVOCATION)
+ .getActiveTimeSlots()));
+ assertEquals(1, activeShortcutInvocationTimeSlots.size());
+ }
+
+ private static <T> void addLocalServiceMock(Class<T> clazz, T mock) {
+ LocalServices.removeServiceForTest(clazz);
+ LocalServices.addService(clazz, mock);
+ }
+
+ private ShortcutInfo buildShortcutInfo(String packageName, int userId, String id,
+ @Nullable Person person) {
+ Context mockContext = mock(Context.class);
+ when(mockContext.getPackageName()).thenReturn(packageName);
+ when(mockContext.getUserId()).thenReturn(userId);
+ when(mockContext.getUser()).thenReturn(UserHandle.of(userId));
+ ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mockContext, id)
+ .setShortLabel(id)
+ .setIntent(new Intent("TestIntent"));
+ if (person != null) {
+ builder.setPersons(new Person[] {person});
+ }
+ return builder.build();
+ }
+
+ private Person buildPerson() {
+ return buildPerson(true, false);
+ }
+
+ private Person buildPerson(boolean isImportant, boolean isBot) {
+ return new Person.Builder()
+ .setImportant(isImportant)
+ .setBot(isBot)
+ .setUri(CONTACT_URI)
+ .build();
+ }
+
+ private UserInfo buildUserInfo(int userId) {
+ return new UserInfo(userId, "", 0);
+ }
+
+ private class TestContactsQueryHelper extends ContactsQueryHelper {
+
+ private Uri mContactUri;
+ private boolean mIsStarred;
+ private String mPhoneNumber;
+
+ TestContactsQueryHelper(Context context) {
+ super(context);
+ mContactUri = Uri.parse(CONTACT_URI);
+ mIsStarred = false;
+ mPhoneNumber = PHONE_NUMBER;
+ }
+
+ @Override
+ boolean query(@NonNull String contactUri) {
+ return true;
+ }
+
+ @Override
+ boolean querySince(long sinceTime) {
+ return true;
+ }
+
+ @Override
+ @Nullable
+ Uri getContactUri() {
+ return mContactUri;
+ }
+
+ @Override
+ boolean isStarred() {
+ return mIsStarred;
+ }
+
+ @Override
+ @Nullable
+ String getPhoneNumber() {
+ return mPhoneNumber;
+ }
+ }
+
+ private class TestInjector extends DataManager.Injector {
+
+ private final TestContactsQueryHelper mContactsQueryHelper =
+ new TestContactsQueryHelper(mContext);
+
+ @Override
+ ScheduledExecutorService createScheduledExecutor() {
+ return mExecutorService;
+ }
+
+ @Override
+ ContactsQueryHelper createContactsQueryHelper(Context context) {
+ return mContactsQueryHelper;
+ }
+
+ @Override
+ int getCallingUserId() {
+ return mCallingUserId;
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/EventHistoryImplTest.java b/services/tests/servicestests/src/com/android/server/people/data/EventHistoryImplTest.java
new file mode 100644
index 000000000000..43e1001f2aee
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/EventHistoryImplTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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 com.android.server.people.data.TestUtils.timestamp;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.android.collect.Sets;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class EventHistoryImplTest {
+
+ private static final long CURRENT_TIMESTAMP = timestamp("01-30 18:50");
+
+ private static final Event E1 = new Event(timestamp("01-06 05:26"),
+ Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E2 = new Event(timestamp("01-27 18:41"),
+ Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E3 = new Event(timestamp("01-30 03:06"),
+ Event.TYPE_SHARE_IMAGE);
+ private static final Event E4 = new Event(timestamp("01-30 18:14"),
+ Event.TYPE_SMS_INCOMING);
+
+ private EventHistoryImpl mEventHistory;
+
+ @Before
+ public void setUp() {
+ EventIndex.Injector eventIndexInjector = new EventIndex.Injector() {
+ @Override
+ long currentTimeMillis() {
+ return CURRENT_TIMESTAMP;
+ }
+ };
+ EventHistoryImpl.Injector eventHistoryInjector = new EventHistoryImpl.Injector() {
+ @Override
+ EventIndex createEventIndex() {
+ return new EventIndex(eventIndexInjector);
+ }
+ };
+ mEventHistory = new EventHistoryImpl(eventHistoryInjector);
+ }
+
+ @Test
+ public void testNoEvents() {
+ EventIndex eventIndex = mEventHistory.getEventIndex(Event.ALL_EVENT_TYPES);
+ assertTrue(eventIndex.isEmpty());
+
+ List<Event> events = mEventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, 999L);
+ assertTrue(events.isEmpty());
+ }
+
+ @Test
+ public void testMultipleEvents() {
+ mEventHistory.addEvent(E1);
+ mEventHistory.addEvent(E2);
+ mEventHistory.addEvent(E3);
+ mEventHistory.addEvent(E4);
+
+ EventIndex eventIndex = mEventHistory.getEventIndex(Event.ALL_EVENT_TYPES);
+ assertEquals(4, eventIndex.getActiveTimeSlots().size());
+
+ List<Event> events = mEventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(4, events.size());
+ }
+
+ @Test
+ public void testQuerySomeEventTypes() {
+ mEventHistory.addEvent(E1);
+ mEventHistory.addEvent(E2);
+ mEventHistory.addEvent(E3);
+ mEventHistory.addEvent(E4);
+
+ EventIndex eventIndex = mEventHistory.getEventIndex(Event.NOTIFICATION_EVENT_TYPES);
+ assertEquals(2, eventIndex.getActiveTimeSlots().size());
+
+ List<Event> events = mEventHistory.queryEvents(
+ Event.NOTIFICATION_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(2, events.size());
+ }
+
+ @Test
+ public void testQuerySingleEventType() {
+ mEventHistory.addEvent(E1);
+ mEventHistory.addEvent(E2);
+ mEventHistory.addEvent(E3);
+ mEventHistory.addEvent(E4);
+
+ EventIndex eventIndex = mEventHistory.getEventIndex(Event.TYPE_SHARE_IMAGE);
+ assertEquals(1, eventIndex.getActiveTimeSlots().size());
+
+ List<Event> events = mEventHistory.queryEvents(
+ Sets.newArraySet(Event.TYPE_SHARE_IMAGE), 0L, Long.MAX_VALUE);
+ assertEquals(1, events.size());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/EventIndexTest.java b/services/tests/servicestests/src/com/android/server/people/data/EventIndexTest.java
new file mode 100644
index 000000000000..e87f428d09bb
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/EventIndexTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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 com.android.server.people.data.TestUtils.timestamp;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.util.Range;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class EventIndexTest {
+
+ private static final long CURRENT_TIMESTAMP = timestamp("01-30 18:50");
+ private static final long SECONDS_PER_HOUR = 60L * 60L;
+ private static final long SECONDS_PER_DAY = SECONDS_PER_HOUR * 24L;
+
+ private TestInjector mInjector;
+ private EventIndex mEventIndex;
+
+ @Before
+ public void setUp() {
+ mInjector = new TestInjector(CURRENT_TIMESTAMP);
+ mEventIndex = new EventIndex(mInjector);
+ }
+
+ @Test
+ public void testNoEvents() {
+ assertTrue(mEventIndex.isEmpty());
+ assertNull(mEventIndex.getMostRecentActiveTimeSlot());
+ assertTrue(mEventIndex.getActiveTimeSlots().isEmpty());
+ }
+
+ @Test
+ public void testMultipleEvents() {
+ mEventIndex.addEvent(timestamp("01-06 05:26"));
+ mEventIndex.addEvent(timestamp("01-27 18:41"));
+ mEventIndex.addEvent(timestamp("01-30 03:06"));
+ mEventIndex.addEvent(timestamp("01-30 18:14"));
+
+ assertFalse(mEventIndex.isEmpty());
+ Range<Long> mostRecentSlot = mEventIndex.getMostRecentActiveTimeSlot();
+ assertNotNull(mostRecentSlot);
+ assertTimeSlot(timestamp("01-30 18:14"), timestamp("01-30 18:16"), mostRecentSlot);
+
+ List<Range<Long>> slots = mEventIndex.getActiveTimeSlots();
+ assertEquals(4, slots.size());
+ assertTimeSlot(timestamp("01-06 00:00"), timestamp("01-07 00:00"), slots.get(0));
+ assertTimeSlot(timestamp("01-27 16:00"), timestamp("01-27 20:00"), slots.get(1));
+ assertTimeSlot(timestamp("01-30 03:00"), timestamp("01-30 04:00"), slots.get(2));
+ assertTimeSlot(timestamp("01-30 18:14"), timestamp("01-30 18:16"), slots.get(3));
+ }
+
+ @Test
+ public void testBitmapShift() {
+ mEventIndex.addEvent(CURRENT_TIMESTAMP);
+ List<Range<Long>> slots;
+
+ slots = mEventIndex.getActiveTimeSlots();
+ assertEquals(1, slots.size());
+ assertTimeSlot(timestamp("01-30 18:50"), timestamp("01-30 18:52"), slots.get(0));
+
+ mInjector.moveTimeForwardSeconds(SECONDS_PER_HOUR * 3L);
+ mEventIndex.update();
+ slots = mEventIndex.getActiveTimeSlots();
+ assertEquals(1, slots.size());
+ assertTimeSlot(timestamp("01-30 18:00"), timestamp("01-30 19:00"), slots.get(0));
+
+ mInjector.moveTimeForwardSeconds(SECONDS_PER_DAY * 6L);
+ mEventIndex.update();
+ slots = mEventIndex.getActiveTimeSlots();
+ assertEquals(1, slots.size());
+ assertTimeSlot(timestamp("01-30 16:00"), timestamp("01-30 20:00"), slots.get(0));
+
+ mInjector.moveTimeForwardSeconds(SECONDS_PER_DAY * 30L);
+ mEventIndex.update();
+ slots = mEventIndex.getActiveTimeSlots();
+ assertEquals(1, slots.size());
+ assertTimeSlot(timestamp("01-30 00:00"), timestamp("01-31 00:00"), slots.get(0));
+
+ mInjector.moveTimeForwardSeconds(SECONDS_PER_DAY * 80L);
+ mEventIndex.update();
+ slots = mEventIndex.getActiveTimeSlots();
+ // The event has been shifted off the left end.
+ assertTrue(slots.isEmpty());
+ }
+
+ @Test
+ public void testCopyConstructor() {
+ mEventIndex.addEvent(timestamp("01-06 05:26"));
+ mEventIndex.addEvent(timestamp("01-27 18:41"));
+ mEventIndex.addEvent(timestamp("01-30 03:06"));
+ mEventIndex.addEvent(timestamp("01-30 18:14"));
+
+ List<Range<Long>> slots = mEventIndex.getActiveTimeSlots();
+
+ EventIndex newIndex = new EventIndex(mEventIndex);
+ List<Range<Long>> newSlots = newIndex.getActiveTimeSlots();
+
+ assertEquals(slots.size(), newSlots.size());
+ for (int i = 0; i < slots.size(); i++) {
+ assertEquals(slots.get(i), newSlots.get(i));
+ }
+ }
+
+ @Test
+ public void combineEventIndexes() {
+ EventIndex a = new EventIndex(mInjector);
+ mInjector.mCurrentTimeMillis = timestamp("01-27 18:41");
+ a.addEvent(mInjector.mCurrentTimeMillis);
+ mInjector.mCurrentTimeMillis = timestamp("01-30 03:06");
+ a.addEvent(mInjector.mCurrentTimeMillis);
+
+ mInjector.mCurrentTimeMillis = CURRENT_TIMESTAMP;
+ EventIndex b = new EventIndex(mInjector);
+ b.addEvent(timestamp("01-06 05:26"));
+ b.addEvent(timestamp("01-30 18:14"));
+
+ EventIndex combined = EventIndex.combine(a, b);
+ List<Range<Long>> slots = combined.getActiveTimeSlots();
+ assertEquals(4, slots.size());
+ assertTimeSlot(timestamp("01-06 00:00"), timestamp("01-07 00:00"), slots.get(0));
+ assertTimeSlot(timestamp("01-27 16:00"), timestamp("01-27 20:00"), slots.get(1));
+ assertTimeSlot(timestamp("01-30 03:00"), timestamp("01-30 04:00"), slots.get(2));
+ assertTimeSlot(timestamp("01-30 18:14"), timestamp("01-30 18:16"), slots.get(3));
+ }
+
+ private static void assertTimeSlot(
+ long expectedLower, long expectedUpper, Range<Long> actualSlot) {
+ assertEquals(expectedLower, actualSlot.getLower().longValue());
+ assertEquals(expectedUpper, actualSlot.getUpper().longValue());
+ }
+
+ private class TestInjector extends EventIndex.Injector {
+
+ private long mCurrentTimeMillis;
+
+ TestInjector(long currentTimeMillis) {
+ mCurrentTimeMillis = currentTimeMillis;
+ }
+
+ private void moveTimeForwardSeconds(long seconds) {
+ mCurrentTimeMillis += (seconds * 1000L);
+ }
+
+ @Override
+ long currentTimeMillis() {
+ return mCurrentTimeMillis;
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/EventListTest.java b/services/tests/servicestests/src/com/android/server/people/data/EventListTest.java
new file mode 100644
index 000000000000..f2f372c1b8c3
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/EventListTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.assertTrue;
+
+import com.google.android.collect.Lists;
+import com.google.android.collect.Sets;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class EventListTest {
+
+ private static final Event E1 = new Event(101L, Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E2 = new Event(103L, Event.TYPE_NOTIFICATION_OPENED);
+ private static final Event E3 = new Event(107L, Event.TYPE_SHARE_IMAGE);
+ private static final Event E4 = new Event(109L, Event.TYPE_SMS_INCOMING);
+
+ private EventList mEventList;
+
+ @Before
+ public void setUp() {
+ mEventList = new EventList();
+ }
+
+ @Test
+ public void testQueryEmptyEventList() {
+ List<Event> events = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 0L, 999L);
+ assertTrue(events.isEmpty());
+ }
+
+ @Test
+ public void testAddAndQueryEvents() {
+ List<Event> in = Lists.newArrayList(E1, E2, E3, E4);
+ for (Event e : in) {
+ mEventList.add(e);
+ }
+
+ List<Event> out = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 0L, 999L);
+ assertEventListEquals(in, out);
+ }
+
+ @Test
+ public void testAddEventsNotInOrder() {
+ mEventList.add(E3);
+ mEventList.add(E1);
+ mEventList.add(E4);
+ mEventList.add(E2);
+
+ List<Event> out = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 0L, 999L);
+ List<Event> expected = Lists.newArrayList(E1, E2, E3, E4);
+ assertEventListEquals(expected, out);
+ }
+
+ @Test
+ public void testQueryEventsByType() {
+ mEventList.add(E1);
+ mEventList.add(E2);
+ mEventList.add(E3);
+ mEventList.add(E4);
+
+ List<Event> out = mEventList.queryEvents(
+ Sets.newArraySet(Event.TYPE_NOTIFICATION_OPENED), 0L, 999L);
+ assertEventListEquals(Lists.newArrayList(E1, E2), out);
+ }
+
+ @Test
+ public void testQueryEventsByTimeRange() {
+ mEventList.add(E1);
+ mEventList.add(E2);
+ mEventList.add(E3);
+ mEventList.add(E4);
+
+ List<Event> out = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 103L, 109L);
+ // Only E2 and E3 are in the time range [103L, 109L).
+ assertEventListEquals(Lists.newArrayList(E2, E3), out);
+ }
+
+ @Test
+ public void testQueryEventsOutOfRange() {
+ mEventList.add(E1);
+ mEventList.add(E2);
+ mEventList.add(E3);
+ mEventList.add(E4);
+
+ List<Event> out = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 900L, 900L);
+ assertTrue(out.isEmpty());
+ }
+
+ @Test
+ public void testAddDuplicateEvents() {
+ mEventList.add(E1);
+ mEventList.add(E2);
+ mEventList.add(E2);
+ mEventList.add(E3);
+ mEventList.add(E2);
+ mEventList.add(E3);
+ mEventList.add(E3);
+ mEventList.add(E4);
+ mEventList.add(E1);
+ mEventList.add(E3);
+ mEventList.add(E2);
+
+ List<Event> out = mEventList.queryEvents(Event.ALL_EVENT_TYPES, 0L, 999L);
+ List<Event> expected = Lists.newArrayList(E1, E2, E3, E4);
+ assertEventListEquals(expected, out);
+ }
+
+ private static void assertEventListEquals(List<Event> expected, List<Event> actual) {
+ assertEquals(expected.size(), actual.size());
+ for (int i = 0; i < expected.size(); i++) {
+ assertEquals(expected.get(i).getTimestamp(), actual.get(i).getTimestamp());
+ assertEquals(expected.get(i).getType(), actual.get(i).getType());
+ }
+ }
+}
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
new file mode 100644
index 000000000000..1b80d6fc3a2d
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/PackageDataTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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 android.content.LocusId;
+import android.content.pm.ShortcutInfo;
+import android.net.Uri;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class PackageDataTest {
+
+ private static final String PACKAGE_NAME = "com.google.test";
+ private static final int USER_ID = 0;
+ private static final String SHORTCUT_ID = "abc";
+ private static final LocusId LOCUS_ID = new LocusId("def");
+ private static final Uri CONTACT_URI = Uri.parse("tel:+1234567890");
+ private static final String PHONE_NUMBER = "+1234567890";
+
+ private Event mE1;
+ private Event mE2;
+ private Event mE3;
+ private Event mE4;
+
+ private PackageData mPackageData;
+
+ @Before
+ public void setUp() {
+ mPackageData = new PackageData(PACKAGE_NAME, USER_ID);
+ ConversationInfo conversationInfo = new ConversationInfo.Builder()
+ .setShortcutId(SHORTCUT_ID)
+ .setLocusId(LOCUS_ID)
+ .setContactUri(CONTACT_URI)
+ .setContactPhoneNumber(PHONE_NUMBER)
+ .setShortcutFlags(ShortcutInfo.FLAG_LONG_LIVED)
+ .build();
+ mPackageData.getConversationStore().addOrUpdate(conversationInfo);
+
+ long currentTimestamp = System.currentTimeMillis();
+ mE1 = new Event(currentTimestamp - 800L, Event.TYPE_SHORTCUT_INVOCATION);
+ mE2 = new Event(currentTimestamp - 700L, Event.TYPE_NOTIFICATION_OPENED);
+ mE3 = new Event(currentTimestamp - 600L, Event.TYPE_CALL_INCOMING);
+ mE4 = new Event(currentTimestamp - 500L, Event.TYPE_SMS_OUTGOING);
+ }
+
+ @Test
+ public void testGetEventHistory() {
+ EventStore eventStore = mPackageData.getEventStore();
+ eventStore.getOrCreateShortcutEventHistory(SHORTCUT_ID).addEvent(mE1);
+ eventStore.getOrCreateLocusEventHistory(LOCUS_ID).addEvent(mE2);
+
+ EventHistory eventHistory = mPackageData.getEventHistory(SHORTCUT_ID);
+ List<Event> events = eventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(2, events.size());
+ assertEventEquals(mE1, events.get(0));
+ assertEventEquals(mE2, events.get(1));
+ }
+
+ @Test
+ public void testGetEventHistoryDefaultDialerAndSmsApp() {
+ mPackageData.setIsDefaultDialer(true);
+ mPackageData.setIsDefaultSmsApp(true);
+ EventStore eventStore = mPackageData.getEventStore();
+ eventStore.getOrCreateShortcutEventHistory(SHORTCUT_ID).addEvent(mE1);
+ eventStore.getOrCreateCallEventHistory(PHONE_NUMBER).addEvent(mE3);
+ eventStore.getOrCreateSmsEventHistory(PHONE_NUMBER).addEvent(mE4);
+
+ assertTrue(mPackageData.isDefaultDialer());
+ assertTrue(mPackageData.isDefaultSmsApp());
+ EventHistory eventHistory = mPackageData.getEventHistory(SHORTCUT_ID);
+ List<Event> events = eventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(3, events.size());
+ assertEventEquals(mE1, events.get(0));
+ assertEventEquals(mE3, events.get(1));
+ assertEventEquals(mE4, events.get(2));
+ }
+
+ @Test
+ public void testGetEventHistoryNotDefaultDialerOrSmsApp() {
+ EventStore eventStore = mPackageData.getEventStore();
+ eventStore.getOrCreateShortcutEventHistory(SHORTCUT_ID).addEvent(mE1);
+ eventStore.getOrCreateCallEventHistory(PHONE_NUMBER).addEvent(mE3);
+ eventStore.getOrCreateSmsEventHistory(PHONE_NUMBER).addEvent(mE4);
+
+ assertFalse(mPackageData.isDefaultDialer());
+ assertFalse(mPackageData.isDefaultSmsApp());
+ EventHistory eventHistory = mPackageData.getEventHistory(SHORTCUT_ID);
+ List<Event> events = eventHistory.queryEvents(Event.ALL_EVENT_TYPES, 0L, Long.MAX_VALUE);
+ assertEquals(1, events.size());
+ assertEventEquals(mE1, events.get(0));
+ }
+
+ private void assertEventEquals(Event expected, Event actual) {
+ assertEquals(expected.getTimestamp(), actual.getTimestamp());
+ assertEquals(expected.getType(), actual.getType());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/people/data/TestUtils.java b/services/tests/servicestests/src/com/android/server/people/data/TestUtils.java
new file mode 100644
index 000000000000..41889aa5b224
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/people/data/TestUtils.java
@@ -0,0 +1,42 @@
+/*
+ * 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 java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+final class TestUtils {
+
+ /**
+ * Gets the epoch time in millis for the specified time string.
+ * @param timeString e.g. "01-02 15:20"
+ * @return epoch time in millis
+ */
+ static long timestamp(String timeString) {
+ String str = String.format("2020-%s", timeString);
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+ return toEpochMilli(LocalDateTime.parse(str, formatter));
+ }
+
+ private static long toEpochMilli(LocalDateTime localDateTime) {
+ return localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ }
+
+ private TestUtils() {
+ }
+}