Demo:TouchConsent|任务编号:TP-206|业务素材:DOC-418 / 会议资料.pdf / 2.3 MB
内容性质:精准碰一碰接收入口之后的业务确认层方案。数据、日志、示意界面均由案例构造,未接入或实测系统精准碰一碰回调。

先讨论一个不舒服的问题:收到了,等于用户接受了吗

“碰一下就到了”听起来轻巧,但在文档、名片或协作素材的业务接收场景里,近场传递成功不应自动等同于用户确认使用内容。接收方可能正在切换应用,弹窗刚出现就进入后台;用户还没有看清文件名,后台却把素材写入了正式列表。更糟的是,应用被系统回收后再次打开,页面丢了待确认态,用户以为已经拒绝,业务端却还在等待。如果直接把事件回调当作最终承诺,就很难区分“系统已经把意图交给应用”和“用户明确接收”。

这篇示例刻意不实现近场发现、碰撞检测和系统级精准碰一碰分享 API。HarmonyOS 的能力接入需要查当期官方指南、开通条件及适用设备,不能把一个普通 Promise 包装成系统近场回调。我们假定外部合法入口完成来源验证后,将最小化的业务载荷交给 TouchConsent:任务 TP-206、文档 DOC-418、展示名“会议资料.pdf”、大小标识 2.3 MB 和接收时间。业务层只负责在有限时间内展示、等待显式确认或撤回;没有完成确认前不得移动文件到正式资源库,也不得自动打开附件。

根据这组数据设计状态机,至少需要 WAIT_CONFIRM、CONFIRMED、REVOKED、EXPIRED 四种互斥状态。WAIT_CONFIRM 是唯一可经人工操作转入确认或撤回的状态;已过期后点击确认只能得到过期提示。最终态应该留一段可复核的状态记录,而不是立即删除所有信息:若程序重启,系统才能辨别用户已撤回,而不是把旧消息当新消息再次弹出。示例 TTL 定为 120 秒,只是业务选择,不是华为平台规定的近场确认期限。

存得少,但保存点必须明确

首选项 Preferences 适合存少量键值型轻量数据。这里不往里面塞 PDF、二进制内容或身份信息,只保存一个业务意图的简短结构。华为文档中常见的 preferences.getPreferences、get、put、flush 可用来建立持久化入口。put 将修改先写入首选项实例,flush 负责同步到持久化文件;若开发者只写内存而没有在关键动作后落盘,杀进程后的恢复测试就没有意义。项目需要根据当前 SDK 核对 Options 的签名,示例使用 name 选项,不配置 dataGroupId。

// entry/src/main/ets/model/ReceiveIntent.ets + ConsentStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export type ConsentState = 'WAIT_CONFIRM' | 'CONFIRMED' | 'REVOKED' | 'EXPIRED';
export interface ReceiveIntent {
  taskId: string;
  docId: string;
  displayName: string;
  sizeLabel: string;
  expiresAt: number;
  state: ConsentState;
}

export class ConsentStore {
  private pref?: preferences.Preferences;
  async open(context: common.UIAbilityContext): Promise<void> {
    this.pref = await preferences.getPreferences(context, { name: 'touch_consent_206' });
  }
  async read(): Promise<ReceiveIntent | undefined> {
    if (!this.pref) throw new Error('Preferences not initialized');
    const raw = await this.pref.get('DOC-418', '') as string;
    if (!raw) return undefined;
    return JSON.parse(raw) as ReceiveIntent;
  }
  async write(value: ReceiveIntent): Promise<void> {
    if (!this.pref) throw new Error('Preferences not initialized');
    await this.pref.put(value.docId, JSON.stringify(value));
    await this.pref.flush();
  }
}

上面的常量键只为这份单文档演示;实际接入必须按可信的业务 ID 建立键,并限制长度、字符集和数据存储量。尤其不能直接拿外部传入的文件名当键,文件名可能重复、含路径分隔符或个人信息。JSON.parse 遇到损坏记录会抛异常,实际工程应在入口加版本字段、schema 校验及损坏数据隔离,不要用空对象糊弄过去。另一个边界是 Preferences 不提供跨设备强事务;若多个线程、多个 Ability 或远端服务同时修改同一单据,应通过统一串行入口或更适合的数据库建立事务约束。

