第78篇 | HarmonyOS 隐私失败态:认证失败、未解锁和导出限制如何提示

第 78 篇收束第 16 天:隐私能力不仅要有成功路径,还要有失败态。保险箱、系统导出、防窥、分享这些能力都和系统权限、用户确认、设备支持有关。只写成功路径的 Demo 很容易看起来漂亮,但用户一旦取消授权或设备不支持,就会卡住。

双镜记忆相机在多个位置处理失败态:认证结果映射成可读文案,敏感操作前要求再次本地认证,导出到系统相册时处理空文件、取消保存和保存失败,防窥能力把权限缺失、设备不支持、系统未开启区分开。本文用这些真实代码整理一套隐私失败态写法。

本篇目标

  • 理解隐私失败态为什么要区分权限、认证、设备和用户取消。
  • 掌握敏感操作前复用本地认证的写法。
  • 理解导出系统相册时如何处理空文件、取消和异常。
  • 学会把错误结果写回对应作用域的状态文案。

对应源码位置

  • superImage/entry/src/main/ets/pages/Index.ets

失败态决定隐私功能是否可信

保险箱页面看起来只有一个解锁按钮,但它背后有很多可能失败的点:权限被拒绝、没有录入生物特征、认证超时、设备不支持、导出时用户取消、源文件还没恢复。每一种失败都需要让用户知道当前发生了什么。

高质量文章不能只截图成功页面,还要把失败路径写出来。真实项目中,用户遇到最多的往往不是“代码完全不可用”,而是某个系统条件没满足。把这些条件处理好,功能才像一个完整应用,而不是一次性演示。

保险箱隐私操作需要明确失败提示和状态恢复

保险箱隐私操作需要明确失败提示和状态恢复

认证结果先映射成用户能懂的话

getUserAuthResultMessage 把 UserAuth 的结果码映射成具体文案。失败、取消、超时、服务忙、认证能力锁定、未录入、类型不支持、信任等级不支持都分别处理。这样页面不需要直接展示数字错误码。

这类映射函数建议集中维护。后续如果要把文案换成更产品化的表达,或者对某些错误码增加引导,只需要改一个函数。训练营项目写到这里时,可以把它作为“错误码到用户语言”的示例。

getUserAuthResultMessage 将认证结果转换为可读状态

