有一次做提交前自查,权限声明看起来没有问题:module.json5 里写了相机权限,页面也有隐私说明,测试机器上拍照流程通了。可一旦换成从未授权的新用户,问题就冒出来:他打开首页时为什么已经出现权限对话框?拒绝以后为什么手动录入按钮也灰掉了?审核方问“相机在哪个操作触发、拒绝以后能否继续使用”时,开发团队手头只有一张授权成功的截图。

这类问题不一定能靠删掉一行权限配置解决。真正需要检查的是页面行为、权限申请、用户选择、功能降级和取证之间有没有一致的时间线。这轮做了一个小型演示工程 AuditTraceLab:票据录入有“拍摄票据”和“手动录入”两条路径,前者使用 ohos.permission.CAMERA,后者不需要相机。我们为隐私声明版本 v1.4 建立审核自检任务 audit_20261009_02,将相机授予与拒绝、麦克风拒绝后的文字模式、系统选图器替代广泛媒体授权、位置能力不触发,以及启动前无业务采集请求,拆成相互独立的测试场景。图 03 呈现的是权限矩阵的综合结果(相机 GRANTED、麦克风 DENIED、媒体 PICKER_ONLY、位置 NOT_REQUIRED),而下面 CAM-001 的相机拒绝则是另一条专门复现的分支,两者不应混作同一次系统弹窗结果。

一、审核材料难的不是截图,而是解释操作次序

很多项目把合规做成一张静态勾选表:权限已声明、隐私政策已填写、SDK 名录已更新,就认为可以提交。问题是系统弹窗出现的时机来自运行时操作,截图只有一个时间切片,不能解释弹窗前做过什么,更不能证明拒绝授权后的分支可用。开发人员一旦用已授权的老设备测试,首次运行的真实路径也被绕过去了。

因此这次把复现输入写死:卸载后重新安装、清除或确认授权状态,启动 Demo,不主动点击相机入口;随后点击“拍摄票据”,在系统弹窗选择拒绝,再返回“手动录入”。验收中期望看到三个可验证条件:启动前业务敏感采集请求为 0,CAMERA 的演示授权结果是 DENIED,MANUAL_ENTRY 仍然为 AVAILABLE。在这个单独的拒绝子场景里,业务敏感采集请求为 0、相机请求结果为 DENIED、手动录入保持 AVAILABLE;综合截图则显示权限矩阵 4/4 项检查通过。这些演示结果不表示平台自动确认合规或保证审核通过。

这次我没有选“添加更多隐私文案”作为第一步。文案要准确,但如果点击路径错误,文案写得再漂亮也不能修复。先把场景边界画清楚:应用启动可以加载与隐私无关的资源;需要相机的动作由用户明确点击;拒绝相机后只能关闭相机相关能力,不应该一并禁用与其无关的功能;退出或重启后要能再次从可靠的系统权限状态恢复 UI。

二、把权限配置视为代码而不是表单

演示工程的结构很小:pages/AuditHomePage.ets 提供入口,services/PermissionGate.ets 负责授权请求,services/AuditRecorder.ets 记录业务事件,pages/EvidenceDetailPage.ets 展示可读证据,model/AuditEvent.ets 负责字段契约。和功能开发一样,权限声明应进代码评审;不仅看“有没有相机权限”,也看 reason 文案是否准确描述拍摄票据、usedScene 是否对应真实的使用 Ability。

// entry/src/main/module.json5(仅展示相关字段)
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:camera_for_receipt",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

资源文案建议是“用于拍摄票据并提取金额信息”,而不是“需要使用相机”这种无法说明业务目的的句子。配置中的 reason 只解决静态声明;对 user_grant 权限,还必须在用户进入相机相关功能时执行运行时请求。更需要注意不同能力可能有不同的最小权限或替代方案:例如能通过 Picker 完成的选图功能,不应为了图方便顺带扩大公共媒体目录权限。超出普通开放范围的权限,还可能涉及额外资格与审核,不能照着旧示例直接堆字段。

