diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index d32acaf1e6204..0f328b034f388 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -7056,6 +7056,10 @@ android:permission="android.permission.BIND_JOB_SERVICE"> + + + diff --git a/services/core/java/com/android/server/notification/NotificationManagerInternal.java b/services/core/java/com/android/server/notification/NotificationManagerInternal.java index c548e7edc3cff..8a627367c1dc4 100644 --- a/services/core/java/com/android/server/notification/NotificationManagerInternal.java +++ b/services/core/java/com/android/server/notification/NotificationManagerInternal.java @@ -42,4 +42,7 @@ public interface NotificationManagerInternal { /** Does the specified package/uid have permission to post notifications? */ boolean areNotificationsEnabledForPackage(String pkg, int uid); + + /** Send a notification to the user prompting them to review their notification permissions. */ + void sendReviewPermissionsNotification(); } diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java index 6078bfc954882..7710a25b95fc1 100755 --- a/services/core/java/com/android/server/notification/NotificationManagerService.java +++ b/services/core/java/com/android/server/notification/NotificationManagerService.java @@ -274,6 +274,7 @@ import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.messages.nano.SystemMessageProto; import com.android.internal.notification.SystemNotificationChannels; import com.android.internal.os.BackgroundThread; import com.android.internal.os.SomeArgs; @@ -442,6 +443,18 @@ public class NotificationManagerService extends SystemService { private static final int NOTIFICATION_INSTANCE_ID_MAX = (1 << 13); + // States for the review permissions notification + static final int REVIEW_NOTIF_STATE_UNKNOWN = -1; + static final int REVIEW_NOTIF_STATE_SHOULD_SHOW = 0; + static final int REVIEW_NOTIF_STATE_USER_INTERACTED = 1; + static final int REVIEW_NOTIF_STATE_DISMISSED = 2; + static final int REVIEW_NOTIF_STATE_RESHOWN = 3; + + // Action strings for review permissions notification + static final String REVIEW_NOTIF_ACTION_REMIND = "REVIEW_NOTIF_ACTION_REMIND"; + static final String REVIEW_NOTIF_ACTION_DISMISS = "REVIEW_NOTIF_ACTION_DISMISS"; + static final String REVIEW_NOTIF_ACTION_CANCELED = "REVIEW_NOTIF_ACTION_CANCELED"; + /** * Apps that post custom toasts in the background will have those blocked. Apps can * still post toasts created with @@ -652,6 +665,9 @@ public class NotificationManagerService extends SystemService { private InstanceIdSequence mNotificationInstanceIdSequence; private Set mMsgPkgsAllowedAsConvos = new HashSet(); + // Broadcast intent receiver for notification permissions review-related intents + private ReviewNotificationPermissionsReceiver mReviewNotificationPermissionsReceiver; + static class Archive { final SparseArray mEnabled; final int mBufferSize; @@ -2416,6 +2432,11 @@ public class NotificationManagerService extends SystemService { IntentFilter localeChangedFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); getContext().registerReceiver(mLocaleChangeReceiver, localeChangedFilter); + + mReviewNotificationPermissionsReceiver = new ReviewNotificationPermissionsReceiver(); + getContext().registerReceiver(mReviewNotificationPermissionsReceiver, + ReviewNotificationPermissionsReceiver.getFilter(), + Context.RECEIVER_NOT_EXPORTED); } /** @@ -2709,6 +2730,7 @@ public class NotificationManagerService extends SystemService { mHistoryManager.onBootPhaseAppsCanStart(); registerDeviceConfigChange(); migrateDefaultNAS(); + maybeShowInitialReviewPermissionsNotification(); } else if (phase == SystemService.PHASE_ACTIVITY_MANAGER_READY) { mSnoozeHelper.scheduleRepostsForPersistedNotifications(System.currentTimeMillis()); } @@ -6336,6 +6358,21 @@ public class NotificationManagerService extends SystemService { public boolean areNotificationsEnabledForPackage(String pkg, int uid) { return areNotificationsEnabledForPackageInt(pkg, uid); } + + @Override + public void sendReviewPermissionsNotification() { + // This method is meant to be called from the JobService upon running the job for this + // notification having been rescheduled; so without checking any other state, it will + // send the notification. + checkCallerIsSystem(); + NotificationManager nm = getContext().getSystemService(NotificationManager.class); + nm.notify(TAG, + SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS, + createReviewPermissionsNotification()); + Settings.Global.putInt(getContext().getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN); + } }; int getNumNotificationChannelsForPackage(String pkg, int uid, boolean includeDeleted) { @@ -11608,6 +11645,76 @@ public class NotificationManagerService extends SystemService { out.endTag(null, LOCKSCREEN_ALLOW_SECURE_NOTIFICATIONS_TAG); } + // Creates a notification that informs the user about changes due to the migration to + // use permissions for notifications. + protected Notification createReviewPermissionsNotification() { + int title = R.string.review_notification_settings_title; + int content = R.string.review_notification_settings_text; + + // Tapping on the notification leads to the settings screen for managing app notifications, + // using the intent reserved for system services to indicate it comes from this notification + Intent tapIntent = new Intent(Settings.ACTION_ALL_APPS_NOTIFICATION_SETTINGS_FOR_REVIEW); + Intent remindIntent = new Intent(REVIEW_NOTIF_ACTION_REMIND); + Intent dismissIntent = new Intent(REVIEW_NOTIF_ACTION_DISMISS); + Intent swipeIntent = new Intent(REVIEW_NOTIF_ACTION_CANCELED); + + // Both "remind me" and "dismiss" actions will be actions received by the BroadcastReceiver + final Notification.Action remindMe = new Notification.Action.Builder(null, + getContext().getResources().getString( + R.string.review_notification_settings_remind_me_action), + PendingIntent.getBroadcast( + getContext(), 0, remindIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)) + .build(); + final Notification.Action dismiss = new Notification.Action.Builder(null, + getContext().getResources().getString( + R.string.review_notification_settings_dismiss), + PendingIntent.getBroadcast( + getContext(), 0, dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)) + .build(); + + return new Notification.Builder(getContext(), SystemNotificationChannels.SYSTEM_CHANGES) + .setSmallIcon(R.drawable.stat_sys_adb) + .setContentTitle(getContext().getResources().getString(title)) + .setContentText(getContext().getResources().getString(content)) + .setContentIntent(PendingIntent.getActivity(getContext(), 0, tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)) + .setStyle(new Notification.BigTextStyle()) + .setFlag(Notification.FLAG_NO_CLEAR, true) + .setAutoCancel(true) + .addAction(remindMe) + .addAction(dismiss) + .setDeleteIntent(PendingIntent.getBroadcast(getContext(), 0, swipeIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)) + .build(); + } + + protected void maybeShowInitialReviewPermissionsNotification() { + int currentState = Settings.Global.getInt(getContext().getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + REVIEW_NOTIF_STATE_UNKNOWN); + + // now check the last known state of the notification -- this determination of whether the + // user is in the correct target audience occurs elsewhere, and will have written the + // REVIEW_NOTIF_STATE_SHOULD_SHOW to indicate it should be shown in the future. + // + // alternatively, if the user has rescheduled the notification (so it has been shown + // again) but not yet interacted with the new notification, then show it again on boot, + // as this state indicates that the user had the notification open before rebooting. + // + // sending the notification here does not record a new state for the notification; + // that will be written by parts of the system further down the line if at any point + // the user interacts with the notification. + if (currentState == REVIEW_NOTIF_STATE_SHOULD_SHOW + || currentState == REVIEW_NOTIF_STATE_RESHOWN) { + NotificationManager nm = getContext().getSystemService(NotificationManager.class); + nm.notify(TAG, + SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS, + createReviewPermissionsNotification()); + } + } + /** * Shows a warning on logcat. Shows the toast only once per package. This is to avoid being too * aggressive and annoying the user. diff --git a/services/core/java/com/android/server/notification/PreferencesHelper.java b/services/core/java/com/android/server/notification/PreferencesHelper.java index 0525b1e33267b..ef3c770f125bf 100644 --- a/services/core/java/com/android/server/notification/PreferencesHelper.java +++ b/services/core/java/com/android/server/notification/PreferencesHelper.java @@ -96,6 +96,10 @@ public class PreferencesHelper implements RankingConfig { private final int XML_VERSION; /** What version to check to do the upgrade for bubbles. */ private static final int XML_VERSION_BUBBLES_UPGRADE = 1; + /** The first xml version with notification permissions enabled. */ + private static final int XML_VERSION_NOTIF_PERMISSION = 3; + /** The first xml version that notifies users to review their notification permissions */ + private static final int XML_VERSION_REVIEW_PERMISSIONS_NOTIFICATION = 4; @VisibleForTesting static final int UNKNOWN_UID = UserHandle.USER_NULL; private static final String NON_BLOCKABLE_CHANNEL_DELIM = ":"; @@ -206,7 +210,7 @@ public class PreferencesHelper implements RankingConfig { mStatsEventBuilderFactory = statsEventBuilderFactory; if (mPermissionHelper.isMigrationEnabled()) { - XML_VERSION = 3; + XML_VERSION = 4; } else { XML_VERSION = 2; } @@ -226,8 +230,16 @@ public class PreferencesHelper implements RankingConfig { final int xmlVersion = parser.getAttributeInt(null, ATT_VERSION, -1); boolean upgradeForBubbles = xmlVersion == XML_VERSION_BUBBLES_UPGRADE; - boolean migrateToPermission = - (xmlVersion < XML_VERSION) && mPermissionHelper.isMigrationEnabled(); + boolean migrateToPermission = (xmlVersion < XML_VERSION_NOTIF_PERMISSION) + && mPermissionHelper.isMigrationEnabled(); + if (xmlVersion < XML_VERSION_REVIEW_PERMISSIONS_NOTIFICATION) { + // make a note that we should show the notification at some point. + // it shouldn't be possible for the user to already have seen it, as the XML version + // would be newer then. + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW); + } ArrayList pkgPerms = new ArrayList<>(); synchronized (mPackagePreferences) { while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { diff --git a/services/core/java/com/android/server/notification/ReviewNotificationPermissionsJobService.java b/services/core/java/com/android/server/notification/ReviewNotificationPermissionsJobService.java new file mode 100644 index 0000000000000..fde45f71a844f --- /dev/null +++ b/services/core/java/com/android/server/notification/ReviewNotificationPermissionsJobService.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2022 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 com.android.server.notification; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.LocalServices; + +/** + * JobService implementation for scheduling the notification informing users about + * notification permissions updates and taking them to review their existing permissions. + * @hide + */ +public class ReviewNotificationPermissionsJobService extends JobService { + public static final String TAG = "ReviewNotificationPermissionsJobService"; + + @VisibleForTesting + protected static final int JOB_ID = 225373531; + + /** + * Schedule a new job that will show a notification the specified amount of time in the future. + */ + public static void scheduleJob(Context context, long rescheduleTimeMillis) { + JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); + // if the job already exists for some reason, cancel & reschedule + if (jobScheduler.getPendingJob(JOB_ID) != null) { + jobScheduler.cancel(JOB_ID); + } + ComponentName component = new ComponentName( + context, ReviewNotificationPermissionsJobService.class); + JobInfo newJob = new JobInfo.Builder(JOB_ID, component) + .setPersisted(true) // make sure it'll still get rescheduled after reboot + .setMinimumLatency(rescheduleTimeMillis) // run after specified amount of time + .build(); + jobScheduler.schedule(newJob); + } + + @Override + public boolean onStartJob(JobParameters params) { + // While jobs typically should be run on different threads, this + // job only posts a notification, which is not a long-running operation + // as notification posting is asynchronous. + NotificationManagerInternal nmi = + LocalServices.getService(NotificationManagerInternal.class); + nmi.sendReviewPermissionsNotification(); + + // once the notification is posted, the job is done, so no need to + // keep it alive afterwards + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + // If we're interrupted for some reason, try again (though this may not + // ever happen due to onStartJob not leaving a job running after being + // called) + return true; + } +} diff --git a/services/core/java/com/android/server/notification/ReviewNotificationPermissionsReceiver.java b/services/core/java/com/android/server/notification/ReviewNotificationPermissionsReceiver.java new file mode 100644 index 0000000000000..b99aeac44025b --- /dev/null +++ b/services/core/java/com/android/server/notification/ReviewNotificationPermissionsReceiver.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2022 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 com.android.server.notification; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.provider.Settings; +import android.util.Log; +import android.util.Slog; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.messages.nano.SystemMessageProto; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; + +/** + * Broadcast receiver for intents that come from the "review notification permissions" notification, + * shown to users who upgrade to T from an earlier OS to inform them of notification setup changes + * and invite them to review their notification permissions. + */ +public class ReviewNotificationPermissionsReceiver extends BroadcastReceiver { + public static final String TAG = "ReviewNotifPermissions"; + static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + // 7 days in millis, as the amount of time to wait before re-sending the notification + private static final long JOB_RESCHEDULE_TIME = 1000 /* millis */ * 60 /* seconds */ + * 60 /* minutes */ * 24 /* hours */ * 7 /* days */; + + static IntentFilter getFilter() { + IntentFilter filter = new IntentFilter(); + filter.addAction(NotificationManagerService.REVIEW_NOTIF_ACTION_REMIND); + filter.addAction(NotificationManagerService.REVIEW_NOTIF_ACTION_DISMISS); + filter.addAction(NotificationManagerService.REVIEW_NOTIF_ACTION_CANCELED); + return filter; + } + + // Cancels the "review notification permissions" notification. + @VisibleForTesting + protected void cancelNotification(Context context) { + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm != null) { + nm.cancel(NotificationManagerService.TAG, + SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS); + } else { + Slog.w(TAG, "could not cancel notification: NotificationManager not found"); + } + } + + @VisibleForTesting + protected void rescheduleNotification(Context context) { + ReviewNotificationPermissionsJobService.scheduleJob(context, JOB_RESCHEDULE_TIME); + // log if needed + if (DEBUG) { + Slog.d(TAG, "Scheduled review permissions notification for on or after: " + + LocalDateTime.now(ZoneId.systemDefault()) + .plus(JOB_RESCHEDULE_TIME, ChronoUnit.MILLIS)); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(NotificationManagerService.REVIEW_NOTIF_ACTION_REMIND)) { + // Reschedule the notification for 7 days in the future + rescheduleNotification(context); + + // note that the user has interacted; no longer needed to show the initial + // notification + Settings.Global.putInt(context.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED); + cancelNotification(context); + } else if (action.equals(NotificationManagerService.REVIEW_NOTIF_ACTION_DISMISS)) { + // user dismissed; write to settings so we don't show ever again + Settings.Global.putInt(context.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_DISMISSED); + cancelNotification(context); + } else if (action.equals(NotificationManagerService.REVIEW_NOTIF_ACTION_CANCELED)) { + // we may get here from the user swiping away the notification, + // or from the notification being canceled in any other way. + // only in the case that the user hasn't interacted with it in + // any other way yet, reschedule + int notifState = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + /* default */ NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN); + if (notifState == NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW) { + // user hasn't interacted in the past, so reschedule once and then note that the + // user *has* interacted now so we don't re-reschedule if they swipe again + rescheduleNotification(context); + Settings.Global.putInt(context.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED); + } else if (notifState == NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN) { + // swiping away on a rescheduled notification; mark as interacted and + // don't reschedule again. + Settings.Global.putInt(context.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED); + } + } + } +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java index 348e015500feb..d85c40ecebe81 100755 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java @@ -198,6 +198,7 @@ import com.android.internal.app.IAppOpsService; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.InstanceIdSequenceFake; +import com.android.internal.messages.nano.SystemMessageProto; import com.android.internal.statusbar.NotificationVisibility; import com.android.server.DeviceIdleInternal; import com.android.server.LocalServices; @@ -303,6 +304,8 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { ActivityManagerInternal mAmi; @Mock private Looper mMainLooper; + @Mock + private NotificationManager mMockNm; @Mock IIntentSender pi1; @@ -405,6 +408,7 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { LocalServices.removeServiceForTest(PermissionPolicyInternal.class); LocalServices.addService(PermissionPolicyInternal.class, mPermissionPolicyInternal); mContext.addMockSystemService(Context.ALARM_SERVICE, mAlarmManager); + mContext.addMockSystemService(NotificationManager.class, mMockNm); doNothing().when(mContext).sendBroadcastAsUser(any(), any(), any()); @@ -9294,4 +9298,77 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { // the notifyPostedLocked function is called twice. verify(mListeners, times(2)).notifyPostedLocked(any(), any()); } + + @Test + public void testMaybeShowReviewPermissionsNotification_unknown() { + // Set up various possible states of the settings int and confirm whether or not the + // notification is shown as expected + + // Initial state: default/unknown setting, make sure nothing happens + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN); + mService.maybeShowInitialReviewPermissionsNotification(); + verify(mMockNm, never()).notify(anyString(), anyInt(), any(Notification.class)); + } + + @Test + public void testMaybeShowReviewPermissionsNotification_shouldShow() { + // If state is SHOULD_SHOW, it ... should show + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW); + mService.maybeShowInitialReviewPermissionsNotification(); + verify(mMockNm, times(1)).notify(eq(NotificationManagerService.TAG), + eq(SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS), + any(Notification.class)); + } + + @Test + public void testMaybeShowReviewPermissionsNotification_alreadyShown() { + // If state is either USER_INTERACTED or DISMISSED, we should not show this on boot + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED); + mService.maybeShowInitialReviewPermissionsNotification(); + + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_DISMISSED); + mService.maybeShowInitialReviewPermissionsNotification(); + + verify(mMockNm, never()).notify(anyString(), anyInt(), any(Notification.class)); + } + + @Test + public void testMaybeShowReviewPermissionsNotification_reshown() { + // If we have re-shown the notification and the user did not subsequently interacted with + // it, then make sure we show when trying on boot + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN); + mService.maybeShowInitialReviewPermissionsNotification(); + verify(mMockNm, times(1)).notify(eq(NotificationManagerService.TAG), + eq(SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS), + any(Notification.class)); + } + + @Test + public void testRescheduledReviewPermissionsNotification() { + // when rescheduled, the notification goes through the NotificationManagerInternal service + // this call doesn't need to know anything about previously scheduled state -- if called, + // it should send the notification & write the appropriate int to Settings + mInternalService.sendReviewPermissionsNotification(); + + // Notification should be sent + verify(mMockNm, times(1)).notify(eq(NotificationManagerService.TAG), + eq(SystemMessageProto.SystemMessage.NOTE_REVIEW_NOTIFICATION_PERMISSIONS), + any(Notification.class)); + + // write STATE_RESHOWN to settings + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } } diff --git a/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java index 63d7453450d28..6d08959358774 100644 --- a/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/PreferencesHelperTest.java @@ -289,6 +289,11 @@ public class PreferencesHelperTest extends UiServiceTestCase { .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) .build(); + + // make sure that the settings for review notification permissions are unset to begin with + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN); } private ByteArrayOutputStream writeXmlAndPurge( @@ -656,6 +661,13 @@ public class PreferencesHelperTest extends UiServiceTestCase { verify(mPermissionHelper).setNotificationPermission(nMr1Expected); verify(mPermissionHelper).setNotificationPermission(oExpected); verify(mPermissionHelper).setNotificationPermission(pExpected); + + // verify that we also write a state for review_permissions_notification to eventually + // show a notification + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); } @Test @@ -738,7 +750,7 @@ public class PreferencesHelperTest extends UiServiceTestCase { } @Test - public void testReadXml_newXml_noMigration() throws Exception { + public void testReadXml_newXml_noMigration_showPermissionNotification() throws Exception { when(mPermissionHelper.isMigrationEnabled()).thenReturn(true); mHelper = new PreferencesHelper(getContext(), mPm, mHandler, mMockZenModeHelper, mPermissionHelper, mLogger, mAppOpsManager, mStatsEventBuilderFactory); @@ -786,6 +798,70 @@ public class PreferencesHelperTest extends UiServiceTestCase { compareChannels(idp, mHelper.getNotificationChannel(PKG_P, UID_P, idp.getId(), false)); verify(mPermissionHelper, never()).setNotificationPermission(any()); + + // verify that we do, however, write a state for review_permissions_notification to + // eventually show a notification, since this XML version is older than the notification + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReadXml_newXml_noMigration_noPermissionNotification() throws Exception { + when(mPermissionHelper.isMigrationEnabled()).thenReturn(true); + mHelper = new PreferencesHelper(getContext(), mPm, mHandler, mMockZenModeHelper, + mPermissionHelper, mLogger, mAppOpsManager, mStatsEventBuilderFactory); + + String xml = "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n"; + NotificationChannel idn = new NotificationChannel("idn", "name", IMPORTANCE_LOW); + idn.setSound(null, new AudioAttributes.Builder() + .setUsage(USAGE_NOTIFICATION) + .setContentType(CONTENT_TYPE_SONIFICATION) + .setFlags(0) + .build()); + idn.setShowBadge(false); + NotificationChannel ido = new NotificationChannel("ido", "name2", IMPORTANCE_LOW); + ido.setShowBadge(true); + ido.setSound(null, new AudioAttributes.Builder() + .setUsage(USAGE_NOTIFICATION) + .setContentType(CONTENT_TYPE_SONIFICATION) + .setFlags(0) + .build()); + NotificationChannel idp = new NotificationChannel("idp", "name3", IMPORTANCE_HIGH); + idp.lockFields(2); + idp.setSound(null, new AudioAttributes.Builder() + .setUsage(USAGE_NOTIFICATION) + .setContentType(CONTENT_TYPE_SONIFICATION) + .setFlags(0) + .build()); + + loadByteArrayXml(xml.getBytes(), true, USER_SYSTEM); + + assertTrue(mHelper.canShowBadge(PKG_N_MR1, UID_N_MR1)); + + assertEquals(idn, mHelper.getNotificationChannel(PKG_N_MR1, UID_N_MR1, idn.getId(), false)); + compareChannels(ido, mHelper.getNotificationChannel(PKG_O, UID_O, ido.getId(), false)); + compareChannels(idp, mHelper.getNotificationChannel(PKG_P, UID_P, idp.getId(), false)); + + verify(mPermissionHelper, never()).setNotificationPermission(any()); + + // this XML is new enough, we should not be attempting to show a notification or anything + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); } @Test @@ -903,7 +979,7 @@ public class PreferencesHelperTest extends UiServiceTestCase { ByteArrayOutputStream baos = writeXmlAndPurge( PKG_N_MR1, UID_N_MR1, false, USER_SYSTEM); - String expected = "\n" + String expected = "\n" + "\n" @@ -984,7 +1060,7 @@ public class PreferencesHelperTest extends UiServiceTestCase { ByteArrayOutputStream baos = writeXmlAndPurge( PKG_N_MR1, UID_N_MR1, true, USER_SYSTEM); - String expected = "\n" + String expected = "\n" // Importance 0 because off in permissionhelper + "\n" + String expected = "\n" // Importance 0 because off in permissionhelper + "\n" + String expected = "\n" // Packages that exist solely in permissionhelper + "\n" + "\n" diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsJobServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsJobServiceTest.java new file mode 100644 index 0000000000000..5a4ce5da676e2 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsJobServiceTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 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 com.android.server.notification; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.testing.AndroidTestingRunner; + +import androidx.test.rule.ServiceTestRule; + +import com.android.server.LocalServices; +import com.android.server.UiServiceTestCase; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; + +@RunWith(AndroidTestingRunner.class) +public class ReviewNotificationPermissionsJobServiceTest extends UiServiceTestCase { + private ReviewNotificationPermissionsJobService mJobService; + private JobParameters mJobParams = new JobParameters(null, + ReviewNotificationPermissionsJobService.JOB_ID, null, null, null, + 0, false, false, null, null, null); + + @Captor + ArgumentCaptor mJobInfoCaptor; + + @Mock + private JobScheduler mMockJobScheduler; + + @Mock + private NotificationManagerInternal mMockNotificationManagerInternal; + + @Rule + public final ServiceTestRule mServiceRule = new ServiceTestRule(); + + @Before + public void setUp() throws Exception { + mJobService = new ReviewNotificationPermissionsJobService(); + mContext.addMockSystemService(JobScheduler.class, mMockJobScheduler); + + // add NotificationManagerInternal to LocalServices + LocalServices.removeServiceForTest(NotificationManagerInternal.class); + LocalServices.addService(NotificationManagerInternal.class, + mMockNotificationManagerInternal); + } + + @Test + public void testScheduleJob() { + // if asked, the job doesn't currently exist yet + when(mMockJobScheduler.getPendingJob(anyInt())).thenReturn(null); + + final int rescheduleTimeMillis = 350; // arbitrary number + + // attempt to schedule the job + ReviewNotificationPermissionsJobService.scheduleJob(mContext, rescheduleTimeMillis); + verify(mMockJobScheduler, times(1)).schedule(mJobInfoCaptor.capture()); + + // verify various properties of the job that is passed in to the job scheduler + JobInfo jobInfo = mJobInfoCaptor.getValue(); + assertEquals(ReviewNotificationPermissionsJobService.JOB_ID, jobInfo.getId()); + assertEquals(rescheduleTimeMillis, jobInfo.getMinLatencyMillis()); + assertTrue(jobInfo.isPersisted()); // should continue after reboot + assertFalse(jobInfo.isPeriodic()); // one time + } + + @Test + public void testOnStartJob() { + // the job need not be persisted after it does its work, so it'll return + // false + assertFalse(mJobService.onStartJob(mJobParams)); + + // verify that starting the job causes the notification to be sent + verify(mMockNotificationManagerInternal).sendReviewPermissionsNotification(); + } +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsReceiverTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsReceiverTest.java new file mode 100644 index 0000000000000..12281a742a502 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/ReviewNotificationPermissionsReceiverTest.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2022 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 com.android.server.notification; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +import android.content.Context; +import android.content.Intent; +import android.provider.Settings; +import android.testing.AndroidTestingRunner; + +import androidx.test.filters.SmallTest; + +import com.android.server.UiServiceTestCase; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidTestingRunner.class) +@SmallTest +public class ReviewNotificationPermissionsReceiverTest extends UiServiceTestCase { + + // Simple mock class that just overrides the reschedule and cancel behavior so that it's easy + // to tell whether the receiver has sent requests to either reschedule or cancel the + // notification (or both). + private class MockReviewNotificationPermissionsReceiver + extends ReviewNotificationPermissionsReceiver { + boolean mCanceled = false; + boolean mRescheduled = false; + + @Override + protected void cancelNotification(Context context) { + mCanceled = true; + } + + @Override + protected void rescheduleNotification(Context context) { + mRescheduled = true; + } + } + + private MockReviewNotificationPermissionsReceiver mReceiver; + private Intent mIntent; + + @Before + public void setUp() { + mReceiver = new MockReviewNotificationPermissionsReceiver(); + mIntent = new Intent(); // actions will be set in test cases + } + + @Test + public void testReceive_remindMeLater_firstTime() { + // Test what happens when we receive a "remind me later" intent coming from + // a previously-not-interacted notification + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW); + + // set up Intent action + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_REMIND); + + // Upon receipt of the intent, the following things should happen: + // - notification rescheduled + // - notification explicitly canceled + // - settings state updated to indicate user has interacted + mReceiver.onReceive(mContext, mIntent); + assertTrue(mReceiver.mRescheduled); + assertTrue(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReceive_remindMeLater_laterTimes() { + // Test what happens when we receive a "remind me later" intent coming from + // a previously-interacted notification that has been rescheduled + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN); + + // set up Intent action + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_REMIND); + + // Upon receipt of the intent, the following things should still happen + // regardless of the fact that the user has interacted before: + // - notification rescheduled + // - notification explicitly canceled + // - settings state still indicate user has interacted + mReceiver.onReceive(mContext, mIntent); + assertTrue(mReceiver.mRescheduled); + assertTrue(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReceive_dismiss() { + // Test that dismissing the notification does *not* reschedule the notification, + // does cancel it, and writes that it has been dismissed to settings + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW); + + // set up Intent action + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_DISMISS); + + // send intent, watch what happens + mReceiver.onReceive(mContext, mIntent); + assertFalse(mReceiver.mRescheduled); + assertTrue(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_DISMISSED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReceive_notificationCanceled_firstSwipe() { + // Test the basic swipe away case: the first time the user swipes the notification + // away, it will not have been interacted with yet, so make sure it's rescheduled + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_SHOULD_SHOW); + + // set up Intent action, would be called from notification's delete intent + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_CANCELED); + + // send intent, make sure it gets: + // - rescheduled + // - not explicitly canceled, the notification was already canceled + // - noted that it's been interacted with + mReceiver.onReceive(mContext, mIntent); + assertTrue(mReceiver.mRescheduled); + assertFalse(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReceive_notificationCanceled_secondSwipe() { + // Test the swipe away case for a rescheduled notification: in this case + // it should not be rescheduled anymore + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_RESHOWN); + + // set up Intent action, would be called from notification's delete intent + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_CANCELED); + + // send intent, make sure it gets: + // - not rescheduled on the second+ swipe + // - not explicitly canceled, the notification was already canceled + // - mark as user interacted + mReceiver.onReceive(mContext, mIntent); + assertFalse(mReceiver.mRescheduled); + assertFalse(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_USER_INTERACTED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } + + @Test + public void testReceive_notificationCanceled_fromDismiss() { + // Test that if the notification delete intent is called due to us canceling + // the notification from the receiver, we don't do anything extra + Settings.Global.putInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_DISMISSED); + + // set up Intent action, would be called from notification's delete intent + mIntent.setAction(NotificationManagerService.REVIEW_NOTIF_ACTION_CANCELED); + + // nothing should happen, nothing at all + mReceiver.onReceive(mContext, mIntent); + assertFalse(mReceiver.mRescheduled); + assertFalse(mReceiver.mCanceled); + assertEquals(NotificationManagerService.REVIEW_NOTIF_STATE_DISMISSED, + Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.REVIEW_PERMISSIONS_NOTIFICATION_STATE, + NotificationManagerService.REVIEW_NOTIF_STATE_UNKNOWN)); + } +}