过期检查要读时执行,不靠一个定时器保命

单靠页面上倒计时归零不够。应用进入后台后,定时器可能暂停;用户重新打开时,若只恢复保存的 WAIT_CONFIRM 字符串而没有检查绝对时间,会出现已经过去十分钟却仍允许点击接收的漏洞。正确做法是存入 expiresAt = 接收时刻 + 120000,每次读取、每次点击动作都重新比对 Date.now()。页面倒计时只是视觉提示,不能当唯一的安全条件。这里的时间依据设备系统时钟,如果业务要求强防重放和跨设备可信时序,还需服务端时间及令牌协议,本文没有解决时钟回拨攻击。

// entry/src/main/ets/service/ConsentController.ets
import { common } from '@kit.AbilityKit';
import { ConsentStore, ReceiveIntent, ConsentState } from '../model/ConsentStore';

export class ConsentController {
  private store: ConsentStore = new ConsentStore();
  async init(ctx: common.UIAbilityContext): Promise<void> {
    await this.store.open(ctx);
  }
  async receiveSample(now: number): Promise<void> {
    const item: ReceiveIntent = {
      taskId: 'TP-206', docId: 'DOC-418', displayName: '会议资料.pdf',
      sizeLabel: '2.3 MB', expiresAt: now + 120000, state: 'WAIT_CONFIRM'
    };
    await this.store.write(item);
    console.info('TP-206 PENDING saved doc=DOC-418');
  }
  async inspect(now: number = Date.now()): Promise<ReceiveIntent | undefined> {
    const current = await this.store.read();
    if (current && current.state === 'WAIT_CONFIRM' && now >= current.expiresAt) {
      current.state = 'EXPIRED';
      await this.store.write(current);
    }
    return current;
  }
  async act(action: 'CONFIRM' | 'REVOKE', now: number = Date.now()): Promise<ConsentState> {
    const current = await this.inspect(now);
    if (!current) return 'EXPIRED';
    if (current.state !== 'WAIT_CONFIRM') return current.state;
    current.state = action === 'CONFIRM' ? 'CONFIRMED' : 'REVOKED';
    await this.store.write(current); // 持久化最终态,避免重启再次弹确认
    return current.state;
  }
}

这里没有用 delete('DOC-418') 直接代表撤回。删除使“从未见过该业务 ID”和“明确拒绝过该业务 ID”变得不可区分,若消息源重放还会再次进入待确认。保留小型终态记录,配合业务设置的清理周期,会更容易审计;但终态记录也不应无限积累。对于真正的多消息系统,建议引入去重键、消息版本和带索引的数据库,并明确用户主动删除与系统过期清理的不同策略。

把页面做成明确的二次确认,不抢答用户

页面的 aboutToAppear 执行初始化,若是构造场景第一次进入则注入 DOC-418 测试意图;若已经保存了终态,则只恢复。页面上展示具体文件名和大小,以及“确认接收”“撤回”两个动作。动作执行过程中应禁用按钮,防止连续点击并发把互斥状态写入不同结果。为了让核心部分清楚,下面仅列最小 UI 和事件绑定;真正的 PDF 预览、来源身份校验、文件下载与沙箱迁移,需要放在明确确认之后的另一套安全流程。

// entry/src/main/ets/pages/Index.ets(演示页核心逻辑)
import { common } from '@kit.AbilityKit';
import { ConsentController } from '../service/ConsentController';

@Entry
@Component
struct Index {
  private ctl: ConsentController = new ConsentController();
  @State stage: string = 'LOADING';
  @State remaining: number = 120;
  @State busy: boolean = false;

  async aboutToAppear(): Promise<void> {
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    await this.ctl.init(ctx);
    let record = await this.ctl.inspect();
    if (!record) {
      await this.ctl.receiveSample(Date.now()); // 仅在演示无记录时注入
      record = await this.ctl.inspect();
    }
    if (record) {
      this.stage = record.state;
      this.remaining = Math.max(0, Math.ceil((record.expiresAt - Date.now()) / 1000));
      console.info(`TP-206 restore ${this.stage} remaining=${this.remaining}s`);
    }
  }