这里刻意不用一个布尔变量 hasAllPermission 来代表整个应用的权限状态。相机拒绝,不意味着手动输入也不安全;另一个权限正常,也不代表相机可以直接使用。每个功能入口应该有自己的依赖集合,然后再由页面组合出可用能力,避免把局部失败升级成全局故障。

三、给权限申请增加“因果编号”

现在需要解决的是重复弹窗与不可解释的回调。假设用户快速点击两次“拍摄票据”,第一次权限窗口尚未结束,第二次又走入相同流程。即使系统自身有保护,应用也不应该把两次点击解释成两次独立采集任务。我在业务服务里加入请求中的状态门闩,并把用户动作和授权结果写成同一个 attemptId,便于回放。

// services/PermissionGate.ets(精简示意)
import { abilityAccessCtrl, common } from '@kit.AbilityKit';

export class PermissionGate {
  private requesting: boolean = false;

  async requestCamera(context: common.UIAbilityContext,
    attemptId: string): Promise<boolean> {
    if (this.requesting) {
      console.info(`[AuditTraceLab] ${attemptId} duplicate_request_ignored`);
      return false;
    }
    this.requesting = true;
    console.info(`[AuditTraceLab] ${attemptId} request CAMERA`);
    try {
      const manager = abilityAccessCtrl.createAtManager();
      const result = await manager.requestPermissionsFromUser(
        context, ['ohos.permission.CAMERA']);
      const granted = result.authResults.length > 0 &&
        result.authResults[0] === 0;
      console.info(`[AuditTraceLab] ${attemptId} result=${granted ? 'GRANTED' : 'DENIED'}`);
      return granted;
    } finally {
      this.requesting = false;
    }
  }
}

这段服务只返回“能否继续当前相机动作”,不擅自打开相机,也不在业务启动时调用。真实工程还应捕获 BusinessError,向 UI 返回明确的异常分支,并在用户拒绝、系统不再弹窗或上下文失效时区分处理;不要把所有异常都打印为 DENIED,否则排查时无法区分用户决策和接口错误。result.authResults 的顺序要与请求权限数组对应,多权限请求尤其不能默认第一个就是你想要的权限。

门闩也不是权限状态的永久缓存。用户可以从系统设置里修改授权,页面重新进入或应用恢复时应重新查询真实权限状态。它只用于阻止短时重复发起同一请求。我们的事件示例使用 attemptId=CAM-001,和任务 audit_20261009_02 关联,审核证据可清楚看到 tap_camera → request_CAMERA → DENIED → fallback_manual 的顺序。

四、页面降级不能偷偷改变功能语义

用户拒绝授权后,界面常见的两种错误都很糟糕:一是无限循环弹窗直到同意,二是把整个“新增票据”页面锁住,让用户误以为必须开放相机。演示中的产品规则很明确:相机扫描是一条效率较高的路径,手动录入才是底线。因此页面状态应该反映功能粒度,而不是整页一个 authorized 开关。

// pages/AuditHomePage.ets(按钮与状态要点)
@Entry
@Component
struct AuditHomePage {
  @State cameraStage: string = 'IDLE';
  @State manualStage: string = 'AVAILABLE';
  private gate: PermissionGate = new PermissionGate();

  async onCaptureTap(): Promise<void> {
    this.cameraStage = 'REQUESTING';
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    try {
      const allowed = await this.gate.requestCamera(ctx, 'CAM-001');
      this.cameraStage = allowed ? 'READY_TO_CAPTURE' : 'DENIED';
      // 仅 allowed=true 时才创建相机会话
    } catch (err) {
      this.cameraStage = 'REQUEST_ERROR';
      console.error(`[AuditTraceLab] camera request error ${JSON.stringify(err)}`);
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('票据录入').fontSize(24)
      Button('拍摄票据').onClick(() => { this.onCaptureTap(); })
      Button('手动录入').onClick(() => {
        this.manualStage = 'AVAILABLE';
      })
      Text(`相机:${this.cameraStage} / 手动:${this.manualStage}`)
    }
    .padding(20)
  }
}

