HarmonyOS 7 ImageSource+ImagePacker:GPS EXIF脱敏
审核退回信息只有一句:“用户导出的图片仍包含可识别的位置元数据。”我第一反应是检查分享参数,结果 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 才是一条可以复现的工程结论,而不是一段“我们不会收集位置”的口头承诺。
更多推荐

所有评论(0)