背景
对于以低于 Android 13 的版本的 SDK 为目标平台的应用,在应用创建至少一个 NotificationChannel 后,拦截首次 activity 启动以显示权限提示,询问用户是否想要接收来自应用的通知。简单来说就是targetSDK在Android 13以前的应用,如果至少有一个NotificationChannel,则在首次Activity启动时会自动弹出通知权限授权。主要分析该机制的原理。来源:https://source.android.com/docs/core/display/notification-perm
分析
在com.android.server.policy.PermissionPolicyService$Internal中,会在ActivityManager准备好后立即注册一个ActivityStartInterceptor:
private void onActivityManagerReady() {
ActivityTaskManagerInternal atm =
LocalServices.getService(ActivityTaskManagerInternal.class);
atm.registerActivityStartInterceptor(
ActivityInterceptorCallback.PERMISSION_POLICY_ORDERED_ID,
mActivityInterceptorCallback);
}
实现以下逻辑:
private final ActivityInterceptorCallback mActivityInterceptorCallback =
new ActivityInterceptorCallback() {
@Nullable
@Override
public ActivityInterceptorCallback.ActivityInterceptResult
onInterceptActivityLaunch(@NonNull ActivityInterceptorInfo info) {
return null;
}
@Override
public void onActivityLaunched(TaskInfo taskInfo, ActivityInfo activityInfo,
ActivityInterceptorInfo info) {
if (!shouldShowNotificationDialogOrClearFlags(taskInfo,
activityInfo.packageName, info.getCallingPackage(),
info.getIntent(), info.getCheckedOptions(), activityInfo.name,
true)
|| isNoDisplayActivity(activityInfo)) {
return;
}
UserHandle user = UserHandle.of(taskInfo.userId);
if (!CompatChanges.isChangeEnabled(NOTIFICATION_PERM_CHANGE_ID,
activityInfo.packageName, user)) {
// Post the activity start checks to ensure the notification channel
// checks happen outside the WindowManager global lock.
mHandler.post(() -> showNotificationPromptIfNeeded(
activityInfo.packageName, taskInfo.userId, taskInfo.taskId,
info));
}
}
};
首先进入shouldShowNotificationDialogOrClearFlags判断,主要检查是否是首次打开的Activity,以及找到一个特定的Task用来弹出授权窗口:
/**
* Determine if a particular task is in the proper state to show a system-triggered
* permission prompt. A prompt can be shown if the task is just starting, or the task is
* currently focused, visible, and running, and,
* 1. The isEligibleForLegacyPermissionPrompt ActivityOption is set, or
* 2. The intent is a launcher intent (action is ACTION_MAIN, category is LAUNCHER), or
* 3. The activity belongs to the same package as the one which launched the task
* originally, and the task was started with a launcher intent, or
* 4. The activity is the first activity in a new task, and was started by the app the
* activity belongs to, and that app has another task that is currently focused, which was
* started with a launcher intent. This case seeks to identify cases where an app launches,
* then immediately trampolines to a new activity and task.
* @param taskInfo The task to be checked
* @param currPkg The package of the current top visible activity
* @param callingPkg The package that initiated this dialog action
* @param intent The intent of the current top visible activity
* @param options The ActivityOptions of the newly started activity, if this is called due
* to an activity start
* @param startedActivity The ActivityInfo of the newly started activity, if this is called
* due to an activity start
*/
private boolean shouldShowNotificationDialogOrClearFlags(TaskInfo taskInfo, String currPkg,
String callingPkg, Intent intent, ActivityOptions options,
String topActivityName, boolean startedActivity) {
if (intent == null || currPkg == null || taskInfo == null || topActivityName == null
|| (!(taskInfo.isFocused && taskInfo.isVisible && taskInfo.isRunning)
&& !startedActivity)) {
return false;
}
return isLauncherIntent(intent)
|| (options != null && options.isEligibleForLegacyPermissionPrompt())
|| isTaskStartedFromLauncher(currPkg, taskInfo)
|| (isTaskPotentialTrampoline(topActivityName, currPkg, callingPkg, taskInfo,
intent)
&& (!startedActivity || pkgHasRunningLauncherTask(currPkg, taskInfo)));
}
然后会post执行showNotificationPromptIfNeeded:
void showNotificationPromptIfNeeded(@NonNull String packageName, int userId,
int taskId, @Nullable ActivityInterceptorInfo info) {
UserHandle user = UserHandle.of(userId);
if (packageName == null || taskId == ActivityTaskManager.INVALID_TASK_ID
|| !shouldForceShowNotificationPermissionRequest(packageName, user)) {
return;
}
launchNotificationPermissionRequestDialog(packageName, user, taskId, info);
}
这里面会检查调用shouldForceShowNotificationPermissionRequest进行检查,这个检查比较关键:
private boolean shouldForceShowNotificationPermissionRequest(@NonNull String pkgName,
@NonNull UserHandle user) {
AndroidPackage pkg = mPackageManagerInternal.getPackage(pkgName);
if (pkg == null || pkg.getPackageName() == null
|| Objects.equals(pkgName, mPackageManager.getPermissionControllerPackageName())
|| pkg.getTargetSdkVersion() < Build.VERSION_CODES.M) {
if (pkg == null) {
Slog.w(LOG_TAG, "Cannot check for Notification prompt, no package for "
+ pkgName);
}
return false;
}
synchronized (mLock) {
if (!mBootCompleted) {
return false;
}
}
if (!pkg.getRequestedPermissions().contains(POST_NOTIFICATIONS)
|| CompatChanges.isChangeEnabled(NOTIFICATION_PERM_CHANGE_ID, pkgName, user)
|| mKeyguardManager.isKeyguardLocked()) {
return false;
}
int uid = user.getUid(pkg.getUid());
if (mNotificationManager == null) {
mNotificationManager = LocalServices.getService(NotificationManagerInternal.class);
}
boolean hasCreatedNotificationChannels = mNotificationManager
.getNumNotificationChannelsForPackage(pkgName, uid, true) > 0;
boolean granted = mPermissionManagerInternal.checkUidPermission(uid, POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
int flags = mPackageManager.getPermissionFlags(POST_NOTIFICATIONS, pkgName, user);
boolean explicitlySet = (flags & PermissionManager.EXPLICIT_SET_FLAGS) != 0;
return !granted && hasCreatedNotificationChannels && !explicitlySet;
}
这里面比较关键的两个检查:
- checkUidPermission:这个就不用说了,只有在未获得权限的时候才会弹窗
- (flags & PermissionManager.EXPLICIT_SET_FLAGS) != 0:这个主要是看用户有没有曾经拒绝过权限,或者是否是被设备策略拒绝
EXPLICIT_SET_FLAGS的定义如下:/** * The set of flags that indicate that a permission state has been explicitly set * * @hide */ public static final int EXPLICIT_SET_FLAGS = FLAG_PERMISSION_USER_SET | FLAG_PERMISSION_USER_FIXED | FLAG_PERMISSION_POLICY_FIXED | FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT | FLAG_PERMISSION_GRANTED_BY_ROLE;
可以看到不仅包含了FLAG_PERMISSION_USER_SET和FLAG_PERMISSION_USER_FIXED这些用户手动拒绝的情况,还包含了FLAG_PERMISSION_POLICY_FIXED等由于设备策略等原因拒绝的情况,以及一些其它情况。都判断完了之后就调用launchNotificationPermissionRequestDialog弹窗:
private void launchNotificationPermissionRequestDialog(String pkgName, UserHandle user, int taskId, @Nullable ActivityInterceptorInfo info) { Intent grantPermission = mPackageManager .buildRequestPermissionsIntent(new String[] { POST_NOTIFICATIONS }); // Prevent the front-most activity entering pip due to overlay activity started on top. grantPermission.addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_NO_USER_ACTION); grantPermission.setAction( ACTION_REQUEST_PERMISSIONS_FOR_OTHER); grantPermission.putExtra(Intent.EXTRA_PACKAGE_NAME, pkgName); final boolean remoteAnimation = info != null && info.getCheckedOptions() != null && info.getCheckedOptions().getAnimationType() == ANIM_REMOTE_ANIMATION && info.getClearOptionsAnimationRunnable() != null; ActivityOptions options = remoteAnimation ? ActivityOptions.makeRemoteAnimation( info.getCheckedOptions().getRemoteAnimationAdapter(), info.getCheckedOptions().getRemoteTransition()) : new ActivityOptions(new Bundle()); options.setTaskOverlay(true, false); options.setLaunchTaskId(taskId); if (remoteAnimation) { // Remote animation set on the intercepted activity will be handled by the grant // permission activity, which is launched below. So we need to clear remote // animation from the intercepted activity and its siblings to prevent duplication. // This should trigger ActivityRecord#clearOptionsAnimationForSiblings for the // intercepted activity. info.getClearOptionsAnimationRunnable().run(); } try { mContext.startActivityAsUser(grantPermission, options.toBundle(), user); } catch (Exception e) { Log.e(LOG_TAG, "couldn't start grant permission dialog" + "for other package " + pkgName, e); } }
这个权限弹窗也是用的buildRequestPermissionsIntent返回的Intent,只不过action更换成了ACTION_REQUEST_PERMISSIONS_FOR_OTHER,并且添加了EXTRA_PACKAGE_NAME,然后ActivityOptions加了一些动画和taskId相关的,最后调用startActivityAsUser启动。
测试
实际上shouldForceShowNotificationPermissionRequest主要的控制点就是用户有没有拒绝过弹窗,但是实际上PermissionController中也会有类似的检查,如果用户拒绝过弹窗的话,也是不能弹出的,即使是system_server进行放行也不行,这个结论是使用hook进行的验证:
if (loadPackageParam.packageName.equals("android")) {
MethodHook hooker = new MethodHook.Builder(
"com.android.server.policy.PermissionPolicyService$Internal", loadPackageParam.classLoader)
.setMethodName("shouldForceShowNotificationPermissionRequest")
.addParameter(String.class)
.addParameter(UserHandle.class)
.setCallback(new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Log.i(TAG, "Previous result: " + param.getResult());
param.setResult(true);
}
}).build();
MethodHook.normalInstall(hooker);
}
实际测试的时候,如果用户拒绝过权限弹窗,的确会出现Previous result: false
。通过hook临时规避之后,已经看到system_server已经发出了Intent但是还是无法弹出权限授权窗口,推测是PermissionController中对FLAG_PERMISSION_USER_SET和FLAG_PERMISSION_USER_FIXED这类标记还有进一步的检查。顺带补充一下系统发送的Intent的内容:
Intent { act=android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER flg=0x10840000 pkg=com.google.android.permissioncontroller cmp=com.google.android.permissioncontroller/com.android.permissioncontroller.permission.ui.GrantPermissionsActivity (has extras) }
[extra] android.intent.extra.PACKAGE_NAME `java.lang.String`: com.example.test
[extra] android.content.pm.extra.REQUEST_PERMISSIONS_NAMES `[Ljava.lang.String;`: [android.permission.POST_NOTIFICATIONS]
[extra] android.content.pm.extra.REQUEST_PERMISSIONS_DEVICE_ID `java.lang.Integer`: 0
总结
本文记录了Android 13通知权限适配弹框生成的原理,本质上和普通权限弹窗一样,只不过是action有区别。然而即使是system_server发出的弹窗也会受到PermissionController对用户拒绝状态检查的限制。