审核退回信息只有一句:“用户导出的图片仍包含可识别的位置元数据。”我第一反应是检查分享参数,结果 UDMF 和系统分享面板都没问题。真正的问题藏在导出文件里:页面显示的是已经裁剪过的预览图,保存时却把原 JPEG 直接复制到了缓存目录,GPSLatitude、GPSLongitude、定位方向和拍摄时间一并带了出去。

更隐蔽的坑是方向。原图 IMG_2042.jpg 为 4032×3024,EXIF Orientation 是 Right-top。图库会自动旋转,所以预览正常;一旦重编码时只删元数据、不把像素旋正,导出的图片就横过来了。隐私脱敏和方向归一必须在同一条像素管线中完成。

Demo 项目叫 MetaScrub,页面是 PrivacyExportPage,任务号 META-2042。输入文件 12.6 MiB,检测到 4 个 GPS 相关标签与方向值 6;输出 IMG_2042_safe.jpg 为 3024×4032、3.8 MiB、JPEG 质量 92。最终 GPS 标签 0、Orientation 为 Up、原文件 untouched,耗时 318 ms,状态 VERIFIED。

一、退审不是因为“读了定位权限”

这张照片来自用户主动选择,应用没有申请定位权限。可 EXIF 里的位置是相机写入文件的历史数据,后续复制文件不需要再调用定位接口。只从权限清单排查,永远看不到问题。

我把导出流程拆成五个状态:SCANNING、NORMALIZING、PACKING、VERIFYING、VERIFIED。任何一步失败都不发布 fileUri。页面上还单独显示 source untouched,因为合规修复不能以改坏用户原图为代价。

项目目录中,page/PrivacyExportPage.ets 负责交互;service/ExifAudit.ets 读取白名单属性;service/OrientationNormalizer.ets 旋正 PixelMap;service/SafeImageExporter.ets 写临时文件并晋级;service/ExportVerifier.ets 从产物重新读取 EXIF。cache/export 只保留已验证文件。

二、先审计属性,再决定是否进入重编码

第一段代码解决“只凭扩展名判断安全”的问题。HarmonyOS Image Kit 的 ImageSource 可以读取 JPEG 的图像属性。我们显式查询四个 GPS 字段、Orientation 和 DateTimeOriginal;缺失属性按 absent 处理,解码错误与不支持格式则是另外的失败类型。

// ExifAudit.ets
import { image } from '@kit.ImageKit'

const GPS_KEYS: image.PropertyKey[] = [
  image.PropertyKey.GPS_LATITUDE,
  image.PropertyKey.GPS_LATITUDE_REF,
  image.PropertyKey.GPS_LONGITUDE,
  image.PropertyKey.GPS_LONGITUDE_REF
]

export async function auditExif(source: image.ImageSource): Promise<ExifReport> {
  const gps = new Map<string, string>()
  for (const key of GPS_KEYS) {
    const value = await readOptionalProperty(source, key)
    if (value !== undefined) gps.set(String(key), value)
  }
  const orientation = await readOptionalProperty(
    source,
    image.PropertyKey.ORIENTATION
  )
  return {
    gpsCount: gps.size,
    orientation: orientation ?? '1',
    needsRepack: gps.size > 0 || orientation !== '1'
  }
}

readOptionalProperty 只吞掉“属性不存在”,不会把文件损坏也当成安全。若 ImageSource 无法完成 EXIF 解码,任务进入 UNSUPPORTED,不生成所谓脱敏结果。这里的原则很简单:未知不等于无风险。

日志不输出经纬度原值,只记录命中的键数量。开发调试时也不应把敏感数据写进 HiLog,再用“仅测试环境”解释。META-2042 的日志固定为 source=IMG_2042.jpg gpsTags=4 orientation=6。

三、删 Orientation 之前必须旋正像素

最初的修复直接把 Orientation 改成 1,文件看起来却旋转了 90 度。原因是 Orientation 只描述查看器如何解释像素,修改标签不会自动搬动像素。安全产物要让像素本身就是正向,再以 Up 写出。

第二段代码根据审计结果创建 PixelMap 并旋转。实际项目把 EXIF 1—8 的镜像和旋转组合做成映射表;本次源图是值 6,对应顺时针 90 度。变换完成后再读取尺寸,必须得到 3024×4032。

// OrientationNormalizer.ets
export async function normalizeOrientation(
  source: image.ImageSource,
  orientation: string
): Promise<image.PixelMap> {
  const pixelMap = await source.createPixelMap({
    editable: true,
    desiredPixelFormat: image.PixelMapFormat.RGBA_8888
  })
  if (orientation === '6') {
    await pixelMap.rotate(90)
  } else if (orientation === '3') {
    await pixelMap.rotate(180)
  } else if (orientation === '8') {
    await pixelMap.rotate(270)
  } else {
    await applyMirrorMapping(pixelMap, orientation)
  }
  const info = await pixelMap.getImageInfo()
  if (info.size.width !== 3024 || info.size.height !== 4032) {
    pixelMap.release()
    throw new Error('META_SIZE_MISMATCH')
  }
  return pixelMap
}

PixelMap 可能占用约 46.5 MiB RGBA 内存,远大于 12.6 MiB 压缩文件。页面不能同时保留原始全尺寸预览和导出 PixelMap。导出开始前会释放旧预览,结束或异常时在 finally 中释放新 PixelMap;重复点击只复用同一个 runningPromise。

四、真正脱敏靠重新编码,不靠修改原文件

