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));
+ }
+}