Loading packages/ExtServices/src/android/ext/services/notification/Assistant.java +169 −21 Original line number Diff line number Diff line Loading @@ -23,16 +23,37 @@ import static android.service.notification.NotificationListenerService.Ranking import android.app.INotificationManager; import android.content.Context; import android.ext.services.R; import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.storage.StorageManager; 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.AtomicFile; import android.util.Log; import android.util.Slog; import android.util.Xml; import com.android.internal.util.FastXmlSerializer; import com.android.internal.util.XmlUtils; import libcore.io.IoUtils; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlSerializer; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Map; /** * Notification assistant that provides guidance on notification channel blocking Loading @@ -41,19 +62,112 @@ 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<>(); private static final String TAG_ASSISTANT = "assistant"; private static final String TAG_IMPRESSION = "impression-set"; private static final String ATT_KEY = "key"; private static final int DB_VERSION = 1; private static final String ATTR_VERSION = "version"; private static final ArrayList<Integer> PREJUDICAL_DISMISSALS = new ArrayList<>(); static { DISMISS_WITH_PREJUDICE.add(REASON_CANCEL); DISMISS_WITH_PREJUDICE.add(REASON_LISTENER_CANCEL); PREJUDICAL_DISMISSALS.add(REASON_CANCEL); PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL); } // key : impressions tracker // TODO: persist across reboots // TODO: prune deleted channels and apps ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>(); // SBN key : channel id ArrayMap<String, String> mLiveNotifications = new ArrayMap<>(); private Ranking mFakeRanking = null; private AtomicFile mFile = null; public Assistant() { } private void loadFile() { if (DEBUG) Slog.d(TAG, "loadFile"); AsyncTask.execute(() -> { InputStream infile = null; try { infile = mFile.openRead(); readXml(infile); } catch (FileNotFoundException e) { // No data yet } catch (IOException e) { Log.e(TAG, "Unable to read channel impressions", e); } catch (NumberFormatException | XmlPullParserException e) { Log.e(TAG, "Unable to parse channel impressions", e); } finally { IoUtils.closeQuietly(infile); } }); } protected void readXml(InputStream stream) throws XmlPullParserException, NumberFormatException, IOException { final XmlPullParser parser = Xml.newPullParser(); parser.setInput(stream, StandardCharsets.UTF_8.name()); final int outerDepth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, outerDepth)) { if (!TAG_ASSISTANT.equals(parser.getName())) { continue; } final int impressionOuterDepth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, impressionOuterDepth)) { if (!TAG_IMPRESSION.equals(parser.getName())) { continue; } String key = parser.getAttributeValue(null, ATT_KEY); ChannelImpressions ci = new ChannelImpressions(); ci.populateFromXml(parser); synchronized (mkeyToImpressions) { ci.append(mkeyToImpressions.get(key)); mkeyToImpressions.put(key, ci); } } } } private void saveFile() throws IOException { AsyncTask.execute(() -> { final FileOutputStream stream; try { stream = mFile.startWrite(); } catch (IOException e) { Slog.w(TAG, "Failed to save policy file", e); return; } try { final XmlSerializer out = new FastXmlSerializer(); out.setOutput(stream, StandardCharsets.UTF_8.name()); writeXml(out); mFile.finishWrite(stream); } catch (IOException e) { Slog.w(TAG, "Failed to save impressions file, restoring backup", e); mFile.failWrite(stream); } }); } protected void writeXml(XmlSerializer out) throws IOException { out.startDocument(null, true); out.startTag(null, TAG_ASSISTANT); out.attribute(null, ATTR_VERSION, Integer.toString(DB_VERSION)); synchronized (mkeyToImpressions) { for (Map.Entry<String, ChannelImpressions> entry : mkeyToImpressions.entrySet()) { // TODO: ensure channel still exists out.startTag(null, TAG_IMPRESSION); out.attribute(null, ATT_KEY, entry.getKey()); entry.getValue().writeXml(out); out.endTag(null, TAG_IMPRESSION); } } out.endTag(null, TAG_ASSISTANT); out.endDocument(); } @Override public Adjustment onNotificationEnqueued(StatusBarNotification sbn) { Loading Loading @@ -87,26 +201,38 @@ public class Assistant extends NotificationAssistantService { public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, NotificationStats stats, int reason) { try { boolean updatedImpressions = false; String channelId = mLiveNotifications.remove(sbn.getKey()); String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId); ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions()); synchronized (mkeyToImpressions) { ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions()); if (stats.hasSeen()) { ci.incrementViews(); updatedImpressions = true; } if (DISMISS_WITH_PREJUDICE.contains(reason) && !sbn.isAppGroup() && !sbn.getNotification().isGroupChild() if (PREJUDICAL_DISMISSALS.contains(reason)) { if ((!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"); if (DEBUG) Log.i(TAG, "increment dismissals " + key); ci.incrementDismissals(); updatedImpressions = true; } else { if (DEBUG) Slog.i(TAG, "reset streak"); if (DEBUG) Slog.i(TAG, "reset streak " + key); if (ci.getStreak() > 0) { updatedImpressions = true; } ci.resetStreak(); } } mkeyToImpressions.put(key, ci); } if (updatedImpressions) { saveFile(); } } catch (Throwable e) { Slog.e(TAG, "Error occurred processing removal", e); } Loading @@ -121,6 +247,11 @@ public class Assistant extends NotificationAssistantService { public void onListenerConnected() { if (DEBUG) Log.i(TAG, "CONNECTED"); try { mFile = new AtomicFile(new File(new File( Environment.getDataUserCePackageDirectory( StorageManager.UUID_PRIVATE_INTERNAL, getUserId(), getPackageName()), "assistant"), "block_stats.xml")); loadFile(); for (StatusBarNotification sbn : getActiveNotifications()) { onNotificationPosted(sbn); } Loading @@ -129,7 +260,7 @@ public class Assistant extends NotificationAssistantService { } } private String getKey(String pkg, int userId, String channelId) { protected String getKey(String pkg, int userId, String channelId) { return pkg + "|" + userId + "|" + channelId; } Loading @@ -151,6 +282,11 @@ public class Assistant extends NotificationAssistantService { } // for testing protected void setFile(AtomicFile file) { mFile = file; } protected void setFakeRanking(Ranking ranking) { mFakeRanking = ranking; } Loading @@ -162,4 +298,16 @@ public class Assistant extends NotificationAssistantService { protected void setContext(Context context) { mSystemContext = context; } protected ChannelImpressions getImpressions(String key) { synchronized (mkeyToImpressions) { return mkeyToImpressions.get(key); } } protected void insertImpressions(String key, ChannelImpressions ci) { synchronized (mkeyToImpressions) { mkeyToImpressions.put(key, ci); } } } No newline at end of file packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java +49 −0 Original line number Diff line number Diff line Loading @@ -18,14 +18,23 @@ package android.ext.services.notification; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; import android.util.Log; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; 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; static final String ATT_DISMISSALS = "dismisses"; static final String ATT_VIEWS = "views"; static final String ATT_STREAK = "streak"; private int mDismissals = 0; private int mViews = 0; Loading Loading @@ -62,6 +71,14 @@ public final class ChannelImpressions implements Parcelable { mStreak++; } public void append(ChannelImpressions additionalImpressions) { if (additionalImpressions != null) { mViews += additionalImpressions.getViews(); mStreak += additionalImpressions.getStreak(); mDismissals += additionalImpressions.getDismissals(); } } public void incrementViews() { mViews++; } Loading Loading @@ -134,4 +151,36 @@ public final class ChannelImpressions implements Parcelable { sb.append('}'); return sb.toString(); } protected void populateFromXml(XmlPullParser parser) { mDismissals = safeInt(parser, ATT_DISMISSALS, 0); mStreak = safeInt(parser, ATT_STREAK, 0); mViews = safeInt(parser, ATT_VIEWS, 0); } protected void writeXml(XmlSerializer out) throws IOException { if (mDismissals != 0) { out.attribute(null, ATT_DISMISSALS, String.valueOf(mDismissals)); } if (mStreak != 0) { out.attribute(null, ATT_STREAK, String.valueOf(mStreak)); } if (mViews != 0) { out.attribute(null, ATT_VIEWS, String.valueOf(mViews)); } } private static int safeInt(XmlPullParser parser, String att, int defValue) { final String val = parser.getAttributeValue(null, att); return tryParseInt(val, defValue); } private static int tryParseInt(String value, int defValue) { if (TextUtils.isEmpty(value)) return defValue; try { return Integer.parseInt(value); } catch (NumberFormatException e) { return defValue; } } } packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java +134 −8 Original line number Diff line number Diff line Loading @@ -31,7 +31,6 @@ 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; Loading @@ -42,6 +41,10 @@ import android.service.notification.StatusBarNotification; import android.support.test.InstrumentationRegistry; import android.test.ServiceTestCase; import android.testing.TestableContext; import android.util.AtomicFile; import android.util.Xml; import com.android.internal.util.FastXmlSerializer; import org.junit.Before; import org.junit.Rule; Loading @@ -49,6 +52,14 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; public class AssistantTest extends ServiceTestCase<Assistant> { Loading @@ -67,6 +78,8 @@ public class AssistantTest extends ServiceTestCase<Assistant> { new NotificationChannel("one", "", IMPORTANCE_LOW); @Mock INotificationManager mNoMan; @Mock AtomicFile mFile; Assistant mAssistant; Loading @@ -88,6 +101,8 @@ public class AssistantTest extends ServiceTestCase<Assistant> { bindService(startIntent); mAssistant = getService(); mAssistant.setNoMan(mNoMan); mAssistant.setFile(mFile); when(mFile.startWrite()).thenReturn(mock(FileOutputStream.class)); } private StatusBarNotification generateSbn(String pkg, int uid, NotificationChannel channel, Loading Loading @@ -170,18 +185,43 @@ public class AssistantTest extends ServiceTestCase<Assistant> { } @Test public void testGroupCannotTriggerAdjustment() throws Exception { public void testGroupChildCanTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", "I HAVE A GROUP"); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", "group"); 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 testGroupSummaryCannotTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", "I HAVE A GROUP"); sbn.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", "group"); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any()); Loading @@ -192,10 +232,11 @@ public class AssistantTest extends ServiceTestCase<Assistant> { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_AOD); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); Loading @@ -208,13 +249,13 @@ public class AssistantTest extends ServiceTestCase<Assistant> { @Test public void testInteractedCannotTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); stats.setExpanded(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); Loading @@ -229,10 +270,11 @@ public class AssistantTest extends ServiceTestCase<Assistant> { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_APP_CANCEL); Loading Loading @@ -265,4 +307,88 @@ public class AssistantTest extends ServiceTestCase<Assistant> { verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any()); } @Test public void testReadXml() throws Exception { String key1 = mAssistant.getKey("pkg1", 1, "channel1"); int streak1 = 2; int views1 = 5; int dismiss1 = 9; int streak1a = 3; int views1a = 10; int dismiss1a = 99; String key1a = mAssistant.getKey("pkg1", 1, "channel1a"); int streak2 = 7; int views2 = 77; int dismiss2 = 777; String key2 = mAssistant.getKey("pkg2", 2, "channel2"); String xml = "<assistant version=\"1\">\n" + "<impression-set key=\"" + key1 + "\" " + "dismisses=\"" + dismiss1 + "\" views=\"" + views1 + "\" streak=\"" + streak1 + "\"/>\n" + "<impression-set key=\"" + key1a + "\" " + "dismisses=\"" + dismiss1a + "\" views=\"" + views1a + "\" streak=\"" + streak1a + "\"/>\n" + "<impression-set key=\"" + key2 + "\" " + "dismisses=\"" + dismiss2 + "\" views=\"" + views2 + "\" streak=\"" + streak2 + "\"/>\n" + "</assistant>\n"; mAssistant.readXml(new BufferedInputStream(new ByteArrayInputStream(xml.getBytes()))); ChannelImpressions c1 = mAssistant.getImpressions(key1); assertEquals(2, c1.getStreak()); assertEquals(5, c1.getViews()); assertEquals(9, c1.getDismissals()); ChannelImpressions c1a = mAssistant.getImpressions(key1a); assertEquals(3, c1a.getStreak()); assertEquals(10, c1a.getViews()); assertEquals(99, c1a.getDismissals()); ChannelImpressions c2 = mAssistant.getImpressions(key2); assertEquals(7, c2.getStreak()); assertEquals(77, c2.getViews()); assertEquals(777, c2.getDismissals()); } @Test public void testRoundTripXml() throws Exception { String key1 = mAssistant.getKey("pkg1", 1, "channel1"); ChannelImpressions ci1 = new ChannelImpressions(9, 10); String key2 = mAssistant.getKey("pkg1", 1, "channel2"); ChannelImpressions ci2 = new ChannelImpressions(); for (int i = 0; i < 3; i++) { ci2.incrementViews(); ci2.incrementDismissals(); } ChannelImpressions ci3 = new ChannelImpressions(); String key3 = mAssistant.getKey("pkg3", 3, "channel2"); for (int i = 0; i < 9; i++) { ci3.incrementViews(); if (i % 3 == 0) { ci3.incrementDismissals(); } } mAssistant.insertImpressions(key1, ci1); mAssistant.insertImpressions(key2, ci2); mAssistant.insertImpressions(key3, ci3); XmlSerializer serializer = new FastXmlSerializer(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); serializer.setOutput(new BufferedOutputStream(baos), "utf-8"); mAssistant.writeXml(serializer); Assistant assistant = new Assistant(); assistant.readXml(new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray()))); assertEquals(ci1, assistant.getImpressions(key1)); assertEquals(ci2, assistant.getImpressions(key2)); assertEquals(ci3, assistant.getImpressions(key3)); } } packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java +26 −0 Original line number Diff line number Diff line Loading @@ -21,6 +21,8 @@ import static android.ext.services.notification.ChannelImpressions.STREAK_LIMIT; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertEquals; import org.junit.Test; public class ChannelImpressionsTest { Loading Loading @@ -82,4 +84,28 @@ public class ChannelImpressionsTest { assertFalse(ci.shouldTriggerBlock()); } @Test public void testAppend() { ChannelImpressions ci = new ChannelImpressions(); ci.incrementViews(); ci.incrementDismissals(); ChannelImpressions ci2 = new ChannelImpressions(); ci2.incrementViews(); ci2.incrementDismissals(); ci2.incrementViews(); ci.append(ci2); assertEquals(3, ci.getViews()); assertEquals(2, ci.getDismissals()); assertEquals(2, ci.getStreak()); assertEquals(2, ci2.getViews()); assertEquals(1, ci2.getDismissals()); assertEquals(1, ci2.getStreak()); // no crash ci.append(null); } } services/tests/notification/src/com/android/server/notification/RankingHelperTest.java +14 −14 Original line number Diff line number Diff line Loading @@ -25,6 +25,20 @@ import static android.app.NotificationManager.IMPORTANCE_UNSPECIFIED; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.fail; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; 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.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; Loading Loading @@ -74,20 +88,6 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; 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; @SmallTest @RunWith(AndroidJUnit4.class) public class RankingHelperTest extends NotificationTestCase { Loading Loading
packages/ExtServices/src/android/ext/services/notification/Assistant.java +169 −21 Original line number Diff line number Diff line Loading @@ -23,16 +23,37 @@ import static android.service.notification.NotificationListenerService.Ranking import android.app.INotificationManager; import android.content.Context; import android.ext.services.R; import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.storage.StorageManager; 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.AtomicFile; import android.util.Log; import android.util.Slog; import android.util.Xml; import com.android.internal.util.FastXmlSerializer; import com.android.internal.util.XmlUtils; import libcore.io.IoUtils; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlSerializer; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Map; /** * Notification assistant that provides guidance on notification channel blocking Loading @@ -41,19 +62,112 @@ 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<>(); private static final String TAG_ASSISTANT = "assistant"; private static final String TAG_IMPRESSION = "impression-set"; private static final String ATT_KEY = "key"; private static final int DB_VERSION = 1; private static final String ATTR_VERSION = "version"; private static final ArrayList<Integer> PREJUDICAL_DISMISSALS = new ArrayList<>(); static { DISMISS_WITH_PREJUDICE.add(REASON_CANCEL); DISMISS_WITH_PREJUDICE.add(REASON_LISTENER_CANCEL); PREJUDICAL_DISMISSALS.add(REASON_CANCEL); PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL); } // key : impressions tracker // TODO: persist across reboots // TODO: prune deleted channels and apps ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>(); // SBN key : channel id ArrayMap<String, String> mLiveNotifications = new ArrayMap<>(); private Ranking mFakeRanking = null; private AtomicFile mFile = null; public Assistant() { } private void loadFile() { if (DEBUG) Slog.d(TAG, "loadFile"); AsyncTask.execute(() -> { InputStream infile = null; try { infile = mFile.openRead(); readXml(infile); } catch (FileNotFoundException e) { // No data yet } catch (IOException e) { Log.e(TAG, "Unable to read channel impressions", e); } catch (NumberFormatException | XmlPullParserException e) { Log.e(TAG, "Unable to parse channel impressions", e); } finally { IoUtils.closeQuietly(infile); } }); } protected void readXml(InputStream stream) throws XmlPullParserException, NumberFormatException, IOException { final XmlPullParser parser = Xml.newPullParser(); parser.setInput(stream, StandardCharsets.UTF_8.name()); final int outerDepth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, outerDepth)) { if (!TAG_ASSISTANT.equals(parser.getName())) { continue; } final int impressionOuterDepth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, impressionOuterDepth)) { if (!TAG_IMPRESSION.equals(parser.getName())) { continue; } String key = parser.getAttributeValue(null, ATT_KEY); ChannelImpressions ci = new ChannelImpressions(); ci.populateFromXml(parser); synchronized (mkeyToImpressions) { ci.append(mkeyToImpressions.get(key)); mkeyToImpressions.put(key, ci); } } } } private void saveFile() throws IOException { AsyncTask.execute(() -> { final FileOutputStream stream; try { stream = mFile.startWrite(); } catch (IOException e) { Slog.w(TAG, "Failed to save policy file", e); return; } try { final XmlSerializer out = new FastXmlSerializer(); out.setOutput(stream, StandardCharsets.UTF_8.name()); writeXml(out); mFile.finishWrite(stream); } catch (IOException e) { Slog.w(TAG, "Failed to save impressions file, restoring backup", e); mFile.failWrite(stream); } }); } protected void writeXml(XmlSerializer out) throws IOException { out.startDocument(null, true); out.startTag(null, TAG_ASSISTANT); out.attribute(null, ATTR_VERSION, Integer.toString(DB_VERSION)); synchronized (mkeyToImpressions) { for (Map.Entry<String, ChannelImpressions> entry : mkeyToImpressions.entrySet()) { // TODO: ensure channel still exists out.startTag(null, TAG_IMPRESSION); out.attribute(null, ATT_KEY, entry.getKey()); entry.getValue().writeXml(out); out.endTag(null, TAG_IMPRESSION); } } out.endTag(null, TAG_ASSISTANT); out.endDocument(); } @Override public Adjustment onNotificationEnqueued(StatusBarNotification sbn) { Loading Loading @@ -87,26 +201,38 @@ public class Assistant extends NotificationAssistantService { public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, NotificationStats stats, int reason) { try { boolean updatedImpressions = false; String channelId = mLiveNotifications.remove(sbn.getKey()); String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId); ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions()); synchronized (mkeyToImpressions) { ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions()); if (stats.hasSeen()) { ci.incrementViews(); updatedImpressions = true; } if (DISMISS_WITH_PREJUDICE.contains(reason) && !sbn.isAppGroup() && !sbn.getNotification().isGroupChild() if (PREJUDICAL_DISMISSALS.contains(reason)) { if ((!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"); if (DEBUG) Log.i(TAG, "increment dismissals " + key); ci.incrementDismissals(); updatedImpressions = true; } else { if (DEBUG) Slog.i(TAG, "reset streak"); if (DEBUG) Slog.i(TAG, "reset streak " + key); if (ci.getStreak() > 0) { updatedImpressions = true; } ci.resetStreak(); } } mkeyToImpressions.put(key, ci); } if (updatedImpressions) { saveFile(); } } catch (Throwable e) { Slog.e(TAG, "Error occurred processing removal", e); } Loading @@ -121,6 +247,11 @@ public class Assistant extends NotificationAssistantService { public void onListenerConnected() { if (DEBUG) Log.i(TAG, "CONNECTED"); try { mFile = new AtomicFile(new File(new File( Environment.getDataUserCePackageDirectory( StorageManager.UUID_PRIVATE_INTERNAL, getUserId(), getPackageName()), "assistant"), "block_stats.xml")); loadFile(); for (StatusBarNotification sbn : getActiveNotifications()) { onNotificationPosted(sbn); } Loading @@ -129,7 +260,7 @@ public class Assistant extends NotificationAssistantService { } } private String getKey(String pkg, int userId, String channelId) { protected String getKey(String pkg, int userId, String channelId) { return pkg + "|" + userId + "|" + channelId; } Loading @@ -151,6 +282,11 @@ public class Assistant extends NotificationAssistantService { } // for testing protected void setFile(AtomicFile file) { mFile = file; } protected void setFakeRanking(Ranking ranking) { mFakeRanking = ranking; } Loading @@ -162,4 +298,16 @@ public class Assistant extends NotificationAssistantService { protected void setContext(Context context) { mSystemContext = context; } protected ChannelImpressions getImpressions(String key) { synchronized (mkeyToImpressions) { return mkeyToImpressions.get(key); } } protected void insertImpressions(String key, ChannelImpressions ci) { synchronized (mkeyToImpressions) { mkeyToImpressions.put(key, ci); } } } No newline at end of file
packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java +49 −0 Original line number Diff line number Diff line Loading @@ -18,14 +18,23 @@ package android.ext.services.notification; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; import android.util.Log; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; 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; static final String ATT_DISMISSALS = "dismisses"; static final String ATT_VIEWS = "views"; static final String ATT_STREAK = "streak"; private int mDismissals = 0; private int mViews = 0; Loading Loading @@ -62,6 +71,14 @@ public final class ChannelImpressions implements Parcelable { mStreak++; } public void append(ChannelImpressions additionalImpressions) { if (additionalImpressions != null) { mViews += additionalImpressions.getViews(); mStreak += additionalImpressions.getStreak(); mDismissals += additionalImpressions.getDismissals(); } } public void incrementViews() { mViews++; } Loading Loading @@ -134,4 +151,36 @@ public final class ChannelImpressions implements Parcelable { sb.append('}'); return sb.toString(); } protected void populateFromXml(XmlPullParser parser) { mDismissals = safeInt(parser, ATT_DISMISSALS, 0); mStreak = safeInt(parser, ATT_STREAK, 0); mViews = safeInt(parser, ATT_VIEWS, 0); } protected void writeXml(XmlSerializer out) throws IOException { if (mDismissals != 0) { out.attribute(null, ATT_DISMISSALS, String.valueOf(mDismissals)); } if (mStreak != 0) { out.attribute(null, ATT_STREAK, String.valueOf(mStreak)); } if (mViews != 0) { out.attribute(null, ATT_VIEWS, String.valueOf(mViews)); } } private static int safeInt(XmlPullParser parser, String att, int defValue) { final String val = parser.getAttributeValue(null, att); return tryParseInt(val, defValue); } private static int tryParseInt(String value, int defValue) { if (TextUtils.isEmpty(value)) return defValue; try { return Integer.parseInt(value); } catch (NumberFormatException e) { return defValue; } } }
packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java +134 −8 Original line number Diff line number Diff line Loading @@ -31,7 +31,6 @@ 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; Loading @@ -42,6 +41,10 @@ import android.service.notification.StatusBarNotification; import android.support.test.InstrumentationRegistry; import android.test.ServiceTestCase; import android.testing.TestableContext; import android.util.AtomicFile; import android.util.Xml; import com.android.internal.util.FastXmlSerializer; import org.junit.Before; import org.junit.Rule; Loading @@ -49,6 +52,14 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; public class AssistantTest extends ServiceTestCase<Assistant> { Loading @@ -67,6 +78,8 @@ public class AssistantTest extends ServiceTestCase<Assistant> { new NotificationChannel("one", "", IMPORTANCE_LOW); @Mock INotificationManager mNoMan; @Mock AtomicFile mFile; Assistant mAssistant; Loading @@ -88,6 +101,8 @@ public class AssistantTest extends ServiceTestCase<Assistant> { bindService(startIntent); mAssistant = getService(); mAssistant.setNoMan(mNoMan); mAssistant.setFile(mFile); when(mFile.startWrite()).thenReturn(mock(FileOutputStream.class)); } private StatusBarNotification generateSbn(String pkg, int uid, NotificationChannel channel, Loading Loading @@ -170,18 +185,43 @@ public class AssistantTest extends ServiceTestCase<Assistant> { } @Test public void testGroupCannotTriggerAdjustment() throws Exception { public void testGroupChildCanTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", "I HAVE A GROUP"); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", null); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", "group"); 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 testGroupSummaryCannotTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", "I HAVE A GROUP"); sbn.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY; mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); sbn = generateSbn(PKG1, UID1, P1C1, "new one!", "group"); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any()); Loading @@ -192,10 +232,11 @@ public class AssistantTest extends ServiceTestCase<Assistant> { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_AOD); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); Loading @@ -208,13 +249,13 @@ public class AssistantTest extends ServiceTestCase<Assistant> { @Test public void testInteractedCannotTriggerAdjustment() throws Exception { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); stats.setExpanded(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_CANCEL); Loading @@ -229,10 +270,11 @@ public class AssistantTest extends ServiceTestCase<Assistant> { almostBlockChannel(PKG1, UID1, P1C1); StatusBarNotification sbn = generateSbn(PKG1, UID1, P1C1, "no", null); mAssistant.setFakeRanking(mock(Ranking.class)); mAssistant.setFakeRanking(generateRanking(sbn, P1C1)); NotificationStats stats = new NotificationStats(); stats.setDismissalSurface(NotificationStats.DISMISSAL_SHADE); stats.setSeen(); mAssistant.onNotificationPosted(sbn, mock(RankingMap.class)); mAssistant.onNotificationRemoved( sbn, mock(RankingMap.class), stats, NotificationListenerService.REASON_APP_CANCEL); Loading Loading @@ -265,4 +307,88 @@ public class AssistantTest extends ServiceTestCase<Assistant> { verify(mNoMan, never()).applyAdjustmentFromAssistant(any(), any()); } @Test public void testReadXml() throws Exception { String key1 = mAssistant.getKey("pkg1", 1, "channel1"); int streak1 = 2; int views1 = 5; int dismiss1 = 9; int streak1a = 3; int views1a = 10; int dismiss1a = 99; String key1a = mAssistant.getKey("pkg1", 1, "channel1a"); int streak2 = 7; int views2 = 77; int dismiss2 = 777; String key2 = mAssistant.getKey("pkg2", 2, "channel2"); String xml = "<assistant version=\"1\">\n" + "<impression-set key=\"" + key1 + "\" " + "dismisses=\"" + dismiss1 + "\" views=\"" + views1 + "\" streak=\"" + streak1 + "\"/>\n" + "<impression-set key=\"" + key1a + "\" " + "dismisses=\"" + dismiss1a + "\" views=\"" + views1a + "\" streak=\"" + streak1a + "\"/>\n" + "<impression-set key=\"" + key2 + "\" " + "dismisses=\"" + dismiss2 + "\" views=\"" + views2 + "\" streak=\"" + streak2 + "\"/>\n" + "</assistant>\n"; mAssistant.readXml(new BufferedInputStream(new ByteArrayInputStream(xml.getBytes()))); ChannelImpressions c1 = mAssistant.getImpressions(key1); assertEquals(2, c1.getStreak()); assertEquals(5, c1.getViews()); assertEquals(9, c1.getDismissals()); ChannelImpressions c1a = mAssistant.getImpressions(key1a); assertEquals(3, c1a.getStreak()); assertEquals(10, c1a.getViews()); assertEquals(99, c1a.getDismissals()); ChannelImpressions c2 = mAssistant.getImpressions(key2); assertEquals(7, c2.getStreak()); assertEquals(77, c2.getViews()); assertEquals(777, c2.getDismissals()); } @Test public void testRoundTripXml() throws Exception { String key1 = mAssistant.getKey("pkg1", 1, "channel1"); ChannelImpressions ci1 = new ChannelImpressions(9, 10); String key2 = mAssistant.getKey("pkg1", 1, "channel2"); ChannelImpressions ci2 = new ChannelImpressions(); for (int i = 0; i < 3; i++) { ci2.incrementViews(); ci2.incrementDismissals(); } ChannelImpressions ci3 = new ChannelImpressions(); String key3 = mAssistant.getKey("pkg3", 3, "channel2"); for (int i = 0; i < 9; i++) { ci3.incrementViews(); if (i % 3 == 0) { ci3.incrementDismissals(); } } mAssistant.insertImpressions(key1, ci1); mAssistant.insertImpressions(key2, ci2); mAssistant.insertImpressions(key3, ci3); XmlSerializer serializer = new FastXmlSerializer(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); serializer.setOutput(new BufferedOutputStream(baos), "utf-8"); mAssistant.writeXml(serializer); Assistant assistant = new Assistant(); assistant.readXml(new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray()))); assertEquals(ci1, assistant.getImpressions(key1)); assertEquals(ci2, assistant.getImpressions(key2)); assertEquals(ci3, assistant.getImpressions(key3)); } }
packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java +26 −0 Original line number Diff line number Diff line Loading @@ -21,6 +21,8 @@ import static android.ext.services.notification.ChannelImpressions.STREAK_LIMIT; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertEquals; import org.junit.Test; public class ChannelImpressionsTest { Loading Loading @@ -82,4 +84,28 @@ public class ChannelImpressionsTest { assertFalse(ci.shouldTriggerBlock()); } @Test public void testAppend() { ChannelImpressions ci = new ChannelImpressions(); ci.incrementViews(); ci.incrementDismissals(); ChannelImpressions ci2 = new ChannelImpressions(); ci2.incrementViews(); ci2.incrementDismissals(); ci2.incrementViews(); ci.append(ci2); assertEquals(3, ci.getViews()); assertEquals(2, ci.getDismissals()); assertEquals(2, ci.getStreak()); assertEquals(2, ci2.getViews()); assertEquals(1, ci2.getDismissals()); assertEquals(1, ci2.getStreak()); // no crash ci.append(null); } }
services/tests/notification/src/com/android/server/notification/RankingHelperTest.java +14 −14 Original line number Diff line number Diff line Loading @@ -25,6 +25,20 @@ import static android.app.NotificationManager.IMPORTANCE_UNSPECIFIED; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.fail; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; 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.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; Loading Loading @@ -74,20 +88,6 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; 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; @SmallTest @RunWith(AndroidJUnit4.class) public class RankingHelperTest extends NotificationTestCase { Loading