getUserAuthResultMessage 将认证结果转换为可读状态

  private getUserAuthResultMessage(resultCode: number): string {
    if (resultCode === userAuth.UserAuthResultCode.SUCCESS) {
      return '认证成功';
    }
    if (resultCode === userAuth.UserAuthResultCode.FAIL) {
      return '认证失败,请重试';
    }
    if (resultCode === userAuth.UserAuthResultCode.CANCELED) {
      return '已取消认证';
    }
    if (resultCode === userAuth.UserAuthResultCode.TIMEOUT) {
      return '认证超时';
    }
    if (resultCode === userAuth.UserAuthResultCode.BUSY) {
      return '认证服务正忙,请稍后再试';
    }
    if (resultCode === userAuth.UserAuthResultCode.LOCKED) {
      return '认证能力暂时锁定';
    }
    if (resultCode === userAuth.UserAuthResultCode.NOT_ENROLLED) {
      return '还没有设置可用的本地身份认证';
    }
    if (resultCode === userAuth.UserAuthResultCode.TYPE_NOT_SUPPORT) {
      return '当前设备不支持此认证方式';
    }
    if (resultCode === userAuth.UserAuthResultCode.TRUST_LEVEL_NOT_SUPPORT) {
      return '当前安全等级不支持';
    }
    return `未知认证结果:${resultCode}`;

解锁流程要处理空保险箱和并发点击

unlockVaultWithLocalAuth 先判断 vaultAuthBusy,避免多次点击并发拉起认证;如果保险箱没有记录,直接返回。进入认证流程后,权限失败、认证不通过和异常都会更新 vaultUnlockedvaultStatusText

最关键的是 finally 里恢复 vaultAuthBusy。没有这个恢复,用户一次取消认证后按钮可能永远处于“认证中”。失败态不是只写 catch,而是要保证状态能回到下一次可操作。

unlockVaultWithLocalAuth 处理并发点击、权限失败和认证失败

unlockVaultWithLocalAuth 处理并发点击、权限失败和认证失败

  private async unlockVaultWithLocalAuth(
    authProvider: () => Promise<userAuth.UserAuthResult>
  ): Promise<void> {
    if (this.vaultAuthBusy) {
      return;
    }
    if (this.getVaultRecords().length === 0) {
      this.vaultStatusText = '';
      return;
    }

    this.vaultAuthBusy = true;
    this.vaultStatusText = '正在进行身份认证...';
    try {
      const permissionReady = await this.requestBiometricPermission();
      if (!permissionReady) {
        this.vaultStatusText = '';
        return;
      }
      this.vaultStatusText = '';
      const result = await authProvider();
      if (result.result === userAuth.UserAuthResultCode.SUCCESS) {
        this.vaultUnlocked = true;
        this.vaultStatusText = '';
      } else {
        this.vaultUnlocked = false;
        this.vaultStatusText = `身份认证未通过:${this.getUserAuthResultMessage(result.result)}`;
      }
    } catch (error) {
      this.vaultUnlocked = false;
      if (error instanceof Error) {
        this.vaultStatusText = `解锁保险箱失败:${error.message}`;
      } else {
        const err = error as BusinessError;
        this.vaultStatusText = `解锁保险箱失败:${err.code ?? -1}`;
      }
    } finally {
      this.vaultAuthBusy = false;
    }
  }

  private async unlockVaultWithFace(): Promise<void> {
    await this.unlockVaultWithLocalAuth(() => this.authenticateVault());
  }

  private async unlockVaultWithFingerprint(): Promise<void> {
    await this.unlockVaultWithLocalAuth(() => this.authenticateVaultWithFingerprint());

敏感操作前复用本地认证

查看保险箱是一层保护,导出私密照片又是另一层敏感操作。requireLocalAuthForSensitiveAction 会在执行保存、导出等动作前再次要求本地身份认证,并把状态写回 gallery 或 vault 对应作用域。

这个函数复用了权限申请和 authenticateVault,成功返回 true,失败返回 false。调用方只要在敏感操作前 await 它,就能把“二次确认”做成统一模式,避免每个导出按钮都重新写认证流程。

导出等敏感操作前统一复用本地认证

导出等敏感操作前统一复用本地认证

  private async requireLocalAuthForSensitiveAction(
    scope: 'gallery' | 'vault',
    actionText: string
  ): Promise<boolean> {
    if (this.vaultAuthBusy) {
      return false;
    }

    this.vaultAuthBusy = true;
    this.updateRecordExportStatus(scope, '正在验证身份...');
    try {
      const permissionReady = await this.requestBiometricPermission();
      if (!permissionReady) {
        this.updateRecordExportStatus(scope, `${actionText}需要本地身份验证`);
        return false;
      }

      const result = await this.authenticateVault();
      if (result.result === userAuth.UserAuthResultCode.SUCCESS) {
        this.updateRecordExportStatus(scope, '');
        return true;
      }

      this.updateRecordExportStatus(scope, `${actionText}未完成:${this.getUserAuthResultMessage(result.result)}`);
      return false;
    } catch (error) {
      const message = error instanceof Error ? error.message : JSON.stringify(error);
      this.updateRecordExportStatus(scope, `${actionText}认证失败:${message}`);
      return false;
    } finally {
      this.vaultAuthBusy = false;
    }
  }

导出到系统相册也要有失败兜底

导出系统相册前,函数先构造导出项;没有可保存文件时直接提示。对于 vault 作用域,还会先调用敏感操作认证。真正保存时,项目处理了源文件不可用、用户取消、保存失败和 photo helper 释放。

这段代码说明隐私失败态不只发生在认证层。文件、系统相册权限、用户确认、系统 API 都可能失败。把状态写回 updateRecordExportStatus,用户才能知道当前是取消、保存失败还是文件还在恢复。

exportRecordToSystemAlbumWithSaveGrant 处理导出空项、认证和保存异常

exportRecordToSystemAlbumWithSaveGrant 处理导出空项、认证和保存异常

  private async exportRecordToSystemAlbumWithSaveGrant(
    record: GalleryMoment,
    scope: 'gallery' | 'vault'
  ): Promise<void> {
    if (this.mediaExportBusy) {
      return;
    }

    const exportItems = this.buildRecordExportItems(record);
    if (exportItems.length === 0) {
      this.updateRecordExportStatus(scope, '没有可保存的照片');
      return;
    }

    if (scope === 'vault') {
      const authReady = await this.requireLocalAuthForSensitiveAction(scope, '保存私密照片');
      if (!authReady) {
        return;
      }
    }

    this.mediaExportBusy = true;
    this.updateRecordExportStatus(scope, `正在保存 ${exportItems.length} 张照片到系统相册...`);
    const context = this.getAbilityContext();
    let photoHelper: photoAccessHelper.PhotoAccessHelper | undefined = undefined;
    try {
      photoHelper = photoAccessHelper.getPhotoAccessHelper(context);
      let exportedCount = 0;
      for (const item of exportItems) {
        const sourceUri = this.getRecordExportSandboxUri(item);
        if (sourceUri.length === 0) {
          console.warn(`Skip non-sandbox export source: ${item.sourceUri}`);
          continue;
        }
        const assetChangeRequest = photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, sourceUri);
        await photoHelper.applyChanges(assetChangeRequest);
        exportedCount++;
      }
      if (exportedCount === 0) {
        this.updateRecordExportStatus(scope, '照片文件还在恢复中,请稍后再保存');
        return;
      }
      this.updateRecordExportStatus(scope, `已保存 ${exportedCount} 张到系统相册`);
    } catch (error) {
      const err = error as BusinessError;
      this.updateRecordExportStatus(scope, `保存失败:${this.getFriendlyMediaExportError(err)}`);
      console.error(`Failed to export record with save grant: ${JSON.stringify(error)}`);
    } finally {
      if (photoHelper) {
        try {
          await photoHelper.release();
        } catch (error) {
          console.error(`Failed to release photo helper: ${JSON.stringify(error)}`);
        }
      }
      this.mediaExportBusy = false;
    }
  }

工程检查清单

  • 认证结果不能直接展示数字码,要映射成用户语言。
  • 认证、导出、分享都需要 busy 状态和 finally 恢复。
  • 保险箱敏感操作前应复用本地认证函数。
  • 错误状态要写回对应页面作用域,gallery 和 vault 不要混用。

今日练习

  1. 模拟用户取消认证,确认 vaultAuthBusy 会恢复为 false。
  2. 删除导出源文件路径,观察导出函数如何提示。
  3. getUserAuthResultMessage 增加一个默认日志,记录未知结果码。

训练营里的每一篇都建议按同一个节奏复盘:先看页面行为,再回到源码定位状态和服务层,最后自己改一个很小的参数验证结果。这样写文章时不会停留在 API 名词,读者也能沿着真实工程把功能跑通。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