From 071f0a46fce819bb35925ba2e50acba82b89cb62 Mon Sep 17 00:00:00 2001 From: Julia Reynolds Date: Thu, 7 Jan 2021 10:08:45 -0500 Subject: [PATCH] Add a status API for conversations Status are transient pices of information about the current activities or availability of someone in a conversation. They may be shown in places where conversations are shown. Test: atest, cts Bug: 163617224 Change-Id: I4b61bc3b7f338e9c8cae2c6142622a7040547ddb --- core/api/current.txt | 47 ++++ .../android/app/SystemServiceRegistry.java | 8 + .../app/people/ConversationStatus.aidl | 19 ++ .../app/people/ConversationStatus.java | 240 ++++++++++++++++++ .../android/app/people/IPeopleManager.aidl | 6 + .../android/app/people/PeopleManager.java | 138 ++++++++++ core/java/android/content/Context.java | 5 +- .../android/server/people/PeopleService.java | 62 +++++ .../server/people/data/ConversationInfo.java | 43 +++- .../server/people/data/DataManager.java | 69 +++++ .../people/data/ConversationInfoTest.java | 22 ++ .../server/people/data/DataManagerTest.java | 82 ++++++ 12 files changed, 737 insertions(+), 4 deletions(-) create mode 100644 core/java/android/app/people/ConversationStatus.aidl create mode 100644 core/java/android/app/people/ConversationStatus.java create mode 100644 core/java/android/app/people/PeopleManager.java diff --git a/core/api/current.txt b/core/api/current.txt index 4ad5e49bf6c0f..b1d4916355aaf 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -7826,6 +7826,52 @@ package android.app.job { } +package android.app.people { + + public final class ConversationStatus implements android.os.Parcelable { + method public int describeContents(); + method public int getActivity(); + method public int getAvailability(); + method @Nullable public CharSequence getDescription(); + method public long getEndTimeMillis(); + method @Nullable public android.graphics.drawable.Icon getIcon(); + method @NonNull public String getId(); + method public long getStartTimeMillis(); + method public void writeToParcel(@NonNull android.os.Parcel, int); + field public static final int ACTIVITY_ANNIVERSARY = 2; // 0x2 + field public static final int ACTIVITY_BIRTHDAY = 1; // 0x1 + field public static final int ACTIVITY_GAME = 5; // 0x5 + field public static final int ACTIVITY_LOCATION = 6; // 0x6 + field public static final int ACTIVITY_MEDIA = 4; // 0x4 + field public static final int ACTIVITY_NEW_STORY = 3; // 0x3 + field public static final int ACTIVITY_OTHER = 0; // 0x0 + field public static final int ACTIVITY_UPCOMING_BIRTHDAY = 7; // 0x7 + field public static final int AVAILABILITY_AVAILABLE = 0; // 0x0 + field public static final int AVAILABILITY_BUSY = 1; // 0x1 + field public static final int AVAILABILITY_OFFLINE = 2; // 0x2 + field public static final int AVAILABILITY_UNKNOWN = -1; // 0xffffffff + field @NonNull public static final android.os.Parcelable.Creator CREATOR; + } + + public static final class ConversationStatus.Builder { + ctor public ConversationStatus.Builder(@NonNull String, @NonNull int); + method @NonNull public android.app.people.ConversationStatus build(); + method @NonNull public android.app.people.ConversationStatus.Builder setAvailability(int); + method @NonNull public android.app.people.ConversationStatus.Builder setDescription(@Nullable CharSequence); + method @NonNull public android.app.people.ConversationStatus.Builder setEndTimeMillis(long); + method @NonNull public android.app.people.ConversationStatus.Builder setIcon(@Nullable android.graphics.drawable.Icon); + method @NonNull public android.app.people.ConversationStatus.Builder setStartTimeMillis(long); + } + + public final class PeopleManager { + method public void addOrUpdateStatus(@NonNull String, @NonNull android.app.people.ConversationStatus); + method public void clearStatus(@NonNull String, @NonNull String); + method public void clearStatuses(@NonNull String); + method @NonNull public java.util.List getStatuses(@NonNull String); + } + +} + package android.app.role { public final class RoleManager { @@ -10315,6 +10361,7 @@ package android.content { field public static final String NFC_SERVICE = "nfc"; field public static final String NOTIFICATION_SERVICE = "notification"; field public static final String NSD_SERVICE = "servicediscovery"; + field public static final String PEOPLE_SERVICE = "people"; field public static final String POWER_SERVICE = "power"; field public static final String PRINT_SERVICE = "print"; field public static final int RECEIVER_VISIBLE_TO_INSTANT_APPS = 1; // 0x1 diff --git a/core/java/android/app/SystemServiceRegistry.java b/core/java/android/app/SystemServiceRegistry.java index ae1c89468b183..050d194c40944 100644 --- a/core/java/android/app/SystemServiceRegistry.java +++ b/core/java/android/app/SystemServiceRegistry.java @@ -29,6 +29,7 @@ import android.app.blob.BlobStoreManagerFrameworkInitializer; import android.app.contentsuggestions.ContentSuggestionsManager; import android.app.contentsuggestions.IContentSuggestionsManager; import android.app.job.JobSchedulerFrameworkInitializer; +import android.app.people.PeopleManager; import android.app.prediction.AppPredictionManager; import android.app.role.RoleControllerManager; import android.app.role.RoleManager; @@ -586,6 +587,13 @@ public final class SystemServiceRegistry { return new NsdManager(ctx.getOuterContext(), service); }}); + registerService(Context.PEOPLE_SERVICE, PeopleManager.class, + new CachedServiceFetcher() { + @Override + public PeopleManager createService(ContextImpl ctx) throws ServiceNotFoundException { + return new PeopleManager(ctx); + }}); + registerService(Context.POWER_SERVICE, PowerManager.class, new CachedServiceFetcher() { @Override diff --git a/core/java/android/app/people/ConversationStatus.aidl b/core/java/android/app/people/ConversationStatus.aidl new file mode 100644 index 0000000000000..acfe1356304c9 --- /dev/null +++ b/core/java/android/app/people/ConversationStatus.aidl @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2021, 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 android.app.people; + +parcelable ConversationStatus; \ No newline at end of file diff --git a/core/java/android/app/people/ConversationStatus.java b/core/java/android/app/people/ConversationStatus.java new file mode 100644 index 0000000000000..d2a0255d572ed --- /dev/null +++ b/core/java/android/app/people/ConversationStatus.java @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2021 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 android.app.people; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.drawable.Icon; +import android.os.Parcel; +import android.os.Parcelable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Objects; + +public final class ConversationStatus implements Parcelable { + private static final String TAG = "ConversationStatus"; + + /** @hide */ + @IntDef(prefix = { "ACTIVITY_" }, value = { + ACTIVITY_OTHER, + ACTIVITY_BIRTHDAY, + ACTIVITY_ANNIVERSARY, + ACTIVITY_NEW_STORY, + ACTIVITY_MEDIA, + ACTIVITY_GAME, + ACTIVITY_LOCATION, + ACTIVITY_UPCOMING_BIRTHDAY + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ActivityType {} + + public static final int ACTIVITY_OTHER = 0; + public static final int ACTIVITY_BIRTHDAY = 1; + public static final int ACTIVITY_ANNIVERSARY = 2; + public static final int ACTIVITY_NEW_STORY = 3; + public static final int ACTIVITY_MEDIA = 4; + public static final int ACTIVITY_GAME = 5; + public static final int ACTIVITY_LOCATION = 6; + public static final int ACTIVITY_UPCOMING_BIRTHDAY = 7; + + /** @hide */ + @IntDef(prefix = { "AVAILABILITY_" }, value = { + AVAILABILITY_UNKNOWN, + AVAILABILITY_AVAILABLE, + AVAILABILITY_BUSY, + AVAILABILITY_OFFLINE + }) + @Retention(RetentionPolicy.SOURCE) + public @interface Availability {} + + public static final int AVAILABILITY_UNKNOWN = -1; + public static final int AVAILABILITY_AVAILABLE = 0; + public static final int AVAILABILITY_BUSY = 1; + public static final int AVAILABILITY_OFFLINE = 2; + + private final String mId; + private final int mActivity; + + private int mAvailability; + private CharSequence mDescription; + private Icon mIcon; + private long mStartTimeMs; + private long mEndTimeMs; + + private ConversationStatus(Builder b) { + mId = b.mId; + mActivity = b.mActivity; + mAvailability = b.mAvailability; + mDescription = b.mDescription; + mIcon = b.mIcon; + mStartTimeMs = b.mStartTimeMs; + mEndTimeMs = b.mEndTimeMs; + } + + private ConversationStatus(Parcel p) { + mId = p.readString(); + mActivity = p.readInt(); + mAvailability = p.readInt(); + mDescription = p.readCharSequence(); + mIcon = p.readParcelable(Icon.class.getClassLoader()); + mStartTimeMs = p.readLong(); + mEndTimeMs = p.readLong(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(mId); + dest.writeInt(mActivity); + dest.writeInt(mAvailability); + dest.writeCharSequence(mDescription); + dest.writeParcelable(mIcon, flags); + dest.writeLong(mStartTimeMs); + dest.writeLong(mEndTimeMs); + } + + public @NonNull String getId() { + return mId; + } + + public @ActivityType int getActivity() { + return mActivity; + } + + public @Availability + int getAvailability() { + return mAvailability; + } + + public @Nullable + CharSequence getDescription() { + return mDescription; + } + + public @Nullable Icon getIcon() { + return mIcon; + } + + public long getStartTimeMillis() { + return mStartTimeMs; + } + + public long getEndTimeMillis() { + return mEndTimeMs; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConversationStatus that = (ConversationStatus) o; + return mActivity == that.mActivity && + mAvailability == that.mAvailability && + mStartTimeMs == that.mStartTimeMs && + mEndTimeMs == that.mEndTimeMs && + mId.equals(that.mId) && + Objects.equals(mDescription, that.mDescription) && + Objects.equals(mIcon, that.mIcon); + } + + @Override + public int hashCode() { + return Objects.hash(mId, mActivity, mAvailability, mDescription, mIcon, mStartTimeMs, + mEndTimeMs); + } + + @Override + public String toString() { + return "ConversationStatus{" + + "mId='" + mId + '\'' + + ", mActivity=" + mActivity + + ", mAvailability=" + mAvailability + + ", mDescription=" + mDescription + + ", mIcon=" + mIcon + + ", mStartTimeMs=" + mStartTimeMs + + ", mEndTimeMs=" + mEndTimeMs + + '}'; + } + + @Override + public int describeContents() { + return 0; + } + + public static final @NonNull Creator CREATOR + = new Creator() { + public ConversationStatus createFromParcel(Parcel source) { + return new ConversationStatus(source); + } + + public ConversationStatus[] newArray(int size) { + return new ConversationStatus[size]; + } + }; + + public static final class Builder { + final String mId; + final int mActivity; + int mAvailability = AVAILABILITY_UNKNOWN; + CharSequence mDescription; + Icon mIcon; + long mStartTimeMs = -1; + long mEndTimeMs = -1; + + /** + * Creates a new builder. + * + * @param id The unique id for this status + * @param activity The type of status + */ + public Builder(@NonNull String id, @ActivityType @NonNull int activity) { + mId = id; + mActivity = activity; + } + + + public @NonNull Builder setAvailability(@Availability int availability) { + mAvailability = availability; + return this; + } + + public @NonNull Builder setDescription(@Nullable CharSequence description) { + mDescription = description; + return this; + } + + public @NonNull Builder setIcon(@Nullable Icon icon) { + mIcon = icon; + return this; + } + + public @NonNull Builder setStartTimeMillis(long startTimeMs) { + mStartTimeMs = startTimeMs; + return this; + } + + public @NonNull Builder setEndTimeMillis(long endTimeMs) { + mEndTimeMs = endTimeMs; + return this; + } + + public @NonNull ConversationStatus build() { + return new ConversationStatus(this); + } + } +} diff --git a/core/java/android/app/people/IPeopleManager.aidl b/core/java/android/app/people/IPeopleManager.aidl index c547ef1e7e9b5..0d12ed02f6103 100644 --- a/core/java/android/app/people/IPeopleManager.aidl +++ b/core/java/android/app/people/IPeopleManager.aidl @@ -16,6 +16,7 @@ package android.app.people; +import android.app.people.ConversationStatus; import android.content.pm.ParceledListSlice; import android.net.Uri; import android.os.IBinder; @@ -45,4 +46,9 @@ interface IPeopleManager { * conversation can't be found or no interactions have been recorded, returns 0L. */ long getLastInteraction(in String packageName, int userId, in String shortcutId); + + void addOrUpdateStatus(in String packageName, int userId, in String conversationId, in ConversationStatus status); + void clearStatus(in String packageName, int userId, in String conversationId, in String statusId); + void clearStatuses(in String packageName, int userId, in String conversationId); + ParceledListSlice getStatuses(in String packageName, int userId, in String conversationId); } diff --git a/core/java/android/app/people/PeopleManager.java b/core/java/android/app/people/PeopleManager.java new file mode 100644 index 0000000000000..de7ba622ae88b --- /dev/null +++ b/core/java/android/app/people/PeopleManager.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2021 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 android.app.people; + +import android.annotation.NonNull; +import android.annotation.SystemService; +import android.content.Context; +import android.content.pm.ParceledListSlice; +import android.content.pm.ShortcutInfo; +import android.os.RemoteException; +import android.os.ServiceManager; + +import com.android.internal.util.Preconditions; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * This class allows interaction with conversation and people data. + */ +@SystemService(Context.PEOPLE_SERVICE) +public final class PeopleManager { + + private static final String LOG_TAG = PeopleManager.class.getSimpleName(); + + @NonNull + private final Context mContext; + + @NonNull + private final IPeopleManager mService; + + /** + * @hide + */ + public PeopleManager(@NonNull Context context) throws ServiceManager.ServiceNotFoundException { + mContext = context; + mService = IPeopleManager.Stub.asInterface(ServiceManager.getServiceOrThrow( + Context.PEOPLE_SERVICE)); + } + + + /** + * Sets or updates a {@link ConversationStatus} for a conversation. + * + *

Statuses are meant to represent current information about the conversation. Like + * notifications, they are transient and are not persisted beyond a reboot, nor are they + * backed up and restored.

+ *

If the provided conversation shortcut is not already pinned, or cached by the system, + * it will remain cached as long as the status is active.

+ * + * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the + * conversation that has an active status + * @param status the current status for the given conversation + * + * @return whether the role is available in the system + */ + public void addOrUpdateStatus(@NonNull String conversationId, + @NonNull ConversationStatus status) { + Preconditions.checkStringNotEmpty(conversationId); + Objects.requireNonNull(status); + try { + mService.addOrUpdateStatus( + mContext.getPackageName(), mContext.getUserId(), conversationId, status); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Unpublishes a given status from the given conversation. + * + * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the + * conversation that has an active status + * @param statusId the {@link ConversationStatus#getId() id} of a published status for the given + * conversation + */ + public void clearStatus(@NonNull String conversationId, @NonNull String statusId) { + Preconditions.checkStringNotEmpty(conversationId); + Preconditions.checkStringNotEmpty(statusId); + try { + mService.clearStatus( + mContext.getPackageName(), mContext.getUserId(), conversationId, statusId); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Removes all published statuses for the given conversation. + * + * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the + * conversation that has one or more active statuses + */ + public void clearStatuses(@NonNull String conversationId) { + Preconditions.checkStringNotEmpty(conversationId); + try { + mService.clearStatuses( + mContext.getPackageName(), mContext.getUserId(), conversationId); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Returns all of the currently published statuses for a given conversation. + * + * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the + * conversation that has one or more active statuses + */ + public @NonNull List getStatuses(@NonNull String conversationId) { + try { + final ParceledListSlice parceledList + = mService.getStatuses( + mContext.getPackageName(), mContext.getUserId(), conversationId); + if (parceledList != null) { + return parceledList.getList(); + } + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + return new ArrayList<>(); + } +} diff --git a/core/java/android/content/Context.java b/core/java/android/content/Context.java index 29ffa0b70313a..43011fce21257 100644 --- a/core/java/android/content/Context.java +++ b/core/java/android/content/Context.java @@ -40,6 +40,7 @@ import android.app.ActivityManager; import android.app.IApplicationThread; import android.app.IServiceConnection; import android.app.VrManager; +import android.app.people.PeopleManager; import android.app.time.TimeManager; import android.compat.annotation.UnsupportedAppUsage; import android.content.pm.ApplicationInfo; @@ -5311,10 +5312,10 @@ public abstract class Context { public static final String SMS_SERVICE = "sms"; /** - * Use with {@link #getSystemService(String)} to access people service. + * Use with {@link #getSystemService(String)} to access a {@link PeopleManager} to interact + * with your published conversations. * * @see #getSystemService(String) - * @hide */ public static final String PEOPLE_SERVICE = "people"; diff --git a/services/people/java/com/android/server/people/PeopleService.java b/services/people/java/com/android/server/people/PeopleService.java index 49a41f02c4841..16b9165915e29 100644 --- a/services/people/java/com/android/server/people/PeopleService.java +++ b/services/people/java/com/android/server/people/PeopleService.java @@ -19,7 +19,9 @@ package com.android.server.people; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UserIdInt; +import android.app.ActivityManager; import android.app.people.ConversationChannel; +import android.app.people.ConversationStatus; import android.app.people.IPeopleManager; import android.app.prediction.AppPredictionContext; import android.app.prediction.AppPredictionSessionId; @@ -27,6 +29,7 @@ import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.IPredictionCallback; import android.content.Context; +import android.content.pm.PackageManagerInternal; import android.content.pm.ParceledListSlice; import android.os.Binder; import android.os.CancellationSignal; @@ -38,9 +41,11 @@ import android.util.ArrayMap; import android.util.Slog; import com.android.internal.annotations.VisibleForTesting; +import com.android.server.LocalServices; import com.android.server.SystemService; import com.android.server.people.data.DataManager; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -54,6 +59,8 @@ public class PeopleService extends SystemService { private final DataManager mDataManager; + private PackageManagerInternal mPackageManagerInternal; + /** * Initializes the system service. * @@ -83,6 +90,7 @@ public class PeopleService extends SystemService { publishBinderService(Context.PEOPLE_SERVICE, mService); } publishLocalService(PeopleServiceInternal.class, new LocalService()); + mPackageManagerInternal = LocalServices.getService(PackageManagerInternal.class); } @Override @@ -112,6 +120,26 @@ public class PeopleService extends SystemService { return UserHandle.isSameApp(uid, Process.SYSTEM_UID) || uid == Process.ROOT_UID; } + private int handleIncomingUser(int userId) { + try { + return ActivityManager.getService().handleIncomingUser( + Binder.getCallingPid(), Binder.getCallingUid(), userId, true, true, "", null); + } catch (RemoteException re) { + // Shouldn't happen, local. + } + return userId; + } + + private void checkCallerIsSameApp(String pkg) { + final int callingUid = Binder.getCallingUid(); + final int callingUserId = UserHandle.getUserId(callingUid); + + if (mPackageManagerInternal.getPackageUid(pkg, /*flags=*/ 0, + callingUserId) != callingUid) { + throw new SecurityException("Calling uid " + callingUid + " cannot query events" + + "for package " + pkg); + } + } /** * Enforces that only the system, root UID or SystemUI can make certain calls. @@ -154,6 +182,40 @@ public class PeopleService extends SystemService { enforceSystemRootOrSystemUI(getContext(), "get last interaction"); return mDataManager.getLastInteraction(packageName, userId, shortcutId); } + + @Override + public void addOrUpdateStatus(String packageName, int userId, String conversationId, + ConversationStatus status) { + handleIncomingUser(userId); + checkCallerIsSameApp(packageName); + mDataManager.addOrUpdateStatus(packageName, userId, conversationId, status); + } + + @Override + public void clearStatus(String packageName, int userId, String conversationId, + String statusId) { + handleIncomingUser(userId); + checkCallerIsSameApp(packageName); + mDataManager.clearStatus(packageName, userId, conversationId, statusId); + } + + @Override + public void clearStatuses(String packageName, int userId, String conversationId) { + handleIncomingUser(userId); + checkCallerIsSameApp(packageName); + mDataManager.clearStatuses(packageName, userId, conversationId); + } + + @Override + public ParceledListSlice getStatuses(String packageName, int userId, + String conversationId) { + handleIncomingUser(userId); + if (!isSystemOrRoot()) { + checkCallerIsSameApp(packageName); + } + return new ParceledListSlice<>( + mDataManager.getStatuses(packageName, userId, conversationId)); + } }; @VisibleForTesting diff --git a/services/people/java/com/android/server/people/data/ConversationInfo.java b/services/people/java/com/android/server/people/data/ConversationInfo.java index 45f389cbd3ff3..16c4c29a798e5 100644 --- a/services/people/java/com/android/server/people/data/ConversationInfo.java +++ b/services/people/java/com/android/server/people/data/ConversationInfo.java @@ -19,6 +19,7 @@ package com.android.server.people.data; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.people.ConversationStatus; import android.content.LocusId; import android.content.LocusIdProto; import android.content.pm.ShortcutInfo; @@ -39,6 +40,10 @@ import java.io.DataOutputStream; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -101,6 +106,8 @@ public class ConversationInfo { @ConversationFlags private int mConversationFlags; + private Map mCurrStatuses; + private ConversationInfo(Builder builder) { mShortcutId = builder.mShortcutId; mLocusId = builder.mLocusId; @@ -111,6 +118,7 @@ public class ConversationInfo { mLastEventTimestamp = builder.mLastEventTimestamp; mShortcutFlags = builder.mShortcutFlags; mConversationFlags = builder.mConversationFlags; + mCurrStatuses = builder.mCurrStatuses; } @NonNull @@ -213,6 +221,10 @@ public class ConversationInfo { return hasConversationFlags(FLAG_CONTACT_STARRED); } + public Collection getStatuses() { + return mCurrStatuses.values(); + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -230,14 +242,15 @@ public class ConversationInfo { && Objects.equals(mParentNotificationChannelId, other.mParentNotificationChannelId) && Objects.equals(mLastEventTimestamp, other.mLastEventTimestamp) && mShortcutFlags == other.mShortcutFlags - && mConversationFlags == other.mConversationFlags; + && mConversationFlags == other.mConversationFlags + && Objects.equals(mCurrStatuses, other.mCurrStatuses); } @Override public int hashCode() { return Objects.hash(mShortcutId, mLocusId, mContactUri, mContactPhoneNumber, mNotificationChannelId, mParentNotificationChannelId, mLastEventTimestamp, - mShortcutFlags, mConversationFlags); + mShortcutFlags, mConversationFlags, mCurrStatuses); } @Override @@ -251,6 +264,7 @@ public class ConversationInfo { sb.append(", notificationChannelId=").append(mNotificationChannelId); sb.append(", parentNotificationChannelId=").append(mParentNotificationChannelId); sb.append(", lastEventTimestamp=").append(mLastEventTimestamp); + sb.append(", statuses=").append(mCurrStatuses); sb.append(", shortcutFlags=0x").append(Integer.toHexString(mShortcutFlags)); sb.append(" ["); if (isShortcutLongLived()) { @@ -321,6 +335,7 @@ public class ConversationInfo { protoOutputStream.write(ConversationInfoProto.CONTACT_PHONE_NUMBER, mContactPhoneNumber); } + // ConversationStatus is a transient object and not persisted } @Nullable @@ -337,6 +352,7 @@ public class ConversationInfo { out.writeUTF(mContactPhoneNumber != null ? mContactPhoneNumber : ""); out.writeUTF(mParentNotificationChannelId != null ? mParentNotificationChannelId : ""); out.writeLong(mLastEventTimestamp); + // ConversationStatus is a transient object and not persisted } catch (IOException e) { Slog.e(TAG, "Failed to write fields to backup payload.", e); return null; @@ -469,6 +485,8 @@ public class ConversationInfo { @ConversationFlags private int mConversationFlags; + private Map mCurrStatuses = new HashMap<>(); + Builder() { } @@ -486,6 +504,7 @@ public class ConversationInfo { mLastEventTimestamp = conversationInfo.mLastEventTimestamp; mShortcutFlags = conversationInfo.mShortcutFlags; mConversationFlags = conversationInfo.mConversationFlags; + mCurrStatuses = conversationInfo.mCurrStatuses; } Builder setShortcutId(@NonNull String shortcutId) { @@ -579,6 +598,26 @@ public class ConversationInfo { return this; } + Builder setStatuses(List statuses) { + mCurrStatuses.clear(); + if (statuses != null) { + for (ConversationStatus status : statuses) { + mCurrStatuses.put(status.getId(), status); + } + } + return this; + } + + Builder addOrUpdateStatus(ConversationStatus status) { + mCurrStatuses.put(status.getId(), status); + return this; + } + + Builder clearStatus(String statusId) { + mCurrStatuses.remove(statusId); + return this; + } + ConversationInfo build() { Objects.requireNonNull(mShortcutId); return new ConversationInfo(this); diff --git a/services/people/java/com/android/server/people/data/DataManager.java b/services/people/java/com/android/server/people/data/DataManager.java index b5e595a42ca99..e04e287d80f63 100644 --- a/services/people/java/com/android/server/people/data/DataManager.java +++ b/services/people/java/com/android/server/people/data/DataManager.java @@ -26,6 +26,7 @@ import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.Person; import android.app.people.ConversationChannel; +import android.app.people.ConversationStatus; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.usage.UsageEvents; @@ -75,6 +76,7 @@ import com.android.server.notification.NotificationManagerInternal; import com.android.server.notification.ShortcutHelper; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; @@ -308,6 +310,73 @@ public class DataManager { return 0L; } + public void addOrUpdateStatus(String packageName, int userId, String conversationId, + ConversationStatus status) { + ConversationStore cs = getConversationStoreOrThrow(packageName, userId); + ConversationInfo convToModify = getConversationInfoOrThrow(cs, conversationId); + ConversationInfo.Builder builder = new ConversationInfo.Builder(convToModify); + builder.addOrUpdateStatus(status); + cs.addOrUpdate(builder.build()); + } + + public void clearStatus(String packageName, int userId, String conversationId, + String statusId) { + ConversationStore cs = getConversationStoreOrThrow(packageName, userId); + ConversationInfo convToModify = getConversationInfoOrThrow(cs, conversationId); + ConversationInfo.Builder builder = new ConversationInfo.Builder(convToModify); + builder.clearStatus(statusId); + cs.addOrUpdate(builder.build()); + } + + public void clearStatuses(String packageName, int userId, String conversationId) { + ConversationStore cs = getConversationStoreOrThrow(packageName, userId); + ConversationInfo convToModify = getConversationInfoOrThrow(cs, conversationId); + ConversationInfo.Builder builder = new ConversationInfo.Builder(convToModify); + builder.setStatuses(null); + cs.addOrUpdate(builder.build()); + } + + public @NonNull List getStatuses(String packageName, int userId, + String conversationId) { + ConversationStore cs = getConversationStoreOrThrow(packageName, userId); + ConversationInfo conversationInfo = getConversationInfoOrThrow(cs, conversationId); + Collection statuses = conversationInfo.getStatuses(); + if (statuses != null) { + final ArrayList list = new ArrayList<>(statuses.size()); + list.addAll(statuses); + return list; + } + return new ArrayList<>(); + } + + /** + * Returns a conversation store for a package, if it exists. + */ + private @NonNull ConversationStore getConversationStoreOrThrow(String packageName, int userId) { + final PackageData packageData = getPackage(packageName, userId); + if (packageData == null) { + throw new IllegalArgumentException("No settings exist for package " + packageName); + } + ConversationStore cs = packageData.getConversationStore(); + if (cs == null) { + throw new IllegalArgumentException("No conversations exist for package " + packageName); + } + return cs; + } + + /** + * Returns a conversation store for a package, if it exists. + */ + private @NonNull ConversationInfo getConversationInfoOrThrow(ConversationStore cs, + String conversationId) { + ConversationInfo ci = cs.getConversation(conversationId); + + if (ci == null) { + throw new IllegalArgumentException("Conversation does not exist"); + } + return ci; + } + /** Reports the sharing related {@link AppTargetEvent} from App Prediction Manager. */ public void reportShareTargetEvent(@NonNull AppTargetEvent event, @NonNull IntentFilter intentFilter) { 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 index c6823ebfd6554..8139310a4c3d7 100644 --- a/services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java +++ b/services/tests/servicestests/src/com/android/server/people/data/ConversationInfoTest.java @@ -16,11 +16,17 @@ package com.android.server.people.data; +import static android.app.people.ConversationStatus.ACTIVITY_ANNIVERSARY; +import static android.app.people.ConversationStatus.ACTIVITY_GAME; + +import static com.google.common.truth.Truth.assertThat; + 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.app.people.ConversationStatus; import android.content.LocusId; import android.content.pm.ShortcutInfo; import android.net.Uri; @@ -41,6 +47,9 @@ public final class ConversationInfoTest { @Test public void testBuild() { + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + ConversationStatus cs2 = new ConversationStatus.Builder("id2", ACTIVITY_GAME).build(); + ConversationInfo conversationInfo = new ConversationInfo.Builder() .setShortcutId(SHORTCUT_ID) .setLocusId(LOCUS_ID) @@ -58,6 +67,8 @@ public final class ConversationInfoTest { .setPersonImportant(true) .setPersonBot(true) .setContactStarred(true) + .addOrUpdateStatus(cs) + .addOrUpdateStatus(cs2) .build(); assertEquals(SHORTCUT_ID, conversationInfo.getShortcutId()); @@ -77,6 +88,8 @@ public final class ConversationInfoTest { assertTrue(conversationInfo.isPersonImportant()); assertTrue(conversationInfo.isPersonBot()); assertTrue(conversationInfo.isContactStarred()); + assertThat(conversationInfo.getStatuses()).contains(cs); + assertThat(conversationInfo.getStatuses()).contains(cs2); } @Test @@ -101,10 +114,15 @@ public final class ConversationInfoTest { assertFalse(conversationInfo.isPersonImportant()); assertFalse(conversationInfo.isPersonBot()); assertFalse(conversationInfo.isContactStarred()); + assertThat(conversationInfo.getStatuses()).isNotNull(); + assertThat(conversationInfo.getStatuses()).isEmpty(); } @Test public void testBuildFromAnotherConversationInfo() { + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + ConversationStatus cs2 = new ConversationStatus.Builder("id2", ACTIVITY_GAME).build(); + ConversationInfo source = new ConversationInfo.Builder() .setShortcutId(SHORTCUT_ID) .setLocusId(LOCUS_ID) @@ -120,6 +138,8 @@ public final class ConversationInfoTest { .setPersonImportant(true) .setPersonBot(true) .setContactStarred(true) + .addOrUpdateStatus(cs) + .addOrUpdateStatus(cs2) .build(); ConversationInfo destination = new ConversationInfo.Builder(source) @@ -141,5 +161,7 @@ public final class ConversationInfoTest { assertTrue(destination.isPersonImportant()); assertTrue(destination.isPersonBot()); assertFalse(destination.isContactStarred()); + assertThat(destination.getStatuses()).contains(cs); + assertThat(destination.getStatuses()).contains(cs2); } } diff --git a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java index 2471210f63255..be8a99c5ce363 100644 --- a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java +++ b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java @@ -16,6 +16,8 @@ package com.android.server.people.data; +import static android.app.people.ConversationStatus.ACTIVITY_ANNIVERSARY; +import static android.app.people.ConversationStatus.ACTIVITY_GAME; import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_ADDED; import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_DELETED; import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_UPDATED; @@ -24,6 +26,8 @@ import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentat import static com.google.common.truth.Truth.assertThat; +import static junit.framework.Assert.fail; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -50,6 +54,7 @@ import android.app.NotificationManager; import android.app.Person; import android.app.job.JobScheduler; import android.app.people.ConversationChannel; +import android.app.people.ConversationStatus; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; @@ -936,6 +941,83 @@ public final class DataManagerTest { TEST_SHORTCUT_ID)); } + @Test + public void testAddOrUpdateStatus_noCachedShortcut() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + + try { + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs); + fail("Updated a conversation info that didn't previously exist"); + } catch (IllegalArgumentException e) { + // good + } + } + + @Test + public void testAddOrUpdateStatus() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, + buildPerson()); + mDataManager.addOrUpdateConversationInfo(shortcut); + + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs); + + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .contains(cs); + + ConversationStatus cs2 = new ConversationStatus.Builder("id2", ACTIVITY_GAME).build(); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs2); + + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .contains(cs); + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .contains(cs2); + } + + @Test + public void testClearStatus() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, + buildPerson()); + mDataManager.addOrUpdateConversationInfo(shortcut); + + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + ConversationStatus cs2 = new ConversationStatus.Builder("id2", ACTIVITY_GAME).build(); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs2); + + mDataManager.clearStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs2.getId()); + + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .contains(cs); + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .doesNotContain(cs2); + } + + @Test + public void testClearStatuses() { + mDataManager.onUserUnlocked(USER_ID_PRIMARY); + + ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, + buildPerson()); + mDataManager.addOrUpdateConversationInfo(shortcut); + + ConversationStatus cs = new ConversationStatus.Builder("id", ACTIVITY_ANNIVERSARY).build(); + ConversationStatus cs2 = new ConversationStatus.Builder("id2", ACTIVITY_GAME).build(); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs); + mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, cs2); + + mDataManager.clearStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID); + + assertThat(mDataManager.getStatuses(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID)) + .isEmpty(); + } + @Test public void testNonCachedShortcutNotInRecentList() { mDataManager.onUserUnlocked(USER_ID_PRIMARY);