Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 09027467 authored by Jan Tomljanovic's avatar Jan Tomljanovic
Browse files

Implement rate limiting toasts.

We rate limit showing toasts on a per package basis. Each time the app
hits the limit, any further toast attempted to be shown will be
discarded. Specific rate limits are designed in a way such that if the
app continuously posts toasts, the period for which it will be blocked
from posting gradually increases each time it hits the limit.

Test: atest android.widget.cts.ToastTest
Test: atest NotificationManagerServiceTest
Bug: 154198299
Change-Id: I41656745cbd4e6cb6650cf4100ca32a09dc67810
parent 66b8a14a
Loading
Loading
Loading
Loading
+51 −3
Original line number Diff line number Diff line
@@ -276,6 +276,7 @@ import com.android.server.pm.PackageManagerService;
import com.android.server.policy.PhoneWindowManager;
import com.android.server.statusbar.StatusBarManagerInternal;
import com.android.server.uri.UriGrantsManagerInternal;
import com.android.server.utils.quota.MultiRateLimiter;
import com.android.server.wm.ActivityTaskManagerInternal;
import com.android.server.wm.BackgroundActivityStartCallback;
import com.android.server.wm.WindowManagerInternal;
@@ -299,6 +300,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
@@ -372,6 +374,20 @@ public class NotificationManagerService extends SystemService {
            RoleManager.ROLE_EMERGENCY
    };

    // Used for rate limiting toasts by package.
    static final String TOAST_QUOTA_TAG = "toast_quota_tag";

    // This constant defines rate limits applied to showing toasts. The numbers are set in a way
    // such that an aggressive toast showing strategy would result in a roughly 1.5x longer wait
    // time (before the package is allowed to show toasts again) each time the toast rate limit is
    // reached. It's meant to protect the user against apps spamming them with toasts (either
    // accidentally or on purpose).
    private static final MultiRateLimiter.RateLimit[] TOAST_RATE_LIMITS = {
            MultiRateLimiter.RateLimit.create(3, Duration.ofSeconds(20)),
            MultiRateLimiter.RateLimit.create(5, Duration.ofSeconds(42)),
            MultiRateLimiter.RateLimit.create(6, Duration.ofSeconds(68)),
    };

    // When #matchesCallFilter is called from the ringer, wait at most
    // 3s to resolve the contacts. This timeout is required since
    // ContactsProvider might take a long time to start up.
@@ -423,6 +439,16 @@ public class NotificationManagerService extends SystemService {
    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R)
    private static final long NOTIFICATION_TRAMPOLINE_BLOCK = 167676448L;

    /**
     * Rate limit showing toasts, on a per package basis.
     *
     * It limits the effects of {@link android.widget.Toast#show()} calls to prevent overburdening
     * the user with too many toasts in a limited time. Any attempt to show more toasts than allowed
     * in a certain time frame will result in the toast being discarded.
     */
    @ChangeId
    private static final long RATE_LIMIT_TOASTS = 154198299L;

    private IActivityManager mAm;
    private ActivityTaskManagerInternal mAtm;
    private ActivityManager mActivityManager;
