--- /dev/null
+/**
+ * Copyright (C) 2017 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+import static android.service.notification.NotificationListenerService.Ranking
+ .USER_SENTIMENT_NEGATIVE;
+
+import android.app.INotificationManager;
+import android.content.Context;
+import android.ext.services.R;
+import android.os.Bundle;
+import android.service.notification.Adjustment;
+import android.service.notification.NotificationAssistantService;
+import android.service.notification.NotificationStats;
+import android.service.notification.StatusBarNotification;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Slog;
+
+import java.util.ArrayList;
+
+/**
+ * Notification assistant that provides guidance on notification channel blocking
+ */
+public class Assistant extends NotificationAssistantService {
+ private static final String TAG = "ExtAssistant";
+ private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+ private static final ArrayList<Integer> DISMISS_WITH_PREJUDICE = new ArrayList<>();
+ static {
+ DISMISS_WITH_PREJUDICE.add(REASON_CANCEL);
+ DISMISS_WITH_PREJUDICE.add(REASON_LISTENER_CANCEL);
+ }
+
+ // key : impressions tracker
+ // TODO: persist across reboots
+ ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>();
+ // SBN key : channel id
+ ArrayMap<String, String> mLiveNotifications = new ArrayMap<>();
+
+ private Ranking mFakeRanking = null;
+
+ @Override
+ public Adjustment onNotificationEnqueued(StatusBarNotification sbn) {
+ if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey());
+ return null;
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
+ if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey());
+ try {
+ Ranking ranking = getRanking(sbn.getKey(), rankingMap);
+ if (ranking != null && ranking.getChannel() != null) {
+ String key = getKey(
+ sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId());
+ ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
+ new ChannelImpressions());
+ if (ranking.getImportance() > IMPORTANCE_MIN && ci.shouldTriggerBlock()) {
+ adjustNotification(createNegativeAdjustment(
+ sbn.getPackageName(), sbn.getKey(), sbn.getUserId()));
+ }
+ mkeyToImpressions.put(key, ci);
+ mLiveNotifications.put(sbn.getKey(), ranking.getChannel().getId());
+ }
+ } catch (Throwable e) {
+ Log.e(TAG, "Error occurred processing post", e);
+ }
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
+ NotificationStats stats, int reason) {
+ try {
+ String channelId = mLiveNotifications.remove(sbn.getKey());
+ String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId);
+ ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions());
+ if (stats.hasSeen()) {
+ ci.incrementViews();
+ }
+ if (DISMISS_WITH_PREJUDICE.contains(reason)
+ && !sbn.isAppGroup()
+ && !sbn.getNotification().isGroupChild()
+ && !stats.hasInteracted()
+ && stats.getDismissalSurface() != NotificationStats.DISMISSAL_AOD
+ && stats.getDismissalSurface() != NotificationStats.DISMISSAL_PEEK
+ && stats.getDismissalSurface() != NotificationStats.DISMISSAL_OTHER) {
+ if (DEBUG) Log.i(TAG, "increment dismissals");
+ ci.incrementDismissals();
+ } else {
+ if (DEBUG) Slog.i(TAG, "reset streak");
+ ci.resetStreak();
+ }
+ mkeyToImpressions.put(key, ci);
+ } catch (Throwable e) {
+ Slog.e(TAG, "Error occurred processing removal", e);
+ }
+ }
+
+ @Override
+ public void onNotificationSnoozedUntilContext(StatusBarNotification sbn,
+ String snoozeCriterionId) {
+ }
+
+ @Override
+ public void onListenerConnected() {
+ if (DEBUG) Log.i(TAG, "CONNECTED");
+ try {
+ for (StatusBarNotification sbn : getActiveNotifications()) {
+ onNotificationPosted(sbn);
+ }
+ } catch (Throwable e) {
+ Log.e(TAG, "Error occurred on connection", e);
+ }
+ }
+
+ private String getKey(String pkg, int userId, String channelId) {
+ return pkg + "|" + userId + "|" + channelId;
+ }
+
+ private Ranking getRanking(String key, RankingMap rankingMap) {
+ if (mFakeRanking != null) {
+ return mFakeRanking;
+ }
+ Ranking ranking = new Ranking();
+ rankingMap.getRanking(key, ranking);
+ return ranking;
+ }
+
+ private Adjustment createNegativeAdjustment(String packageName, String key, int user) {
+ if (DEBUG) Log.d(TAG, "User probably doesn't want " + key);
+ Bundle signals = new Bundle();
+ signals.putInt(Adjustment.KEY_USER_SENTIMENT, USER_SENTIMENT_NEGATIVE);
+ return new Adjustment(packageName, key, signals,
+ getContext().getString(R.string.prompt_block_reason), user);
+ }
+
+ // for testing
+ protected void setFakeRanking(Ranking ranking) {
+ mFakeRanking = ranking;
+ }
+
+ protected void setNoMan(INotificationManager noMan) {
+ mNoMan = noMan;
+ }
+
+ protected void setContext(Context context) {
+ mSystemContext = context;
+ }
+}
\ No newline at end of file
--- /dev/null
+/**
+ * Copyright (C) 2017 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.ext.services.notification;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.Log;
+
+public final class ChannelImpressions implements Parcelable {
+ private static final String TAG = "ExtAssistant.CI";
+ private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+ static final double DISMISS_TO_VIEW_RATIO_LIMIT = .8;
+ static final int STREAK_LIMIT = 2;
+
+ private int mDismissals = 0;
+ private int mViews = 0;
+ private int mStreak = 0;
+
+ public ChannelImpressions() {
+ }
+
+ public ChannelImpressions(int dismissals, int views) {
+ mDismissals = dismissals;
+ mViews = views;
+ }
+
+ protected ChannelImpressions(Parcel in) {
+ mDismissals = in.readInt();
+ mViews = in.readInt();
+ mStreak = in.readInt();
+ }
+
+ public int getStreak() {
+ return mStreak;
+ }
+
+ public int getDismissals() {
+ return mDismissals;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void incrementDismissals() {
+ mDismissals++;
+ mStreak++;
+ }
+
+ public void incrementViews() {
+ mViews++;
+ }
+
+ public void resetStreak() {
+ mStreak = 0;
+ }
+
+ public boolean shouldTriggerBlock() {
+ if (getViews() == 0) {
+ return false;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "should trigger? " + getDismissals() + " " + getViews() + " " + getStreak());
+ }
+ return ((double) getDismissals() / getViews()) > DISMISS_TO_VIEW_RATIO_LIMIT
+ && getStreak() > STREAK_LIMIT;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mDismissals);
+ dest.writeInt(mViews);
+ dest.writeInt(mStreak);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator<ChannelImpressions> CREATOR = new Creator<ChannelImpressions>() {
+ @Override
+ public ChannelImpressions createFromParcel(Parcel in) {
+ return new ChannelImpressions(in);
+ }
+
+ @Override
+ public ChannelImpressions[] newArray(int size) {
+ return new ChannelImpressions[size];
+ }
+ };
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ChannelImpressions that = (ChannelImpressions) o;
+
+ if (mDismissals != that.mDismissals) return false;
+ if (mViews != that.mViews) return false;
+ return mStreak == that.mStreak;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = mDismissals;
+ result = 31 * result + mViews;
+ result = 31 * result + mStreak;
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("ChannelImpressions{");
+ sb.append("mDismissals=").append(mDismissals);
+ sb.append(", mViews=").append(mViews);
+ sb.append(", mStreak=").append(mStreak);
+ sb.append('}');
+ return sb.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (C) 2017 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.ext.services.notification;
+
+import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
+import static android.app.NotificationManager.IMPORTANCE_LOW;
+import static android.app.NotificationManager.IMPORTANCE_MIN;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.INotificationManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.content.Intent;
+import android.ext.services.R;
+import android.os.UserHandle;
+import android.service.notification.Adjustment;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.NotificationListenerService.Ranking;
+import android.service.notification.NotificationListenerService.RankingMap;
+import android.service.notification.NotificationStats;
+import android.service.notification.StatusBarNotification;
+import android.support.test.InstrumentationRegistry;
+import android.test.ServiceTestCase;
+import android.testing.TestableContext;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class AssistantTest extends ServiceTestCase<Assistant> {
+
+ private static final String PKG1 = "pkg1";
+ private static final int UID1 = 1;
+ private static final NotificationChannel P1C1 =
+ new NotificationChannel("one", "", IMPORTANCE_LOW);
+ private static final NotificationChannel P1C2 =
+ new NotificationChannel("p1c2", "", IMPORTANCE_DEFAULT);
+ private static final NotificationChannel P1C3 =
+ new NotificationChannel("p1c3", "", IMPORTANCE_MIN);
+ private static final String PKG2 = "pkg2";
+
+ private static final int UID2 = 2;
+ private static final NotificationChannel P2C1 =
+ new NotificationChannel("one", "", IMPORTANCE_LOW);
+
+ @Mock INotificationManager mNoMan;
+
+ Assistant mAssistant;
+
+ @Rule
+ public final TestableContext mContext =
+ new TestableContext(InstrumentationRegistry.getContext(), null);
+
+ public AssistantTest() {
+ super(Assistant.class);
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ Intent startIntent =
+ new Intent("android.service.notification.NotificationAssistantService");
+ startIntent.setPackage("android.ext.services");
+ bindService(startIntent);
+ mAssistant = getService();
+ mAssistant.setNoMan(mNoMan);
+ }
+
+ private StatusBarNotification generateSbn(String pkg, int uid, NotificationChannel channel,
+ String tag, String groupKey) {
+ Notification n = new Notification.Builder(mContext, channel.getId())
+ .setContentTitle("foo")
+ .setGroup(groupKey)
+ .build();
+
+ StatusBarNotification sbn = new StatusBarNotification(pkg, pkg, 0, tag, uid, uid, n,
+ UserHandle.SYSTEM, null, 0);
+
+ return sbn;
+ }
+
+ private Ranking generateRanking(StatusBarNotification sbn, NotificationChannel channel) {
+ Ranking mockRanking = mock(Ranking.class);
+ when(mockRanking.getChannel()).thenReturn(channel);
+ when(mockRanking.getImportance()).thenReturn(channel.getImportance());
+ when(mockRanking.getKey()).thenReturn(sbn.getKey());
+ when(mockRanking.getOverrideGroupKey()).thenReturn(null);
+ return mockRanking;
+ }
+
+ private void almostBlockChannel(String pkg, int uid, NotificationChannel channel) {
+ for (int i = 0; i < ChannelImpressions.STREAK_LIMIT; i++) {
+ dismissBadNotification(pkg, uid, channel, String.valueOf(i));
+ }
+ }
+
+ private void dismissBadNotification(String pkg, int uid, NotificationChannel channel,
+ String tag) {
+ StatusBarNotification sbn = generateSbn(pkg, uid, channel, tag, null);
+ mAssistant.setFakeRanking(generateRanking(sbn, channel));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+ mAssistant.setFakeRanking(mock(Ranking.class));
+ NotificationStats stats = new NotificationStats();
+ stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE);
+ stats.setSeen();
+ mAssistant.onNotificationRemoved(
+ sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL);
+ }
+
+ @Test
+ public void testNoAdjustmentForInitialPost() throws Exception {
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, null, null);
+
+ mAssistant.setFakeRanking(generateRanking(sbn, P1C1));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+ dismissBadNotification(PKG1, UID1, P1C1, "trigger!");
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
+ mAssistant.setFakeRanking(generateRanking(sbn, P1C1));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ ArgumentCaptor<Adjustment> captor = ArgumentCaptor.forClass(Adjustment.class);
+ verify(mNoMan, times(1)).applyAdjustmentFromAssistant(any(), captor.capture());
+ assertEquals(sbn.getKey(), captor.getValue().getKey());
+ assertEquals(Ranking.USER_SENTIMENT_NEGATIVE,
+ captor.getValue().getSignals().getInt(Adjustment.KEY_USER_SENTIMENT));
+ }
+
+ @Test
+ public void testMinCannotTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C3);
+ dismissBadNotification(PKG1, UID1, P1C3, "trigger!");
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C3, "new one!", null);
+ mAssistant.setFakeRanking(generateRanking(sbn, P1C3));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testGroupCannotTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", "I HAVE A GROUP");
+ mAssistant.setFakeRanking(mock(Ranking.class));
+ NotificationStats stats = new NotificationStats();
+ stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE);
+ stats.setSeen();
+ mAssistant.onNotificationRemoved(
+ sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL);
+
+ sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testAodCannotTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null);
+ mAssistant.setFakeRanking(mock(Ranking.class));
+ NotificationStats stats = new NotificationStats();
+ stats.setDismissalSurface(NotificationStats.DISMISSAL_AOD);
+ stats.setSeen();
+ mAssistant.onNotificationRemoved(
+ sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL);
+
+ sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testInteractedCannotTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null);
+ mAssistant.setFakeRanking(mock(Ranking.class));
+ NotificationStats stats = new NotificationStats();
+ stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE);
+ stats.setSeen();
+ stats.setExpanded();
+ mAssistant.onNotificationRemoved(
+ sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL);
+
+ sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testAppDismissedCannotTriggerAdjustment() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null);
+ mAssistant.setFakeRanking(mock(Ranking.class));
+ NotificationStats stats = new NotificationStats();
+ stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE);
+ stats.setSeen();
+ mAssistant.onNotificationRemoved(
+ sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_APP_CANCEL);
+
+ sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null);
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testAppSeparation() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+ dismissBadNotification(PKG1, UID1, P1C1, "trigger!");
+
+ StatusBarNotification sbn = generateSbn(PKG2, UID2, P2C1, "new app!", null);
+ mAssistant.setFakeRanking(generateRanking(sbn, P2C1));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+
+ @Test
+ public void testChannelSeparation() throws Exception {
+ almostBlockChannel(PKG1, UID1, P1C1);
+ dismissBadNotification(PKG1, UID1, P1C1, "trigger!");
+
+ StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C2, "new app!", null);
+ mAssistant.setFakeRanking(generateRanking(sbn, P1C2));
+ mAssistant.onNotificationPosted(sbn, mock(RankingMap.class));
+
+ verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any());
+ }
+}