| /* |
| * Copyright (C) 2015 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.service.notification; |
| |
| import static java.lang.annotation.RetentionPolicy.SOURCE; |
| |
| import android.annotation.IntDef; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.SdkConstant; |
| import android.annotation.SystemApi; |
| import android.annotation.TestApi; |
| import android.app.Notification; |
| import android.app.NotificationChannel; |
| import android.app.NotificationManager; |
| import android.app.admin.DevicePolicyManager; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.os.Handler; |
| import android.os.IBinder; |
| import android.os.Looper; |
| import android.os.Message; |
| import android.os.RemoteException; |
| import android.util.Log; |
| |
| import com.android.internal.os.SomeArgs; |
| |
| import java.lang.annotation.Retention; |
| import java.util.List; |
| |
| /** |
| * A service that helps the user manage notifications. |
| * <p> |
| * Only one notification assistant can be active at a time. Unlike notification listener services, |
| * assistant services can additionally modify certain aspects about notifications |
| * (see {@link Adjustment}) before they are posted. |
| *<p> |
| * A note about managed profiles: Unlike {@link NotificationListenerService listener services}, |
| * NotificationAssistantServices are allowed to run in managed profiles |
| * (see {@link DevicePolicyManager#isManagedProfile(ComponentName)}), so they can access the |
| * information they need to create good {@link Adjustment adjustments}. To maintain the contract |
| * with {@link NotificationListenerService}, an assistant service will receive all of the |
| * callbacks from {@link NotificationListenerService} for the current user, managed profiles of |
| * that user, and ones that affect all users. However, |
| * {@link #onNotificationEnqueued(StatusBarNotification)} will only be called for notifications |
| * sent to the current user, and {@link Adjustment adjuments} will only be accepted for the |
| * current user. |
| * <p> |
| * All callbacks are called on the main thread. |
| * </p> |
| * @hide |
| */ |
| @SystemApi |
| @TestApi |
| public abstract class NotificationAssistantService extends NotificationListenerService { |
| private static final String TAG = "NotificationAssistants"; |
| |
| /** @hide */ |
| @Retention(SOURCE) |
| @IntDef({SOURCE_FROM_APP, SOURCE_FROM_ASSISTANT}) |
| public @interface Source {} |
| |
| /** |
| * To indicate an adjustment is from an app. |
| */ |
| public static final int SOURCE_FROM_APP = 0; |
| /** |
| * To indicate an adjustment is from a {@link NotificationAssistantService}. |
| */ |
| public static final int SOURCE_FROM_ASSISTANT = 1; |
| |
| /** |
| * The {@link Intent} that must be declared as handled by the service. |
| */ |
| @SdkConstant(SdkConstant.SdkConstantType.SERVICE_ACTION) |
| public static final String SERVICE_INTERFACE |
| = "android.service.notification.NotificationAssistantService"; |
| |
| /** |
| * @hide |
| */ |
| protected Handler mHandler; |
| |
| @Override |
| protected void attachBaseContext(Context base) { |
| super.attachBaseContext(base); |
| mHandler = new MyHandler(getContext().getMainLooper()); |
| } |
| |
| @Override |
| public final @NonNull IBinder onBind(@Nullable Intent intent) { |
| if (mWrapper == null) { |
| mWrapper = new NotificationAssistantServiceWrapper(); |
| } |
| return mWrapper; |
| } |
| |
| /** |
| * A notification was snoozed until a context. For use with |
| * {@link Adjustment#KEY_SNOOZE_CRITERIA}. When the device reaches the given context, the |
| * assistant should restore the notification with {@link #unsnoozeNotification(String)}. |
| * |
| * @param sbn the notification to snooze |
| * @param snoozeCriterionId the {@link SnoozeCriterion#getId()} representing a device context. |
| */ |
| abstract public void onNotificationSnoozedUntilContext(@NonNull StatusBarNotification sbn, |
| @NonNull String snoozeCriterionId); |
| |
| /** |
| * A notification was posted by an app. Called before post. |
| * |
| * <p>Note: this method is only called if you don't override |
| * {@link #onNotificationEnqueued(StatusBarNotification, NotificationChannel)}.</p> |
| * |
| * @param sbn the new notification |
| * @return an adjustment or null to take no action, within 100ms. |
| */ |
| abstract public @Nullable Adjustment onNotificationEnqueued(@NonNull StatusBarNotification sbn); |
| |
| /** |
| * A notification was posted by an app. Called before post. |
| * |
| * @param sbn the new notification |
| * @param channel the channel the notification was posted to |
| * @return an adjustment or null to take no action, within 100ms. |
| */ |
| public @Nullable Adjustment onNotificationEnqueued(@NonNull StatusBarNotification sbn, |
| @NonNull NotificationChannel channel) { |
| return onNotificationEnqueued(sbn); |
| } |
| |
| /** |
| * Implement this method to learn when notifications are removed, how they were interacted with |
| * before removal, and why they were removed. |
| * <p> |
| * This might occur because the user has dismissed the notification using system UI (or another |
| * notification listener) or because the app has withdrawn the notification. |
| * <p> |
| * NOTE: The {@link StatusBarNotification} object you receive will be "light"; that is, the |
| * result from {@link StatusBarNotification#getNotification} may be missing some heavyweight |
| * fields such as {@link android.app.Notification#contentView} and |
| * {@link android.app.Notification#largeIcon}. However, all other fields on |
| * {@link StatusBarNotification}, sufficient to match this call with a prior call to |
| * {@link #onNotificationPosted(StatusBarNotification)}, will be intact. |
| * |
| ** @param sbn A data structure encapsulating at least the original information (tag and id) |
| * and source (package name) used to post the {@link android.app.Notification} that |
| * was just removed. |
| * @param rankingMap The current ranking map that can be used to retrieve ranking information |
| * for active notifications. |
| * @param stats Stats about how the user interacted with the notification before it was removed. |
| * @param reason see {@link #REASON_LISTENER_CANCEL}, etc. |
| */ |
| @Override |
| public void onNotificationRemoved(@NonNull StatusBarNotification sbn, |
| @NonNull RankingMap rankingMap, |
| @NonNull NotificationStats stats, int reason) { |
| onNotificationRemoved(sbn, rankingMap, reason); |
| } |
| |
| /** |
| * Implement this to know when a user has seen notifications, as triggered by |
| * {@link #setNotificationsShown(String[])}. |
| */ |
| public void onNotificationsSeen(@NonNull List<String> keys) { |
| |
| } |
| |
| /** |
| * Implement this to know when a notification change (expanded / collapsed) is visible to user. |
| * |
| * @param key the notification key |
| * @param isUserAction whether the expanded change is caused by user action. |
| * @param isExpanded whether the notification is expanded. |
| */ |
| public void onNotificationExpansionChanged( |
| @NonNull String key, boolean isUserAction, boolean isExpanded) {} |
| |
| /** |
| * Implement this to know when a direct reply is sent from a notification. |
| * @param key the notification key |
| */ |
| public void onNotificationDirectReplied(@NonNull String key) {} |
| |
| /** |
| * Implement this to know when a suggested reply is sent. |
| * @param key the notification key |
| * @param reply the reply that is just sent |
| * @param source the source that provided the reply, e.g. SOURCE_FROM_APP |
| */ |
| public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, |
| @Source int source) { |
| } |
| |
| /** |
| * Implement this to know when an action is clicked. |
| * @param key the notification key |
| * @param action the action that is just clicked |
| * @param source the source that provided the action, e.g. SOURCE_FROM_APP |
| */ |
| public void onActionInvoked(@NonNull String key, @NonNull Notification.Action action, |
| @Source int source) { |
| } |
| |
| /** |
| * Implement this to know when a user has changed which features of |
| * their notifications the assistant can modify. |
| * <p> Query {@link NotificationManager#getAllowedAssistantAdjustments()} to see what |
| * {@link Adjustment adjustments} you are currently allowed to make.</p> |
| */ |
| public void onAllowedAdjustmentsChanged() { |
| } |
| |
| /** |
| * Updates a notification. N.B. this won’t cause |
| * an existing notification to alert, but might allow a future update to |
| * this notification to alert. |
| * |
| * @param adjustment the adjustment with an explanation |
| */ |
| public final void adjustNotification(@NonNull Adjustment adjustment) { |
| if (!isBound()) return; |
| try { |
| setAdjustmentIssuer(adjustment); |
| getNotificationInterface().applyEnqueuedAdjustmentFromAssistant(mWrapper, adjustment); |
| } catch (android.os.RemoteException ex) { |
| Log.v(TAG, "Unable to contact notification manager", ex); |
| throw ex.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Updates existing notifications. Re-ranking won't occur until all adjustments are applied. |
| * N.B. this won’t cause an existing notification to alert, but might allow a future update to |
| * these notifications to alert. |
| * |
| * @param adjustments a list of adjustments with explanations |
| */ |
| public final void adjustNotifications(@NonNull List<Adjustment> adjustments) { |
| if (!isBound()) return; |
| try { |
| for (Adjustment adjustment : adjustments) { |
| setAdjustmentIssuer(adjustment); |
| } |
| getNotificationInterface().applyAdjustmentsFromAssistant(mWrapper, adjustments); |
| } catch (android.os.RemoteException ex) { |
| Log.v(TAG, "Unable to contact notification manager", ex); |
| throw ex.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Inform the notification manager about un-snoozing a specific notification. |
| * <p> |
| * This should only be used for notifications snoozed because of a contextual snooze suggestion |
| * you provided via {@link Adjustment#KEY_SNOOZE_CRITERIA}. Once un-snoozed, you will get a |
| * {@link #onNotificationPosted(StatusBarNotification, RankingMap)} callback for the |
| * notification. |
| * @param key The key of the notification to snooze |
| */ |
| public final void unsnoozeNotification(@NonNull String key) { |
| if (!isBound()) return; |
| try { |
| getNotificationInterface().unsnoozeNotificationFromAssistant(mWrapper, key); |
| } catch (android.os.RemoteException ex) { |
| Log.v(TAG, "Unable to contact notification manager", ex); |
| } |
| } |
| |
| private class NotificationAssistantServiceWrapper extends NotificationListenerWrapper { |
| @Override |
| public void onNotificationEnqueuedWithChannel(IStatusBarNotificationHolder sbnHolder, |
| NotificationChannel channel) { |
| StatusBarNotification sbn; |
| try { |
| sbn = sbnHolder.get(); |
| } catch (RemoteException e) { |
| Log.w(TAG, "onNotificationEnqueued: Error receiving StatusBarNotification", e); |
| return; |
| } |
| |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = sbn; |
| args.arg2 = channel; |
| mHandler.obtainMessage(MyHandler.MSG_ON_NOTIFICATION_ENQUEUED, |
| args).sendToTarget(); |
| } |
| |
| @Override |
| public void onNotificationSnoozedUntilContext( |
| IStatusBarNotificationHolder sbnHolder, String snoozeCriterionId) { |
| StatusBarNotification sbn; |
| try { |
| sbn = sbnHolder.get(); |
| } catch (RemoteException e) { |
| Log.w(TAG, "onNotificationSnoozed: Error receiving StatusBarNotification", e); |
| return; |
| } |
| |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = sbn; |
| args.arg2 = snoozeCriterionId; |
| mHandler.obtainMessage(MyHandler.MSG_ON_NOTIFICATION_SNOOZED, |
| args).sendToTarget(); |
| } |
| |
| @Override |
| public void onNotificationsSeen(List<String> keys) { |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = keys; |
| mHandler.obtainMessage(MyHandler.MSG_ON_NOTIFICATIONS_SEEN, |
| args).sendToTarget(); |
| } |
| |
| @Override |
| public void onNotificationExpansionChanged(String key, boolean isUserAction, |
| boolean isExpanded) { |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = key; |
| args.argi1 = isUserAction ? 1 : 0; |
| args.argi2 = isExpanded ? 1 : 0; |
| mHandler.obtainMessage(MyHandler.MSG_ON_NOTIFICATION_EXPANSION_CHANGED, args) |
| .sendToTarget(); |
| } |
| |
| @Override |
| public void onNotificationDirectReply(String key) { |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = key; |
| mHandler.obtainMessage(MyHandler.MSG_ON_NOTIFICATION_DIRECT_REPLY_SENT, args) |
| .sendToTarget(); |
| } |
| |
| @Override |
| public void onSuggestedReplySent(String key, CharSequence reply, int source) { |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = key; |
| args.arg2 = reply; |
| args.argi2 = source; |
| mHandler.obtainMessage(MyHandler.MSG_ON_SUGGESTED_REPLY_SENT, args).sendToTarget(); |
| } |
| |
| @Override |
| public void onActionClicked(String key, Notification.Action action, int source) { |
| SomeArgs args = SomeArgs.obtain(); |
| args.arg1 = key; |
| args.arg2 = action; |
| args.argi2 = source; |
| mHandler.obtainMessage(MyHandler.MSG_ON_ACTION_INVOKED, args).sendToTarget(); |
| } |
| |
| @Override |
| public void onAllowedAdjustmentsChanged() { |
| mHandler.obtainMessage(MyHandler.MSG_ON_ALLOWED_ADJUSTMENTS_CHANGED).sendToTarget(); |
| } |
| } |
| |
| private void setAdjustmentIssuer(@Nullable Adjustment adjustment) { |
| if (adjustment != null) { |
| adjustment.setIssuer(getOpPackageName() + "/" + getClass().getName()); |
| } |
| } |
| |
| private final class MyHandler extends Handler { |
| public static final int MSG_ON_NOTIFICATION_ENQUEUED = 1; |
| public static final int MSG_ON_NOTIFICATION_SNOOZED = 2; |
| public static final int MSG_ON_NOTIFICATIONS_SEEN = 3; |
| public static final int MSG_ON_NOTIFICATION_EXPANSION_CHANGED = 4; |
| public static final int MSG_ON_NOTIFICATION_DIRECT_REPLY_SENT = 5; |
| public static final int MSG_ON_SUGGESTED_REPLY_SENT = 6; |
| public static final int MSG_ON_ACTION_INVOKED = 7; |
| public static final int MSG_ON_ALLOWED_ADJUSTMENTS_CHANGED = 8; |
| |
| public MyHandler(Looper looper) { |
| super(looper, null, false); |
| } |
| |
| @Override |
| public void handleMessage(Message msg) { |
| switch (msg.what) { |
| case MSG_ON_NOTIFICATION_ENQUEUED: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| StatusBarNotification sbn = (StatusBarNotification) args.arg1; |
| NotificationChannel channel = (NotificationChannel) args.arg2; |
| args.recycle(); |
| Adjustment adjustment = onNotificationEnqueued(sbn, channel); |
| setAdjustmentIssuer(adjustment); |
| if (adjustment != null) { |
| if (!isBound()) { |
| Log.w(TAG, "MSG_ON_NOTIFICATION_ENQUEUED: service not bound, skip."); |
| return; |
| } |
| try { |
| getNotificationInterface().applyEnqueuedAdjustmentFromAssistant( |
| mWrapper, adjustment); |
| } catch (android.os.RemoteException ex) { |
| Log.v(TAG, "Unable to contact notification manager", ex); |
| throw ex.rethrowFromSystemServer(); |
| } catch (SecurityException e) { |
| // app cannot catch and recover from this, so do on their behalf |
| Log.w(TAG, "Enqueue adjustment failed; no longer connected", e); |
| } |
| } |
| break; |
| } |
| case MSG_ON_NOTIFICATION_SNOOZED: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| StatusBarNotification sbn = (StatusBarNotification) args.arg1; |
| String snoozeCriterionId = (String) args.arg2; |
| args.recycle(); |
| onNotificationSnoozedUntilContext(sbn, snoozeCriterionId); |
| break; |
| } |
| case MSG_ON_NOTIFICATIONS_SEEN: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| List<String> keys = (List<String>) args.arg1; |
| args.recycle(); |
| onNotificationsSeen(keys); |
| break; |
| } |
| case MSG_ON_NOTIFICATION_EXPANSION_CHANGED: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| String key = (String) args.arg1; |
| boolean isUserAction = args.argi1 == 1; |
| boolean isExpanded = args.argi2 == 1; |
| args.recycle(); |
| onNotificationExpansionChanged(key, isUserAction, isExpanded); |
| break; |
| } |
| case MSG_ON_NOTIFICATION_DIRECT_REPLY_SENT: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| String key = (String) args.arg1; |
| args.recycle(); |
| onNotificationDirectReplied(key); |
| break; |
| } |
| case MSG_ON_SUGGESTED_REPLY_SENT: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| String key = (String) args.arg1; |
| CharSequence reply = (CharSequence) args.arg2; |
| int source = args.argi2; |
| args.recycle(); |
| onSuggestedReplySent(key, reply, source); |
| break; |
| } |
| case MSG_ON_ACTION_INVOKED: { |
| SomeArgs args = (SomeArgs) msg.obj; |
| String key = (String) args.arg1; |
| Notification.Action action = (Notification.Action) args.arg2; |
| int source = args.argi2; |
| args.recycle(); |
| onActionInvoked(key, action, source); |
| break; |
| } |
| case MSG_ON_ALLOWED_ADJUSTMENTS_CHANGED: { |
| onAllowedAdjustmentsChanged(); |
| break; |
| } |
| } |
| } |
| } |
| } |