Skip to content

权限使用历史

解释访问记录如何经过时间窗口、设备和归因链过滤,生成隐私指示器可消费的数据。

AndroidPMSAppOps隐私

权限使用历史 ​

Android 的权限历史并不是 PermissionManagerService 维护的一张“最近访问表”。在 Android 17 中,受隐私指示器关注的访问先由 AppOps 记录,再由 PermissionUsageHelper 按权限组、用户、设备和归因链聚合,最后通过 AppOpsManager 暴露为 PermissionGroupUsage。本文只讨论这条真实链路,以及它如何处理运行中访问、代理应用、位置后台访问和虚拟设备。

1. 先区分两种数据 ​

“权限历史”容易和“权限是否被授予”混淆。前者回答应用最近有没有使用受保护能力,后者回答当前调用是否被允许。Android 17 的实现将二者拆开:

数据主要 owner典型消费者生命周期
权限授予状态PermissionManager / AccessCheckingServicecheckPermission、运行时授权持久权限状态
AppOp 访问事件AppOpsService操作检查、历史查询按访问事件变化
指示器聚合结果PermissionUsageHelper状态栏隐私指示器最近窗口或运行中

隐私指示器只关心 camera、microphone、location 对应的 AppOps。普通的 MODE_ALLOWED 检查结果不会自动生成 UI 数据;必须有 AppOps 访问记录,并且通过 helper 的时间、用户、敏感性和代理过滤。

2. AppOps 入口 ​

源码文件:frameworks/base/core/java/android/app/AppOpsManager.java

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

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

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

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

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

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

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 并返回空结果。

java
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

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 选择返回空,而不是让隐私指示器崩溃。

java
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

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

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

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 代理标签与防环 ​

java
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

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

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. 排查方法 ​

  1. 先确认调用方拥有 GET_APP_OPS_STATS,且 getPermissionGroupUsageForPrivacyIndicator 没有因为 shouldShowIndicators() 返回空。
  2. 再确认实际产生的是 camera、record audio 或 fine location 对应 AppOp,而不是只有 PermissionManager 的授予状态。
  3. 检查 DeviceConfig 的 recent/running 窗口;访问超过阈值后,非 running 记录会被跳过。
  4. 对位置访问额外检查应用是否属于当前 profile、是否是系统敏感访问、是否后台且没有最近前台访问。
  5. 对虚拟设备检查 persistent device id 和 deviceAwarePermissionApisEnabled flag;旧路径只支持默认设备。
  6. 对“显示了代理应用”或“没有显示代理应用”的问题,沿 OpEventProxyInfo、mAttributionChains 和 shouldShowPackage 检查,而不是只看 package 的权限授予结果。
  7. 若列表为空,区分“没有 AppOps 记录”“记录过期”“被位置过滤”“指示器开关关闭”和“旧版本 op 不兼容”这几类路径。

14. 源码阅读路线 ​

推荐按以下顺序跳转 Android 17 源码:

  1. AppOpsManager.getPermissionGroupUsageForPrivacyIndicator:确认系统 API、权限要求和 helper 的延迟初始化。
  2. PermissionUsageHelper 的常量与构造函数:确认关注的 op、DeviceConfig 窗口和监听器注册。
  3. getOpUsageDataForAllDevices / getOpUsageDataForIndicatorsByDevice:确认设备遍历、feature flag 和结果模型构造。
  4. getOpUsagesByDevice:逐层阅读 PackageOps、OpEntry、AttributedOpEntry、时间窗口和位置过滤。
  5. getUniqueUsagesWithLabels:确认代理链、归因标签、去重和循环保护。
  6. PermissionGroupUsage:把最终字段与 UI 可见数据对应起来。

权限授予决定“能不能访问”,AppOps 记录决定“是否发生过访问”,PermissionUsageHelper 再决定“这次访问是否应该被用户看见”。这三个层次必须分开,才能正确解释 Android 隐私指示器中的时间窗口、代理标签和设备维度。