权限使用历史
Android 的权限历史并不是 PermissionManagerService 维护的一张“最近访问表”。在 Android 17 中,受隐私指示器关注的访问先由 AppOps 记录,再由 PermissionUsageHelper 按权限组、用户、设备和归因链聚合,最后通过 AppOpsManager 暴露为 PermissionGroupUsage。本文只讨论这条真实链路,以及它如何处理运行中访问、代理应用、位置后台访问和虚拟设备。
1. 先区分两种数据
“权限历史”容易和“权限是否被授予”混淆。前者回答应用最近有没有使用受保护能力,后者回答当前调用是否被允许。Android 17 的实现将二者拆开:
| 数据 | 主要 owner | 典型消费者 | 生命周期 |
|---|---|---|---|
| 权限授予状态 | PermissionManager / AccessCheckingService | checkPermission、运行时授权 | 持久权限状态 |
| AppOp 访问事件 | AppOpsService | 操作检查、历史查询 | 按访问事件变化 |
| 指示器聚合结果 | PermissionUsageHelper | 状态栏隐私指示器 | 最近窗口或运行中 |
隐私指示器只关心 camera、microphone、location 对应的 AppOps。普通的 MODE_ALLOWED 检查结果不会自动生成 UI 数据;必须有 AppOps 访问记录,并且通过 helper 的时间、用户、敏感性和代理过滤。
2. AppOps 入口
源码文件:frameworks/base/core/java/android/app/AppOpsManager.java
@SystemApi
@NonNull
@FlaggedApi(android.permission.flags.Flags.FLAG_DEVICE_AWARE_PERMISSION_APIS_ENABLED)
@RequiresPermission(Manifest.permission.GET_APP_OPS_STATS)
public List<PermissionGroupUsage> getPermissionGroupUsageForPrivacyIndicator(
boolean includeMicrophoneUsage) {
if (mUsageHelper == null) {
mUsageHelper = new PermissionUsageHelper(mContext);
}
return mUsageHelper.getOpUsageDataForAllDevices(includeMicrophoneUsage);
}这是指示器数据的公开系统 API 入口。调用者需要 GET_APP_OPS_STATS,helper 采用延迟初始化;第一次调用时才注册 AppOps 监听器。includeMicrophoneUsage 由消费者决定是否把麦克风纳入查询,结果类型是可跨进程传输的 PermissionGroupUsage,而不是内部 OpEntry。
这里的 getPackagesForOps 查询的是当前 AppOps 状态对象中的包级记录。helper 自己计算“最近”和“运行中”的窗口,因此 AppOps 查询返回的数据并不等于最终 UI 列表。
3. 关注的操作
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
private static final List<String> LOCATION_OPS = List.of(
OPSTR_FINE_LOCATION
);
private static final List<String> MIC_OPS = List.of(
OPSTR_PHONE_CALL_MICROPHONE,
OPSTR_RECEIVE_AMBIENT_TRIGGER_AUDIO,
OPSTR_RECORD_AUDIO
);
private static final List<String> CAMERA_OPS = List.of(
OPSTR_PHONE_CALL_CAMERA,
OPSTR_CAMERA
);
private static @NonNull String getGroupForOp(String op) {
switch (op) {
case OPSTR_RECEIVE_AMBIENT_TRIGGER_AUDIO:
case OPSTR_RECORD_AUDIO:
return MICROPHONE;
case OPSTR_CAMERA:
return CAMERA;
case OPSTR_PHONE_CALL_MICROPHONE:
case OPSTR_PHONE_CALL_CAMERA:
return op;
case OPSTR_COARSE_LOCATION:
case OPSTR_FINE_LOCATION:
return LOCATION;
default:
throw new IllegalArgumentException("Unknown app op: " + op);
}
}查询集合包含三类:相机、麦克风和位置。电话通话的 camera/microphone op 先保留为独立标识,后面再映射回 CAMERA 或 MICROPHONE 并设置 phoneCall 字段。位置列表在查询入口中受 location indicator feature flag 控制,不能把“位置权限存在”直接推导成“位置指示器一定显示”。
4. 监听器维护归因链
4.1 构造时注册监听
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
public PermissionUsageHelper(@NonNull Context context) {
mContext = context;
mPkgManager = context.getPackageManager();
mAppOpsManager = context.getSystemService(AppOpsManager.class);
mActivityManager = context.getSystemService(ActivityManager.class);
mVirtualDeviceManager = context.getSystemService(VirtualDeviceManager.class);
mUserContexts = new ArrayMap<>();
mUserContexts.put(Process.myUserHandle(), mContext);
String[] opStrs = {OPSTR_CAMERA, OPSTR_RECORD_AUDIO};
mAppOpsManager.startWatchingActive(opStrs, context.getMainExecutor(), this);
int[] ops = {OP_CAMERA, OP_RECORD_AUDIO};
mAppOpsManager.startWatchingStarted(ops, this);
}
public void tearDown() {
mAppOpsManager.stopWatchingActive(this);
mAppOpsManager.stopWatchingStarted(this);
}startWatchingActive 监听 camera/microphone 的 active 状态,startWatchingStarted 监听带归因链的 started 事件。监听器只负责维护短生命周期的链路信息;真正的最近访问列表仍在查询时从 AppOps 状态重建。tearDown 必须成对注销,否则 helper 会被 AppOpsManager 持有并继续接收回调。
4.2 成功可信链路
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
@Override
public void onOpStarted(int op, int uid, String packageName, String attributionTag,
@AppOpsManager.OpFlags int flags, @AppOpsManager.Mode int result,
@StartedType int startedType, @AttributionFlags int attributionFlags,
int attributionChainId) {
if (startedType == START_TYPE_FAILED
|| attributionChainId == ATTRIBUTION_CHAIN_ID_NONE
|| attributionFlags == ATTRIBUTION_FLAGS_NONE
|| (attributionFlags & ATTRIBUTION_FLAG_TRUSTED) == 0) {
return;
}
synchronized (mAttributionChains) {
addLinkToChainIfNotPresentLocked(
AppOpsManager.opToPublicName(op), packageName, uid,
attributionTag, attributionFlags, attributionChainId);
}
}失败的 start、没有 chain id、没有 attribution flags 或不可信链路都不会进入 mAttributionChains。这一步是安全边界:代理应用只有在 AppOps 已经确认访问成功且链路可信时,才可能出现在最终的 proxy label 中。
4.3 链路结束时清理
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
@Override
public void onOpActiveChanged(@NonNull String op, int uid,
@NonNull String packageName, @Nullable String attributionTag,
boolean active, @AttributionFlags int attributionFlags,
int attributionChainId) {
if (active) {
return;
}
synchronized (mAttributionChains) {
mAttributionChains.remove(attributionChainId);
int numChains = mAttributionChains.size();
ArrayList<Integer> toRemove = new ArrayList<>();
for (int i = 0; i < numChains; i++) {
int chainId = mAttributionChains.keyAt(i);
ArrayList<AccessChainLink> chain = mAttributionChains.valueAt(i);
for (int j = 0; j < chain.size(); j++) {
AccessChainLink link = chain.get(j);
if (link.packageAndOpEquals(op, packageName, attributionTag, uid)) {
toRemove.add(chainId);
break;
}
}
}
mAttributionChains.removeAll(toRemove);
}
}结束回调不仅删除传入的 chain id,还会扫描并删除包含同一 package/op/uid/tag 的其他链。原因是同一访问链可能收到多个结束事件;不清理会让下一次查询错误地把已经结束的代理链当作仍然可见。
5. 查询时间窗口
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
private static final long DEFAULT_RUNNING_TIME_MS = 5000L;
private static final long ADDITIONAL_RUNNING_TIME_LOCATION_ONLY_MS =
10_000L - DEFAULT_RUNNING_TIME_MS;
private static final long DEFAULT_RECENT_TIME_MS = 15000L;
private static final long ADDITIONAL_RECENT_TIME_LOCATION_ONLY_MS = 5000L;
private static long getRecentThreshold(Long now) {
return now - DeviceConfig.getLong(
DeviceConfig.NAMESPACE_PRIVACY,
RECENT_ACCESS_TIME_MS,
DEFAULT_RECENT_TIME_MS);
}
private static long getRunningThreshold(Long now) {
return now - DeviceConfig.getLong(
DeviceConfig.NAMESPACE_PRIVACY,
RUNNING_ACCESS_TIME_MS,
DEFAULT_RUNNING_TIME_MS);
}默认“最近”窗口是 15 秒,“运行中”窗口是 5 秒,两个值可由 Privacy namespace 的 DeviceConfig 调整。位置访问另外增加 5 秒 recent 保留和 5 秒 running 保留,使位置指示器比相机/麦克风更不容易瞬间消失。窗口是在查询时读取的,所以配置变化会影响下一次聚合,而不是回写历史记录。
6. 设备查询与模型
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
public @NonNull List<PermissionGroupUsage> getOpUsageDataForIndicatorsByDevice(
boolean includeMicrophoneUsage, String deviceId) {
List<PermissionGroupUsage> usages = new ArrayList<>();
if (!shouldShowIndicators()) {
return usages;
}
List<String> ops = new ArrayList<>(CAMERA_OPS);
if (includeMicrophoneUsage) {
ops.addAll(MIC_OPS);
}
if (android.location.flags.Flags.locationIndicatorsEnabled()) {
ops.addAll(LOCATION_OPS);
}
Map<String, List<OpUsage>> rawUsages = getOpUsagesByDevice(ops, deviceId);
ArrayList<String> usedPermGroups = new ArrayList<>(rawUsages.keySet());
// 后续处理电话通话、归因标签并构造 PermissionGroupUsage
...
}shouldShowIndicators() 为 false 时直接返回空列表,后续的 AppOps 查询也不会发生。device-aware flag 开启后,helper 为每个 persistent device id 独立查询;关闭时,非默认设备会记录 warning 并返回空结果。
public @NonNull List<PermissionGroupUsage> getOpUsageDataForAllDevices(
boolean includeMicrophoneUsage) {
List<PermissionGroupUsage> allUsages = new ArrayList<>();
if (mVirtualDeviceManager != null) {
List<VirtualDevice> virtualDevices = mVirtualDeviceManager.getVirtualDevices();
ArraySet<String> persistentDeviceIds = new ArraySet<>();
for (int num = 0; num < virtualDevices.size(); num++) {
persistentDeviceIds.add(
virtualDevices.get(num).getPersistentDeviceId());
}
persistentDeviceIds.add(
VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT);
for (int index = 0; index < persistentDeviceIds.size(); index++) {
allUsages.addAll(getOpUsageDataForIndicatorsByDevice(
includeMicrophoneUsage, persistentDeviceIds.valueAt(index)));
}
}
return allUsages;
}最终结果带有 persistentDeviceId,因此同一包在默认设备和虚拟设备上的访问不会被错误合并。
7. AppOps 过滤
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
private Map<String, List<OpUsage>> getOpUsagesByDevice(
List<String> opNames, String deviceId) {
List<AppOpsManager.PackageOps> ops;
try {
if (Flags.deviceAwarePermissionApisEnabled()) {
ops = mAppOpsManager.getPackagesForOps(
opNames.toArray(new String[opNames.size()]), deviceId);
} else if (!Objects.equals(
deviceId, VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT)) {
Slog.w(LOG_TAG,
"device_aware_permission_apis_enabled flag not enabled when deviceId is "
+ deviceId);
return Collections.emptyMap();
} else {
ops = mAppOpsManager.getPackagesForOps(
opNames.toArray(new String[opNames.size()]));
}
} catch (NullPointerException e) {
return Collections.emptyMap();
}捕获 NullPointerException 是兼容旧 AppOps 表的降级路径:旧版本可能不认识新请求的 op,helper 选择返回空,而不是让隐私指示器崩溃。
long now = System.currentTimeMillis();
long recentThreshold = getRecentThreshold(now);
long runningThreshold = getRunningThreshold(now);
int opFlags = OP_FLAGS_ALL_TRUSTED;
// 遍历 PackageOps、OpEntry 和 AttributedOpEntry
long lastAccessTime = attrOpEntry.getLastAccessTime(opFlags);
if (attrOpEntry.isRunning()) {
lastAccessTime = now;
}
if (lastAccessTime < currentRecentThreshold && !attrOpEntry.isRunning()) {
continue;
}
boolean isRunning = attrOpEntry.isRunning()
|| lastAccessTime >= currentRunningThreshold;只要 op 仍在运行,就把 lastAccessTime 视为当前时间;否则必须落在 recent 窗口内。isRunning 不只由 AppOps 的 running bit 决定,最近一次访问仍在 running threshold 内也会暂时显示为 active。
8. 位置过滤
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
if (isLocationOp) {
long lastFgAccess = attrOpEntry.getLastAccessForegroundTime(opFlags);
boolean isBackgroundAndNotRecentlyForeground =
isBackgroundApp(uid)
&& lastFgAccess < currentRecentThreshold
&& !attrOpEntry.isRunning();
if (isSystemApp(op, packageName, user, uid)
|| isBackgroundAndNotRecentlyForeground) {
continue;
}
}位置指示器只保留非系统、非后台的访问,除非后台应用刚刚在前台访问过且仍处于 holding period。isSystemApp 不是简单读取 ApplicationInfo.FLAG_SYSTEM:helper 会结合用户 profile、权限是否获授以及 USER_SENSITIVE_WHEN_GRANTED/DENIED 标志判断该访问是否应被用户看见。
isBackgroundApp 通过 ActivityManager.getRunningAppProcesses() 查找 uid 的 importance;找不到 uid 时再回退到 getUidImportance。这说明“后台过滤”是查询时的动态判断,不是 AppOps 写入时的永久属性。
9. 代理与归因链聚合
9.1 代理记录
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
AppOpsManager.OpEventProxyInfo proxy =
attrOpEntry.getLastProxyInfo(opFlags);
if (proxy != null && proxy.getPackageName() != null) {
proxyUsage = new OpUsage(
proxy.getPackageName(), proxy.getAttributionTag(), op,
proxy.getUid(), lastAccessTime, isRunning, null);
}
OpUsage usage = new OpUsage(
packageName, attributionTag, op, uid,
lastAccessTime, isRunning, proxyUsage);AppOps 的一条记录可能同时包含“实际执行访问的包”和“代理包”。helper 不立即输出两条 UI 项,而是把代理挂到 OpUsage.proxy,交给 getUniqueUsagesWithLabels 统一去重和生成标签。
9.2 按包和 UID 去重
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
Integer packageAttr = usage.getPackageIdHash();
if (!usages.containsKey(permGroupName)) {
ArrayMap<Integer, OpUsage> map = new ArrayMap<>();
map.put(packageAttr, usage);
usages.put(permGroupName, map);
} else {
Map<Integer, OpUsage> permGroupUsages = usages.get(permGroupName);
if (!permGroupUsages.containsKey(packageAttr)) {
permGroupUsages.put(packageAttr, usage);
} else if (usage.lastAccessTime
> permGroupUsages.get(packageAttr).lastAccessTime) {
permGroupUsages.put(packageAttr, usage);
}
}同一权限组中,同一 packageName + uid 只保留最近的一条访问。这样应用同时使用 FINE_LOCATION 和 COARSE_LOCATION 不会在指示器中出现两次。
9.3 代理标签与防环
while (currentUsage.proxy != null) {
if (allUsages.containsKey(currentUsage.proxy.getPackageIdHash())) {
currentUsage = allUsages.get(currentUsage.proxy.getPackageIdHash());
} else {
OpUsage proxy = currentUsage.proxy;
if (shouldShowPackage(proxy.packageName)) {
currentUsage = proxy;
maxUsages++;
} else {
break;
}
}
if (currentUsage == null || iterNum == maxUsages
|| currentUsage.getPackageIdHash() == start.getPackageIdHash()) {
break;
}
iterNum++;
}代理链缺少中间节点时,helper 只在代理包允许显示时补入;遇到空节点、达到最大遍历次数或回到起点就中止,避免损坏数据形成无限循环。麦克风旧代理逻辑和 camera 的 AttributionSource 链路并不完全相同,源码中明确保留了迁移 TODO,因此不能假设两者展示规则一致。
10. 结果模型
源码文件:frameworks/base/core/java/android/permission/PermissionGroupUsage.java
public PermissionGroupUsage(
@NonNull String packageName,
int uid,
long lastAccessTimeMillis,
@NonNull String permissionGroupName,
boolean active,
boolean phoneCall,
@Nullable CharSequence attributionTag,
@Nullable CharSequence attributionLabel,
@Nullable CharSequence proxyLabel,
@NonNull String persistentDeviceId) {
this.mPackageName = packageName;
this.mUid = uid;
this.mLastAccessTimeMillis = lastAccessTimeMillis;
this.mPermissionGroupName = permissionGroupName;
this.mActive = active;
this.mPhoneCall = phoneCall;
this.mAttributionTag = attributionTag;
this.mAttributionLabel = attributionLabel;
this.mProxyLabel = proxyLabel;
this.mPersistentDeviceId = persistentDeviceId;
}模型字段分别表达包、UID、最后访问时间、权限组、是否 active、是否电话通话、归因 tag/label、代理 label 和持久设备 ID。PermissionUsageHelper 负责填充这些字段,UI 不应再次从原始 AppOps 推断 active 或代理关系。
11. 电话通话归因
源码文件:frameworks/base/core/java/android/permission/PermissionUsageHelper.java
boolean hasPhoneCall = usedPermGroups.contains(OPSTR_PHONE_CALL_CAMERA)
|| usedPermGroups.contains(OPSTR_PHONE_CALL_MICROPHONE);
if (hasPhoneCall && usedPermGroups.contains(MICROPHONE)
&& audioManager.getMode() == MODE_IN_COMMUNICATION) {
TelephonyManager telephonyManager =
mContext.getSystemService(TelephonyManager.class);
List<OpUsage> permUsages = rawUsages.get(MICROPHONE);
for (int usageNum = 0; usageNum < permUsages.size(); usageNum++) {
if (telephonyManager.checkCarrierPrivilegesForPackage(
permUsages.get(usageNum).packageName)
== CARRIER_PRIVILEGE_STATUS_HAS_ACCESS) {
usedPermGroups.remove(OPSTR_PHONE_CALL_CAMERA);
usedPermGroups.remove(OPSTR_PHONE_CALL_MICROPHONE);
}
}
}通信模式下,如果麦克风使用者是具备 carrier privilege 的应用,helper 会隐藏电话通话 op,避免同一次蜂窝通话同时显示普通麦克风和电话通话两组提示。随后电话 op 会被映射到 CAMERA/MICROPHONE,并在模型中把 phoneCall 设为 true。
12. 完整时序
写入访问记录的 API 可能发生在不同系统服务或数据交付链中;本文的查询链从 AppOpsManager 开始,因为 PermissionUsageHelper 消费的是已经存在的 AppOps 状态。一个访问要出现在列表中,必须同时满足:对应 op 被查询、记录仍在窗口内、设备 flag 允许查询、过滤条件通过,并且最终没有被去重或代理聚合抹掉。
13. 排查方法
- 先确认调用方拥有
GET_APP_OPS_STATS,且getPermissionGroupUsageForPrivacyIndicator没有因为shouldShowIndicators()返回空。 - 再确认实际产生的是 camera、record audio 或 fine location 对应 AppOp,而不是只有 PermissionManager 的授予状态。
- 检查
DeviceConfig的 recent/running 窗口;访问超过阈值后,非 running 记录会被跳过。 - 对位置访问额外检查应用是否属于当前 profile、是否是系统敏感访问、是否后台且没有最近前台访问。
- 对虚拟设备检查 persistent device id 和
deviceAwarePermissionApisEnabledflag;旧路径只支持默认设备。 - 对“显示了代理应用”或“没有显示代理应用”的问题,沿
OpEventProxyInfo、mAttributionChains和shouldShowPackage检查,而不是只看 package 的权限授予结果。 - 若列表为空,区分“没有 AppOps 记录”“记录过期”“被位置过滤”“指示器开关关闭”和“旧版本 op 不兼容”这几类路径。
14. 源码阅读路线
推荐按以下顺序跳转 Android 17 源码:
AppOpsManager.getPermissionGroupUsageForPrivacyIndicator:确认系统 API、权限要求和 helper 的延迟初始化。PermissionUsageHelper的常量与构造函数:确认关注的 op、DeviceConfig 窗口和监听器注册。getOpUsageDataForAllDevices/getOpUsageDataForIndicatorsByDevice:确认设备遍历、feature flag 和结果模型构造。getOpUsagesByDevice:逐层阅读PackageOps、OpEntry、AttributedOpEntry、时间窗口和位置过滤。getUniqueUsagesWithLabels:确认代理链、归因标签、去重和循环保护。PermissionGroupUsage:把最终字段与 UI 可见数据对应起来。
权限授予决定“能不能访问”,AppOps 记录决定“是否发生过访问”,PermissionUsageHelper 再决定“这次访问是否应该被用户看见”。这三个层次必须分开,才能正确解释 Android 隐私指示器中的时间窗口、代理标签和设备维度。