ImageSource 提供部分 EXIF 读写能力,但逐字段清空容易漏掉厂商扩展或未来新增字段。当前产品策略不是在原 JPEG 上修补,而是从已经归一的 PixelMap 重新编码一个新文件,并关闭属性打包。这样像素来自原图,元数据容器重新生成。

第三段代码解决“半成品被分享”的问题。ImagePacker 先写 cache/export/META-2042.jpg.tmp,质量固定 92,needsPackProperties 为 false。写完后不立刻返回 URI,而是重新打开临时文件做验证;只有验证通过才原子改名为 IMG_2042_safe.jpg。

// SafeImageExporter.ets
export async function repackWithoutMetadata(
  pixelMap: image.PixelMap,
  paths: ExportPaths
): Promise<void> {
  const packer = image.createImagePacker()
  const file = fileIo.openSync(paths.temp,
    fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC)
  try {
    await packer.packToFile(pixelMap, file.fd, {
      format: 'image/jpeg',
      quality: 92,
      needsPackProperties: false
    })
  } finally {
    fileIo.closeSync(file)
    packer.release()
  }
  const report = await verifyExport(paths.temp)
  if (report.gpsCount !== 0 || report.orientation !== '1') {
    fileIo.unlinkSync(paths.temp)
    throw new Error('META_VERIFY_FAILED')
  }
  fileIo.renameSync(paths.temp, paths.final)
}

临时文件和最终文件必须位于同一目录,原子改名才有意义。任务失败时清理 .tmp;应用被杀后,下次启动扫描孤儿临时文件并重新验证,不能看到文件存在就直接晋级。最终路径不复用原文件名,避免 UI 把安全副本和用户原图混为一谈。

DevEco Studio 图左侧展示 MetaScrub 的 Audit、Normalizer、Exporter 和 Verifier,中间是 packToFile 与二次校验,右侧模拟器显示 META-2042 的状态,底部 HiLog 为 gpsBefore=4 orientationBefore=6、output=3024x4032 quality=92,以及 state=VERIFIED gpsAfter=0 sourceUntouched=true elapsed=318ms。

五、二次读取才是验收,不是“代码看起来会删除”

审核修复最危险的自信是:参数写了 needsPackProperties=false,就认为产物必然安全。格式支持、API 版本、输入损坏和异常中断都会改变结果。ExportVerifier 使用新的文件描述符和新的 ImageSource 读取最终文件,不能复用源 ImageSource 或内存中的审计对象。

第四段代码把发布 URI 延后到 VERIFIED。页面离开时可以取消尚未开始的任务,但进入 PACKING 后由 exporter 自己收口。finally 负责释放 source、PixelMap 与订阅,cleanup 只删除本次缓存副本,不触碰用户选择的源文件。

// PrivacyExportModel.ets
async exportOnce(): Promise<void> {
  if (this.runningPromise) return this.runningPromise
  this.runningPromise = this.pipeline.run('META-2042')
    .then((result) => {
      this.state = 'VERIFIED'
      this.safeUri = result.fileUri
    })
    .catch((error) => {
      this.state = 'FAILED'
      this.safeUri = ''
      throw error
    })
    .finally(() => {
      this.runningPromise = undefined
      this.pipeline.releaseTransient()
    })
  return this.runningPromise
}

这里没有在 catch 中保留旧 safeUri。第二次导出失败时如果仍展示上一轮 URI,用户会误以为当前图片已经脱敏。每次选择新文件都会递增 generation,旧任务即使晚回来,也不能覆盖新页面状态。

六、手机页只展示与合规有关的证据

最终页不展示真实经纬度,也不展示原图缩略图。它列出源文件、输入大小、方向、GPS 标签数、输出尺寸、质量、输出大小、耗时和源文件保护状态。状态轨迹为 SCANNING → NORMALIZING → PACKING → VERIFYING → VERIFIED。

按钮“扫描原图”只读取属性,不写文件;“生成安全副本”在审计后才可用。红色批注只指向 gpsAfter=0 和 source untouched。对审核人员来说,这两项比一个笼统的“处理成功”更能说明问题。

回归用例包括无 EXIF JPEG、Orientation 3/6/8、带四个 GPS 字段、损坏 JPEG、打包中断和应用重启。损坏文件不得生成副本;方向矩阵要核对像素尺寸;重启后孤儿临时文件数量必须回到 0。测试完成后,MetaScrub 缓存目录只保留用户明确确认的安全产物。

七、脱敏不是把所有元数据都删光

当前策略针对分享与上架素材,默认不保留拍摄时间、设备型号和定位信息。若产品确实需要版权作者、色彩配置或无障碍说明,应该建立允许列表并在隐私说明中解释用途,而不是切换回“复制原文件”。不同输出格式的属性行为也不同,本例只验收 JPEG。

318 ms 是 4032×3024 样本在测试机上的观测值。更大图片要评估峰值内存,必要时进入受控降采样,而不是冒险同时持有多张全尺寸 PixelMap。质量 92 是产品权衡,不代表所有图片都适用;任何参数变化都要重新跑 GPS、方向和尺寸三组验证。

这次退审最后没有通过增加一句隐私文案解决,而是改掉了文件所有权:原图只读,安全副本独立生成,临时文件验证后晋级,分享能力只能拿到 VERIFIED URI。到这里,gpsAfter=0 才是一条可以复现的工程结论,而不是一段“我们不会收集位置”的口头承诺。

Logo

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

更多推荐