这段代码展示的是入口边界,完整工程仍需要导入服务类、补齐异常类型以及相机资源的生命周期管理。真正进入相机预览后,页面离开时必须释放对应采集会话,不得因为 UI 上写着 DENIED 就假定所有设备资源自动关闭。应用还要解释拒绝后的替代流程,避免用户以为资料无法添加。对于审核复现,一条无歧义的“拒绝后返回手动录入并成功提交模拟数据”比单纯显示权限拒绝消息更有说服力。

另外,用户点击“同意隐私声明”和系统弹框授予相机是两件不同的事。若应用使用平台托管的隐私声明服务,应根据实际托管状态规划后续权限请求;若自行维护隐私流程,也要确保正式告知、同意记录和功能触发顺序一致。不能把系统权限授权当成对全部数据处理行为的一揽子同意,也不应把手动录入随意设计成隐私说明的绕过通道。

五、日志必须能还原“没有发生的事”

正向日志很容易写:点击相机、弹窗、结果拒绝。审核和安全自测还需要证明本该不发生的事情没有发生,例如启动后尚未操作时有没有读取相机、上传图片或访问与该功能有关的数据。这里的“业务敏感采集请求数 0”是我们自定义埋点范围内的统计,它只证明被仪表化的调用路径没有触发,不能自动证明应用或第三方 SDK 完全没有任何数据行为。

// services/AuditRecorder.ets(业务事件协议)
export interface AuditEvent {
  taskId: string;
  attemptId: string;
  action: string;
  outcome: string;
  at: string;
}

export class AuditRecorder {
  private events: AuditEvent[] = [];

  append(event: AuditEvent): void {
    this.events.push({ ...event });
    console.info(`[AuditTraceLab] task=${event.taskId} ` +
      `attempt=${event.attemptId} action=${event.action} outcome=${event.outcome}`);
  }

  exportLines(): string[] {
    return this.events.map((event: AuditEvent) =>
      `${event.at}|${event.taskId}|${event.attemptId}|` +
      `${event.action}|${event.outcome}`);
  }
}

日志不应存储票据正文、真实照片、用户身份证明或完整设备标识。审核取证的目标是解释行为边界,因此使用任务 ID、结果类别、事件时间、配置版本等足够定位问题的字段即可。导出前还要检查第三方日志、错误堆栈和文件路径里是否带有敏感内容。生产版本与调试版本的日志开关需有明确差异,不能为了自测长期开启过度详细的收集。

本轮模拟执行日志包含:task=audit_20261009_02、build=v1.4、preConsentSensitiveCalls=0、attempt=CAM-001、permission=DENIED、manual=AVAILABLE。这里没有凭空假设相机硬件已经拍到了图片,也没有在拒绝权限后调用相机 API。这样的记录更容易映射回页面,从而解释为什么拒绝分支仍然满足产品可用性。

六、证据包不是随手复制三张截图

我给每个自检任务创建独立目录,用 audit_20261009_02 作为目录键,避免测试人员把上个版本的截图混进本轮。包内至少保留三种内容:permissions.json 是静态声明和检查结果,flow-log.txt 是脱敏事件链,evidence-index.md 是人工可读的证据索引,注明版本、测试入口、操作步骤和界面图编号。截图不是全部证据,日志也不是单独的真相,二者必须能靠 ID 对齐。

目录路径演示为 /data/storage/el2/base/files/audit/audit_20261009_02/。这是应用沙箱中的示例位置,正式导出方式还要遵守文件访问和分享规范,不能默认任意第三方应用能直接读取此路径。为避免影响发布包体积,长时间积累的证据应有保留周期和清理策略,尤其不要把真实用户的敏感数据留在开发测试目录里。

