diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index 803abf3f5f0da..98d9bea442f95 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -12429,7 +12429,7 @@ public final class Settings { */ public static final String MULTI_SIM_SMS_SUBSCRIPTION = "multi_sim_sms"; - /** + /** * Used to provide option to user to select subscription during send SMS. * The value 1 - enable, 0 - disable * @hide @@ -12570,6 +12570,28 @@ public final class Settings { public static final String NOTIFICATION_SNOOZE_OPTIONS = "notification_snooze_options"; + /** + * Settings key for the ratio of notification dismissals to notification views - one of the + * criteria for showing the notification blocking helper. + * + *

The value is a float ranging from 0.0 to 1.0 (the closer to 0.0, the more intrusive + * the blocking helper will be). + * + * @hide + */ + public static final String BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT = + "blocking_helper_dismiss_to_view_ratio"; + + /** + * Settings key for the longest streak of dismissals - one of the criteria for showing the + * notification blocking helper. + * + *

The value is an integer greater than 0. + * + * @hide + */ + public static final String BLOCKING_HELPER_STREAK_LIMIT = "blocking_helper_streak_limit"; + /** * Configuration flags for SQLite Compatibility WAL. Encoded as a key-value list, separated * by commas. E.g.: compatibility_wal_supported=true, wal_syncmode=OFF diff --git a/core/java/android/service/notification/NotificationAssistantService.java b/core/java/android/service/notification/NotificationAssistantService.java index 8e52bfa80edaa..18e0ab00a78b8 100644 --- a/core/java/android/service/notification/NotificationAssistantService.java +++ b/core/java/android/service/notification/NotificationAssistantService.java @@ -48,7 +48,10 @@ public abstract class NotificationAssistantService extends NotificationListenerS public static final String SERVICE_INTERFACE = "android.service.notification.NotificationAssistantService"; - private Handler mHandler; + /** + * @hide + */ + protected Handler mHandler; @Override protected void attachBaseContext(Context base) { diff --git a/core/tests/coretests/src/android/provider/SettingsBackupTest.java b/core/tests/coretests/src/android/provider/SettingsBackupTest.java index db221cd7d8b93..ceb58f6c8c00a 100644 --- a/core/tests/coretests/src/android/provider/SettingsBackupTest.java +++ b/core/tests/coretests/src/android/provider/SettingsBackupTest.java @@ -131,6 +131,8 @@ public class SettingsBackupTest { Settings.Global.BLE_SCAN_LOW_LATENCY_WINDOW_MS, Settings.Global.BLE_SCAN_LOW_LATENCY_INTERVAL_MS, Settings.Global.BLE_SCAN_BACKGROUND_MODE, + Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, + Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, Settings.Global.BLUETOOTH_A2DP_SINK_PRIORITY_PREFIX, Settings.Global.BLUETOOTH_A2DP_SRC_PRIORITY_PREFIX, Settings.Global.BLUETOOTH_A2DP_SUPPORTS_OPTIONAL_CODECS_PREFIX, diff --git a/packages/ExtServices/src/android/ext/services/notification/Assistant.java b/packages/ExtServices/src/android/ext/services/notification/Assistant.java index 9a66b07fb74f1..a2d2ddfa445fe 100644 --- a/packages/ExtServices/src/android/ext/services/notification/Assistant.java +++ b/packages/ExtServices/src/android/ext/services/notification/Assistant.java @@ -17,16 +17,20 @@ package android.ext.services.notification; import static android.app.NotificationManager.IMPORTANCE_MIN; -import static android.service.notification.NotificationListenerService.Ranking - .USER_SENTIMENT_NEGATIVE; +import static android.service.notification.NotificationListenerService.Ranking.USER_SENTIMENT_NEGATIVE; import android.app.INotificationManager; +import android.content.ContentResolver; import android.content.Context; +import android.database.ContentObserver; import android.ext.services.R; +import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; +import android.os.Handler; import android.os.storage.StorageManager; +import android.provider.Settings; import android.service.notification.Adjustment; import android.service.notification.NotificationAssistantService; import android.service.notification.NotificationStats; @@ -74,9 +78,12 @@ public class Assistant extends NotificationAssistantService { PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL); } + private float mDismissToViewRatioLimit; + private int mStreakLimit; + // key : impressions tracker // TODO: prune deleted channels and apps - ArrayMap mkeyToImpressions = new ArrayMap<>(); + final ArrayMap mkeyToImpressions = new ArrayMap<>(); // SBN key : channel id ArrayMap mLiveNotifications = new ArrayMap<>(); @@ -86,6 +93,14 @@ public class Assistant extends NotificationAssistantService { public Assistant() { } + @Override + public void onCreate() { + super.onCreate(); + // Contexts are correctly hooked up by the creation step, which is required for the observer + // to be hooked up/initialized. + new SettingsObserver(mHandler); + } + private void loadFile() { if (DEBUG) Slog.d(TAG, "loadFile"); AsyncTask.execute(() -> { @@ -120,7 +135,7 @@ public class Assistant extends NotificationAssistantService { continue; } String key = parser.getAttributeValue(null, ATT_KEY); - ChannelImpressions ci = new ChannelImpressions(); + ChannelImpressions ci = createChannelImpressionsWithThresholds(); ci.populateFromXml(parser); synchronized (mkeyToImpressions) { ci.append(mkeyToImpressions.get(key)); @@ -184,7 +199,7 @@ public class Assistant extends NotificationAssistantService { String key = getKey( sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId()); ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, - new ChannelImpressions()); + createChannelImpressionsWithThresholds()); if (ranking.getImportance() > IMPORTANCE_MIN && ci.shouldTriggerBlock()) { adjustNotification(createNegativeAdjustment( sbn.getPackageName(), sbn.getKey(), sbn.getUserId())); @@ -206,7 +221,7 @@ public class Assistant extends NotificationAssistantService { String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId); synchronized (mkeyToImpressions) { ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, - new ChannelImpressions()); + createChannelImpressionsWithThresholds()); if (stats.hasSeen()) { ci.incrementViews(); updatedImpressions = true; @@ -310,4 +325,58 @@ public class Assistant extends NotificationAssistantService { mkeyToImpressions.put(key, ci); } } + + private ChannelImpressions createChannelImpressionsWithThresholds() { + ChannelImpressions impressions = new ChannelImpressions(); + impressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit); + return impressions; + } + + /** + * Observer for updates on blocking helper threshold values. + */ + private final class SettingsObserver extends ContentObserver { + private final Uri STREAK_LIMIT_URI = + Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT); + private final Uri DISMISS_TO_VIEW_RATIO_LIMIT_URI = + Settings.Global.getUriFor( + Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT); + + public SettingsObserver(Handler handler) { + super(handler); + ContentResolver resolver = getApplicationContext().getContentResolver(); + resolver.registerContentObserver( + DISMISS_TO_VIEW_RATIO_LIMIT_URI, false, this, getUserId()); + resolver.registerContentObserver(STREAK_LIMIT_URI, false, this, getUserId()); + + // Update all uris on creation. + update(null); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + update(uri); + } + + private void update(Uri uri) { + ContentResolver resolver = getApplicationContext().getContentResolver(); + if (uri == null || DISMISS_TO_VIEW_RATIO_LIMIT_URI.equals(uri)) { + mDismissToViewRatioLimit = Settings.Global.getFloat( + resolver, Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, + ChannelImpressions.DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT); + } + if (uri == null || STREAK_LIMIT_URI.equals(uri)) { + mStreakLimit = Settings.Global.getInt( + resolver, Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, + ChannelImpressions.DEFAULT_STREAK_LIMIT); + } + + // Update all existing channel impression objects with any new limits/thresholds. + synchronized (mkeyToImpressions) { + for (ChannelImpressions channelImpressions: mkeyToImpressions.values()) { + channelImpressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit); + } + } + } + } } \ No newline at end of file diff --git a/packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java b/packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java index de2659f496345..8908ebd2bc65b 100644 --- a/packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java +++ b/packages/ExtServices/src/android/ext/services/notification/ChannelImpressions.java @@ -21,6 +21,8 @@ import android.os.Parcelable; import android.text.TextUtils; import android.util.Log; +import com.android.internal.annotations.VisibleForTesting; + import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlSerializer; @@ -30,8 +32,8 @@ 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 = .4; - static final int STREAK_LIMIT = 2; + static final float DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT = .4f; + static final int DEFAULT_STREAK_LIMIT = 2; static final String ATT_DISMISSALS = "dismisses"; static final String ATT_VIEWS = "views"; static final String ATT_STREAK = "streak"; @@ -40,18 +42,20 @@ public final class ChannelImpressions implements Parcelable { private int mViews = 0; private int mStreak = 0; - public ChannelImpressions() { - } + private float mDismissToViewRatioLimit; + private int mStreakLimit; - public ChannelImpressions(int dismissals, int views) { - mDismissals = dismissals; - mViews = views; + public ChannelImpressions() { + mDismissToViewRatioLimit = DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT; + mStreakLimit = DEFAULT_STREAK_LIMIT; } protected ChannelImpressions(Parcel in) { mDismissals = in.readInt(); mViews = in.readInt(); mStreak = in.readInt(); + mDismissToViewRatioLimit = in.readFloat(); + mStreakLimit = in.readInt(); } public int getStreak() { @@ -71,6 +75,21 @@ public final class ChannelImpressions implements Parcelable { mStreak++; } + void updateThresholds(float dismissToViewRatioLimit, int streakLimit) { + mDismissToViewRatioLimit = dismissToViewRatioLimit; + mStreakLimit = streakLimit; + } + + @VisibleForTesting + float getDismissToViewRatioLimit() { + return mDismissToViewRatioLimit; + } + + @VisibleForTesting + int getStreakLimit() { + return mStreakLimit; + } + public void append(ChannelImpressions additionalImpressions) { if (additionalImpressions != null) { mViews += additionalImpressions.getViews(); @@ -94,8 +113,8 @@ public final class ChannelImpressions implements Parcelable { if (DEBUG) { Log.d(TAG, "should trigger? " + getDismissals() + " " + getViews() + " " + getStreak()); } - return ((double) getDismissals() / getViews()) > DISMISS_TO_VIEW_RATIO_LIMIT - && getStreak() > STREAK_LIMIT; + return ((float) getDismissals() / getViews()) > mDismissToViewRatioLimit + && getStreak() > mStreakLimit; } @Override @@ -103,6 +122,8 @@ public final class ChannelImpressions implements Parcelable { dest.writeInt(mDismissals); dest.writeInt(mViews); dest.writeInt(mStreak); + dest.writeFloat(mDismissToViewRatioLimit); + dest.writeInt(mStreakLimit); } @Override @@ -148,7 +169,9 @@ public final class ChannelImpressions implements Parcelable { sb.append("mDismissals=").append(mDismissals); sb.append(", mViews=").append(mViews); sb.append(", mStreak=").append(mStreak); - sb.append('}'); + sb.append(", thresholds=(").append(mDismissToViewRatioLimit); + sb.append(",").append(mStreakLimit); + sb.append(")}"); return sb.toString(); } diff --git a/packages/ExtServices/tests/AndroidManifest.xml b/packages/ExtServices/tests/AndroidManifest.xml index e6c7b9785784b..ddf725b8cdfe2 100644 --- a/packages/ExtServices/tests/AndroidManifest.xml +++ b/packages/ExtServices/tests/AndroidManifest.xml @@ -17,6 +17,8 @@ + + diff --git a/packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java b/packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java index db48f610471da..a6b6a6b61581f 100644 --- a/packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java +++ b/packages/ExtServices/tests/src/android/ext/services/notification/AssistantTest.java @@ -20,6 +20,7 @@ import static android.app.NotificationManager.IMPORTANCE_DEFAULT; import static android.app.NotificationManager.IMPORTANCE_LOW; import static android.app.NotificationManager.IMPORTANCE_MIN; +import static junit.framework.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -27,11 +28,15 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.Application; import android.app.INotificationManager; import android.app.Notification; import android.app.NotificationChannel; +import android.content.ContentResolver; +import android.content.IContentProvider; import android.content.Intent; import android.os.UserHandle; +import android.provider.Settings; import android.service.notification.Adjustment; import android.service.notification.NotificationListenerService; import android.service.notification.NotificationListenerService.Ranking; @@ -78,10 +83,10 @@ public class AssistantTest extends ServiceTestCase { new NotificationChannel("one", "", IMPORTANCE_LOW); @Mock INotificationManager mNoMan; - @Mock - AtomicFile mFile; + @Mock AtomicFile mFile; Assistant mAssistant; + Application mApplication; @Rule public final TestableContext mContext = @@ -98,6 +103,16 @@ public class AssistantTest extends ServiceTestCase { Intent startIntent = new Intent("android.service.notification.NotificationAssistantService"); startIntent.setPackage("android.ext.services"); + + // To bypass real calls to global settings values, set the Settings values here. + Settings.Global.putFloat(mContext.getContentResolver(), + Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, 0.8f); + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, 2); + mApplication = (Application) InstrumentationRegistry.getInstrumentation(). + getTargetContext().getApplicationContext(); + // Force the test to use the correct application instead of trying to use a mock application + setApplication(mApplication); bindService(startIntent); mAssistant = getService(); mAssistant.setNoMan(mNoMan); @@ -128,7 +143,7 @@ public class AssistantTest extends ServiceTestCase { } private void almostBlockChannel(String pkg, int uid, NotificationChannel channel) { - for (int i = 0; i < ChannelImpressions.STREAK_LIMIT; i++) { + for (int i = 0; i < ChannelImpressions.DEFAULT_STREAK_LIMIT; i++) { dismissBadNotification(pkg, uid, channel, String.valueOf(i)); } } @@ -358,7 +373,7 @@ public class AssistantTest extends ServiceTestCase { @Test public void testRoundTripXml() throws Exception { String key1 = mAssistant.getKey("pkg1", 1, "channel1"); - ChannelImpressions ci1 = new ChannelImpressions(9, 10); + ChannelImpressions ci1 = new ChannelImpressions(); String key2 = mAssistant.getKey("pkg1", 1, "channel2"); ChannelImpressions ci2 = new ChannelImpressions(); for (int i = 0; i < 3; i++) { @@ -391,4 +406,43 @@ public class AssistantTest extends ServiceTestCase { assertEquals(ci3, assistant.getImpressions(key3)); } + @Test + public void testSettingsProviderUpdate() { + ContentResolver resolver = mApplication.getContentResolver(); + + // Set up channels + String key = mAssistant.getKey("pkg1", 1, "channel1"); + ChannelImpressions ci = new ChannelImpressions(); + for (int i = 0; i < 3; i++) { + ci.incrementViews(); + if (i % 2 == 0) { + ci.incrementDismissals(); + } + } + + mAssistant.insertImpressions(key, ci); + + // With default values, the blocking helper shouldn't be triggered. + assertEquals(false, ci.shouldTriggerBlock()); + + // Update settings values. + float newDismissToViewRatioLimit = 0f; + int newStreakLimit = 0; + Settings.Global.putFloat(resolver, + Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT, + newDismissToViewRatioLimit); + Settings.Global.putInt(resolver, + Settings.Global.BLOCKING_HELPER_STREAK_LIMIT, newStreakLimit); + + // Notify for the settings values we updated. + resolver.notifyChange( + Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT), null); + resolver.notifyChange( + Settings.Global.getUriFor( + Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT), + null); + + // With the new threshold, the blocking helper should be triggered. + assertEquals(true, ci.shouldTriggerBlock()); + } } diff --git a/packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java b/packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java index d28e2ac0cffe0..3253802bec039 100644 --- a/packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java +++ b/packages/ExtServices/tests/src/android/ext/services/notification/ChannelImpressionsTest.java @@ -16,7 +16,8 @@ package android.ext.services.notification; -import static android.ext.services.notification.ChannelImpressions.STREAK_LIMIT; +import static android.ext.services.notification.ChannelImpressions.DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT; +import static android.ext.services.notification.ChannelImpressions.DEFAULT_STREAK_LIMIT; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; @@ -37,7 +38,7 @@ public class ChannelImpressionsTest { public void testNoStreakNoBlock() { ChannelImpressions ci = new ChannelImpressions(); - for (int i = 0; i < STREAK_LIMIT - 1; i++) { + for (int i = 0; i < DEFAULT_STREAK_LIMIT - 1; i++) { ci.incrementViews(); ci.incrementDismissals(); } @@ -49,10 +50,10 @@ public class ChannelImpressionsTest { public void testNoStreakNoBlock_breakStreak() { ChannelImpressions ci = new ChannelImpressions(); - for (int i = 0; i < STREAK_LIMIT; i++) { + for (int i = 0; i < DEFAULT_STREAK_LIMIT; i++) { ci.incrementViews(); ci.incrementDismissals(); - if (i == STREAK_LIMIT - 1) { + if (i == DEFAULT_STREAK_LIMIT - 1) { ci.resetStreak(); } } @@ -64,7 +65,7 @@ public class ChannelImpressionsTest { public void testStreakBlock() { ChannelImpressions ci = new ChannelImpressions(); - for (int i = 0; i <= STREAK_LIMIT; i++) { + for (int i = 0; i <= DEFAULT_STREAK_LIMIT; i++) { ci.incrementViews(); ci.incrementDismissals(); } @@ -76,7 +77,7 @@ public class ChannelImpressionsTest { public void testRatio_NoBlockEvenWithStreak() { ChannelImpressions ci = new ChannelImpressions(); - for (int i = 0; i < STREAK_LIMIT; i++) { + for (int i = 0; i < DEFAULT_STREAK_LIMIT; i++) { ci.incrementViews(); ci.incrementDismissals(); ci.incrementViews(); @@ -108,4 +109,53 @@ public class ChannelImpressionsTest { // no crash ci.append(null); } + + @Test + public void testUpdateThresholds_streakLimitsCorrectlyApplied() { + int updatedStreakLimit = DEFAULT_STREAK_LIMIT + 3; + ChannelImpressions ci = new ChannelImpressions(); + ci.updateThresholds(DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT, updatedStreakLimit); + + for (int i = 0; i <= updatedStreakLimit; i++) { + ci.incrementViews(); + ci.incrementDismissals(); + } + + ChannelImpressions ci2 = new ChannelImpressions(); + ci2.updateThresholds(DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT, updatedStreakLimit); + + for (int i = 0; i < updatedStreakLimit; i++) { + ci2.incrementViews(); + ci2.incrementDismissals(); + } + + assertTrue(ci.shouldTriggerBlock()); + assertFalse(ci2.shouldTriggerBlock()); + } + + @Test + public void testUpdateThresholds_ratioLimitsCorrectlyApplied() { + float updatedDismissRatio = .99f; + ChannelImpressions ci = new ChannelImpressions(); + ci.updateThresholds(updatedDismissRatio, DEFAULT_STREAK_LIMIT); + + // N views, N-1 dismissals, which doesn't satisfy the ratio = 1 criteria. + for (int i = 0; i <= DEFAULT_STREAK_LIMIT; i++) { + ci.incrementViews(); + if (i != DEFAULT_STREAK_LIMIT) { + ci.incrementDismissals(); + } + } + + ChannelImpressions ci2 = new ChannelImpressions(); + ci2.updateThresholds(updatedDismissRatio, DEFAULT_STREAK_LIMIT); + + for (int i = 0; i <= DEFAULT_STREAK_LIMIT; i++) { + ci2.incrementViews(); + ci2.incrementDismissals(); + } + + assertFalse(ci.shouldTriggerBlock()); + assertTrue(ci2.shouldTriggerBlock()); + } }