diff options
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() { + } +} |