图 03 展示的是自检页面的四种权限相关结果:CAMERA=GRANTED、MICROPHONE=DENIED、PHOTO=PICKER_ONLY、LOCATION=NOT_REQUIRED,四项行为校验显示 4/4 已通过。注意它是任务 audit_20261009_02 的综合场景矩阵,与前文 CAM-001 的相机拒绝子场景并不是同一次点击。权限测试并不要求用户把所有权限都授予:在麦克风拒绝场景下正确切到文字记录模式,在选图场景下使用系统 Picker,同样可以构成通过的验收结果。

为了防止结果误读,我会在截图旁写出规则:首次启动不请求相机;点击“拍摄票据”时请求;用户拒绝后保留手动录入;系统设置后重新检查实际权限;申请失败应显示操作性提示;脱敏日志与截图可以追踪同一任务。假如用户选择了“不再询问”,不要用无限弹窗或遮挡手动入口的方式强迫授权,而应在确需使用相机时给出清晰说明,并让用户自主决定后续操作。

七、详情页把“系统结果”和“自测结论”分开

图 04 是 EvidenceDetailPage,用来回答审核复现最常问的三个问题:到底请求了什么权限?系统实际返回什么?拒绝之后业务做了什么?页面把 CAMERA / DENIED 放在系统结果栏,把 MANUAL_ENTRY / AVAILABLE 放在业务结果栏,再把 18/18 PASS 放在自测判定栏。这三个层级必须分开,不能把业务自测通过画成系统已授权,更不能把自测通过写成平台审核已经通过。

图 04 是证据归档后的结果页:状态 READY_FOR_SUBMISSION、归档名 audit_pkg_20261009_02.zip,界面汇总了 6 张操作截图、18 条日志、隐私声明版本 v1.4,并记录麦克风拒绝证明 mic_denied_proof.jpg 及照片选择器降级标记 PHOTO_PICKER_USED。它是按本文场景设计的演示数据,不代表实机上已经发生过平台提审。导出的索引同时记录场景编号 CAM-001、任务 audit_20261009_02 和正文示例的三类逻辑证据文件(清单、日志、索引);这三类文件不同于界面统计的六张截图。在实际提审前,我会用空白安装、升级安装、拒绝后再次进入、撤销权限后返回页面、离线条件、系统语言变更和 SDK 异常至少跑一轮。不同路径可能暴露不同的首次启动行为,尤其是一些第三方 SDK 的初始化动作,不能因 Demo 页面没有主动发起请求就忽略 SDK 的真实行为。

项目里还应给截图增加自检版本和时间,但不要在正常用户可见的生产页面上放这些调试信息。研发版可通过开关进入审核验证面板,正式版则将证据保存和导出工具剥离或严格限制入口;这既减少非必要功能暴露,也避免调试日志长期驻留。证据生成时应当判断写盘成功、记录文件大小与校验结果,否则按钮显示“导出成功”却没有文件,是另一类典型的假阳性。

八、把静态扫描、运行回放与人工审查接起来

从工程实施角度,我把 18 个自测项分成几组:声明字段和用途文案检查;首次启动与点击路径检查;拒绝、允许、取消和系统改权后的状态检查;手动降级检查;日志脱敏和证据导出检查。每项都有输入、期望、观测和结论,不允许一个“已测”复选框同时代表几类不同问题。版本迭代以后,对配置差异和 SDK 升级做增量回归,避免完整流程被测试习惯替代。

这套小工具不是一个能替代 AppGallery Connect 审核的检测器。官方审核指南、市场政策、隐私保护规则可能调整,不同应用涉及的资质、数据处理场景与三方 SDK 差异也很大。Demo 展示的是可迁移的工程方法:把权限的请求原因、运行时触发点、用户结果与业务降级用统一 ID 连起来,并保留足够让其他人复现的证据。对正式产品,还要逐一核对集成 SDK 的数据收集目的、方式和范围是否在隐私政策中明示。