  private async choose(action: 'CONFIRM' | 'REVOKE'): Promise<void> {
    if (this.busy) return;
    this.busy = true;
    try { this.stage = await this.ctl.act(action); }
    finally { this.busy = false; }
  }

  build() {
    Column({ space: 16 }) {
      Text('接收确认  TP-206').fontSize(24)
      Text('DOC-418  会议资料.pdf  2.3 MB')
      Text(`${this.stage}  剩余 ${this.remaining}s`)
      Button('确认接收').enabled(!this.busy && this.stage === 'WAIT_CONFIRM')
        .onClick(() => { this.choose('CONFIRM'); })
      Button('撤回').enabled(!this.busy && this.stage === 'WAIT_CONFIRM')
        .onClick(() => { this.choose('REVOKE'); })
    }.width('100%').padding(18)
  }
}

配图里的表单是 DOC-418,任务号为 TP-206,业务状态为 WAIT_CONFIRM,图中可见的“剩余 120s”来自构造的初始时间。值得注意的是,IDE 风格插画底部为演示处理日志,可能包含用户确认后的后续步骤,而右侧模拟器保留初始待确认画面;它不是某次真实单帧录屏,因此不能据此推导实际同步性能。真正做工程截图时,应该固定时钟与事件触发时刻,从同一次构建中采集 UI 和 HiLog,确保两者的时间轴可复查。

最小验证表从“失败也说得通”开始

第一组用程序注入 now = T,写入 120 秒 TTL,立即读取得到 WAIT_CONFIRM;构造 T+119000 时确认,预期变为 CONFIRMED;再构造 T+120000 执行确认,预期过期校验抢先落盘,最后是 EXPIRED。第二组在 WAIT_CONFIRM 时选择撤回,持久化状态为 REVOKED,重启后必须仍然恢复 REVOKED,不能擅自重新接收。第三组特意模拟 flush 报错:页面不应先显示确认完成然后悄悄丢弃写盘错误,必须反馈失败并保留可重试入口。第四组连续两次触发不同按钮,检查 busy 门禁是否让第二次被忽略;真正并发写入时还要在仓储层串行化,而不能只依赖 UI 控件。

可读的预期日志类似:TP-206 PENDING saved doc=DOC-418、TP-206 restore WAIT_CONFIRM remaining=120s。点击确认后的终态日志必须另行记录,且 restore CONFIRMED 应仅来自重启后读取的持久化结果,而不是 UI 组件把本地布尔变量当作永久事实。真实测试应包含冷启动、进程销毁、应用升级和跨日期场景;这篇文章不提供上述真实测试报告。尤其是手机时钟人为调快或调慢时,业务只应把本地 120 秒当交互限时,不应当成可信授权令牌。

不能把接收确认伪装成系统授权

Preferences 的轻量级存储并非加密保险箱;不要将真实传输令牌、用户证件内容、完整文件或可复用的设备身份标识直接放入这里。实际系统级精准碰一碰能力是否开放、通过何种回调接入、适用哪些设备版本,要依赖华为当期正式资料与开发者权限验证。当前示例只演示“应用已拿到一个可信入口数据之后,如何不擅自替用户做决定”,既不声明支持所有 HarmonyOS 设备,也不声称触碰行为已在真机发生。

在更完整的方案里,服务端需要对确认事件执行幂等签收,文件落盘必须采用临时区到正式区的受控迁移,撤回和过期后的缓存应按应用隐私策略清理。用户选择撤回后,界面展示“已撤回”并不等于远端数据都已撤销,这类跨设备补偿协议是另一项工程。本文的可复用成果,是把接收意图和用户意志切成两个阶段,并让过期、撤回及重启后状态都有明确解释;系统分享接入层仍需独立开发与验证。

官方资料核对与使用边界

  • 华为用户首选项开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V14/data-persistence-by-preferences-V14
  • 华为开发者 Preferences 示例(为早期版本,需按当前 SDK 核对接口):https://developer.huawei.com/consumer/cn/forum/topic/0207125313251960030
  • ArkData 官方文档中心:https://developer.huawei.com/consumer/cn/doc/
Logo

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

更多推荐