@@ -501,6 +527,9 @@ public class NotificationManagerService extends SystemService {
    @GuardedBy("mToastQueue")
    private boolean mIsCurrentToastShown = false;

    // Used for rate limiting toasts by package.
    private MultiRateLimiter mToastRateLimiter;

    // The last key in this list owns the hardware.
    ArrayList<String> mLights = new ArrayList<>();

@@ -1907,7 +1936,8 @@ public class NotificationManagerService extends SystemService {
            DevicePolicyManagerInternal dpm, IUriGrantsManager ugm,
            UriGrantsManagerInternal ugmInternal, AppOpsManager appOps, UserManager userManager,
            NotificationHistoryManager historyManager, StatsManager statsManager,
            TelephonyManager telephonyManager, ActivityManagerInternal ami) {
            TelephonyManager telephonyManager, ActivityManagerInternal ami,
            MultiRateLimiter toastRateLimiter) {
        mHandler = handler;
        Resources resources = getContext().getResources();
        mMaxPackageEnqueueRate = Settings.Global.getFloat(getContext().getContentResolver(),
@@ -2099,6 +2129,8 @@ public class NotificationManagerService extends SystemService {
                com.android.internal.R.array.config_notificationMsgPkgsAllowedAsConvos));
        mStatsManager = statsManager;

        mToastRateLimiter = toastRateLimiter;

        // register for various Intents.
        // If this is called within a test, make sure to unregister the intent receivers by
        // calling onDestroy()
@@ -2209,7 +2241,8 @@ public class NotificationManagerService extends SystemService {
                mStatsManager = (StatsManager) getContext().getSystemService(
                        Context.STATS_MANAGER),
                getContext().getSystemService(TelephonyManager.class),
                LocalServices.getService(ActivityManagerInternal.class));
                LocalServices.getService(ActivityManagerInternal.class),
                createToastRateLimiter());

        publishBinderService(Context.NOTIFICATION_SERVICE, mService, /* allowIsolated= */ false,
                DUMP_FLAG_PRIORITY_CRITICAL | DUMP_FLAG_PRIORITY_NORMAL);
@@ -2847,6 +2880,10 @@ public class NotificationManagerService extends SystemService {
        return mInternalService;
    }

    private MultiRateLimiter createToastRateLimiter() {
        return new MultiRateLimiter.Builder(getContext()).addRateLimits(TOAST_RATE_LIMITS).build();
    }

    @VisibleForTesting
    final IBinder mService = new INotificationManager.Stub() {
        // Toasts
@@ -7310,10 +7347,21 @@ public class NotificationManagerService extends SystemService {

        ToastRecord record = mToastQueue.get(0);
        while (record != null) {
            if (record.show()) {
            int userId = UserHandle.getUserId(record.uid);
            boolean rateLimitingEnabled =
                    CompatChanges.isChangeEnabled(RATE_LIMIT_TOASTS, record.uid);
            boolean isWithinQuota =
                    mToastRateLimiter.isWithinQuota(userId, record.pkg, TOAST_QUOTA_TAG);
            if ((!rateLimitingEnabled || isWithinQuota) && record.show()) {
                scheduleDurationReachedLocked(record);
                mIsCurrentToastShown = true;
                if (rateLimitingEnabled) {
                    mToastRateLimiter.noteEvent(userId, record.pkg, TOAST_QUOTA_TAG);
                }
                return;
            } else if (rateLimitingEnabled && !isWithinQuota) {
                Slog.w(TAG, "Package " + record.pkg + " is above allowed toast quota, the "
                        + "following toast was blocked and discarded: " + record);
            }
            int index = mToastQueue.indexOf(record);
            if (index >= 0) {
+67 −2
Original line number Diff line number Diff line
@@ -182,6 +182,7 @@ import com.android.server.notification.NotificationManagerService.NotificationAs
import com.android.server.notification.NotificationManagerService.NotificationListeners;
import com.android.server.statusbar.StatusBarManagerInternal;
import com.android.server.uri.UriGrantsManagerInternal;
import com.android.server.utils.quota.MultiRateLimiter;
import com.android.server.wm.ActivityTaskManagerInternal;
import com.android.server.wm.WindowManagerInternal;

@@ -296,6 +297,8 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
    NotificationHistoryManager mHistoryManager;
    @Mock
    StatsManager mStatsManager;
    @Mock
    MultiRateLimiter mToastRateLimiter;
    BroadcastReceiver mPackageIntentReceiver;
    NotificationRecordLoggerFake mNotificationRecordLogger = new NotificationRecordLoggerFake();
    private InstanceIdSequence mNotificationInstanceIdSequence = new InstanceIdSequenceFake(
@@ -485,7 +488,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
                mGroupHelper, mAm, mAtm, mAppUsageStats,
                mock(DevicePolicyManagerInternal.class), mUgm, mUgmInternal,
                mAppOpsManager, mUm, mHistoryManager, mStatsManager,
                mock(TelephonyManager.class), mAmi);
                mock(TelephonyManager.class), mAmi, mToastRateLimiter);
        mService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY);

        mService.setAudioManager(mAudioManager);
@@ -565,7 +568,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {

        try {
            mService.onDestroy();
        } catch (IllegalStateException e) {
        } catch (IllegalStateException | IllegalArgumentException e) {
            // can throw if a broadcast receiver was never registered
        }

@@ -4887,6 +4890,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -4909,6 +4913,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -4927,6 +4932,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -4948,11 +4954,33 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        verify(callback, times(1)).show(any());
    }

    @Test
    public void testToastRateLimiterCanPreventsShowCallForCustomToast() throws Exception {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(false); // rate limit reached

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
                .thenReturn(false);

        setAppInForegroundForToasts(mUid, true);

        Binder token = new Binder();
        ITransientNotification callback = mock(ITransientNotification.class);
        INotificationManager nmService = (INotificationManager) mService.mService;

        nmService.enqueueToast(testPackage, token, callback, 2000, 0);
        verify(callback, times(0)).show(any());
    }

    @Test
    public void testAllowForegroundTextToasts() throws Exception {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -4971,6 +4999,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -4989,6 +5018,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5011,12 +5041,32 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
                .showToast(anyInt(), any(), any(), any(), any(), anyInt(), any());
    }

    @Test
    public void testToastRateLimiterCanPreventsShowCallForTextToast() throws Exception {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(false); // rate limit reached

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
                .thenReturn(false);

        Binder token = new Binder();
        INotificationManager nmService = (INotificationManager) mService.mService;

        nmService.enqueueTextToast(testPackage, token, "Text", 2000, 0, null);
        verify(mStatusBar, times(0))
                .showToast(anyInt(), any(), any(), any(), any(), anyInt(), any());
    }

    @Test
    public void backgroundSystemCustomToast_callsSetProcessImportantAsForegroundForToast() throws
            Exception {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = true;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5041,6 +5091,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5061,6 +5112,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5080,6 +5132,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5096,6 +5149,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5116,6 +5170,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5138,6 +5193,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = true;
        setToastRateIsWithinQuota(true);

        // package is suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5160,6 +5216,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        final String testPackage = "testPackageName";
        assertEquals(0, mService.mToastQueue.size());
        mService.isSystemUid = false;
        setToastRateIsWithinQuota(true);

        // package is not suspended
        when(mPackageManager.isPackageSuspendedForUser(testPackage, UserHandle.getUserId(mUid)))
@@ -5187,6 +5244,14 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
        when(mAtm.hasResumedActivity(uid)).thenReturn(inForeground);
    }

    private void setToastRateIsWithinQuota(boolean isWithinQuota) {
        when(mToastRateLimiter.isWithinQuota(
                anyInt(),
                anyString(),
                eq(NotificationManagerService.TOAST_QUOTA_TAG)))
                .thenReturn(isWithinQuota);
    }

    @Test
    public void testOnPanelRevealedAndHidden() {
        int items = 5;
+3 −1
Original line number Diff line number Diff line
@@ -67,6 +67,7 @@ import com.android.server.lights.LightsManager;
import com.android.server.notification.NotificationManagerService.NotificationAssistants;
import com.android.server.notification.NotificationManagerService.NotificationListeners;
import com.android.server.uri.UriGrantsManagerInternal;
import com.android.server.utils.quota.MultiRateLimiter;
import com.android.server.wm.ActivityTaskManagerInternal;
import com.android.server.wm.WindowManagerInternal;

@@ -156,7 +157,8 @@ public class RoleObserverTest extends UiServiceTestCase {
                    mock(UriGrantsManagerInternal.class),
                    mock(AppOpsManager.class), mUm, mock(NotificationHistoryManager.class),
                    mock(StatsManager.class), mock(TelephonyManager.class),
                    mock(ActivityManagerInternal.class));
                    mock(ActivityManagerInternal.class),
                    mock(MultiRateLimiter.class));
        } catch (SecurityException e) {
            if (!e.getMessage().contains("Permission Denial: not allowed to send broadcast")) {
                throw e;