八点一、把 18 项测试拆成“行为契约”

自检项目越多,越需要防止分数形式化。我给每项都写四个字段:触发操作、允许发生的系统能力调用、禁止发生的业务行为,以及可以复核的证据。例如 AT-01 的操作是冷启动,允许渲染本地首页,却禁止未点击时发起相机授权请求;AT-07 的操作是点击拍摄并拒绝权限,允许页面显示拒绝提示,却禁止继续创建相机会话;AT-12 的操作是转到手动录入,必须保持输入框可编辑。这比一句“权限申请正常”更能发现版本回归。

自动化能检查事件顺序、UI 按钮状态、权限配置差异和导出文件是否存在,但文案是否充分告知、SDK 实际的数据收集范围和用户是否被诱导授权,仍需要人工审阅。因此汇总页虽然显示 18/18 PASS,索引文件还会注明检查范围与未覆盖项。测试计划中至少要明确:本次没有进行平台审核、没有使用真实用户票据,也没有评估应用之外第三方服务的所有行为。自测结论越明确限定边界,越不容易在团队流转时被误当成最终合规证明。

八点二、升级包与首次安装必须分开跑

研发测试常用已经反复安装的设备,很容易把授权状态带进下一轮。新版已经删掉了某个使用入口,但旧系统设置里可能还留着历史许可;反过来,首次安装时的新隐私说明也可能改变整个启动流程。我的习惯是准备两套独立脚本:一套从干净安装开始,关注首次告知和首次触发;另一套按真实用户升级路径运行,关注旧授权状态、持久化数据和 SDK 配置迁移。

对“拒绝后改为允许”的回归,要观察页面重新进入时是否查询最新系统状态,而不是继续读缓存中的 DENIED;对“允许后在设置中撤销”的回归,则要确认应用在下次访问相机前重新检查,不能继续使用已失效的会话。还要在日志中把权限系统返回值、业务是否继续执行分开记录。只要出现两者不一致,就应让检查失败,而不是用一次弹窗成功截图掩盖状态同步问题。

九、这次最值得留下的工程约束

回头看,团队原先以为“有权限声明、有授权成功截图”就是完成权限接入,后来才意识到合规验收关注的是整个使用过程。现在 AuditTraceLab 的任务 audit_20261009_02 有明确的隐私声明版本 v1.4、综合矩阵 4/4 项通过、相机拒绝子场景 CAM-001 的 DENIED 和手动路径 AVAILABLE,以及 audit_pkg_20261009_02.zip 的演示归档状态;归档页另展示六张截图和十八条运行日志。这个结果只适用于本文设计的演示检查项,不能外推到真实 App 的全部合规条款。

更重要的不是这 18 项,而是以后有人修改相机入口、接入 OCR SDK、引入新的文件选择能力,能马上问出几个具体问题:新功能多请求了什么权限?用户在什么时点看见说明?拒绝以后剩下哪些功能?证据是否还覆盖新路径?这些问题能提前在提交前被回答,就减少了靠最后一天补截图、补文案、临时拆 SDK 的被动局面。

参考资料(提交前请重新核对最新版政策和 SDK):

  • HarmonyOS 权限声明:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/declare-permissions
  • 向用户申请授权相关权限与分类:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/permissions-for-all-V5
  • 隐私管理服务:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/store-privacy-V5
  • 提交 HarmonyOS 应用:https://developer.huawei.com/consumer/cn/app/submit/

说明:上述 18/18 PASS、日志、手机与 IDE 配图均为同一演示用例的仿真数据,不是平台审核结论,也不是真机实测留证。权限、用户授权、文件保存和发布流程需在目标 SDK、设备和正式审核环境重新确认。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