feat(exif): write the Xiaomi watermark blob (0x889e) so HyperOS Gallery can frame our captures

HyperOS Gallery reads EXIF 0x889e (a JSON blob) to build its watermark
frame; without it a photo fails with "cannot recognize the parameters".
CameraX/HAL never supplies that vendor tag, so a capture from this app
lacked it while a stock-camera photo carried it.

Read back the same props the stock camera derives the blob from
(ro.product.device / ro.product.marketname / Build.MANUFACTURER) via the
native module, synthesize the blob in exifWrite, and write it only when
the source has none and the device is Xiaomi/Redmi/POCO - a source blob
is never overwritten.
This commit is contained in:
2026-09-11 18:47:01 +07:00
parent 33123d47fd
commit 17ee01b62a
6 changed files with 163 additions and 3 deletions
+66
View File
@@ -682,3 +682,69 @@ tuyệt đối không dùng lại được, còn Make/Model là do HAL trả v
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
---
## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
**Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung
dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì
mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`).
**Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu
chìm — đây là JSON do app camera gốc ghi:
```
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
```
Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote
`0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không
có GPS IFD.
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này.
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`).
**Diff tag-set** (`exifchk/cmp_exif.py`):
| | RC_183114 | IMG mẫu |
| --- | --- | --- |
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
| byte order | II | MM |
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
| Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` |
| IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` |
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs
`Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob.
**Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
- `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả
`device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname`
→ `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection
(`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng.
- `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc
(`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong
`writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không
bao giờ ghi đè** blob nguồn.
- `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ.
- `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp.
**Kiểm chứng.**
| Kiểm chứng | Kết quả |
| --- | --- |
| blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 |
| `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" |
| `exifchk/verify.py` (PIL) | PIL VERIFY PASSED |
| `tsc --noEmit` | 14 lỗi, đúng baseline |
| `gradlew --offline assembleRelease` | BUILD SUCCESSFUL |
| cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION |
**Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã
hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop
chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.
@@ -58,6 +58,17 @@ private class FramedResult(val bmp: Bitmap, val window: RectF?)
// Font temp files are tiny and reused across exports — cache the Typefaces.
private val typefaceCache = HashMap<String, Typeface>()
// android.os.SystemProperties is hidden API: reflection keeps this compiling
// without stubs and degrades to null if a future SDK moves it.
private fun systemProp(key: String): String? = try {
val get = Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
(get.invoke(null, key, "") as String).ifEmpty { null }
} catch (e: Throwable) {
Log.w(TAG, "systemProp($key) unavailable: ${e.message}")
null
}
class RecipescamExportModule : Module() {
override fun definition() = ModuleDefinition {
Name("RecipesCamExport")
@@ -104,6 +115,22 @@ class RecipescamExportModule : Module() {
"pong"
}
// Raw device identity the stock Xiaomi camera folds into EXIF 0x889e —
// the watermark blob HyperOS Gallery reads before it will open its
// watermark editor. CameraX/HAL never writes that tag, so Gallery reports
// it cannot recognize the parameters on our captures. JS formats the JSON
// (see exifWrite.watermarkBlob); native only reads the props.
Function("deviceInfo") { ->
mapOf(
"device" to (systemProp("ro.product.device") ?: ""),
"marketName" to (systemProp("ro.product.marketname")
?: systemProp("ro.product.odm.marketname")
?: systemProp("ro.product.vendor.marketname") ?: ""),
"manufacturer" to (android.os.Build.MANUFACTURER ?: ""),
"model" to (android.os.Build.MODEL ?: ""),
)
}
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
// real file path back to JS, which then runs decodeEncodeAsync on it. This
+7
View File
@@ -91,6 +91,13 @@ export interface RecipescamExportModule {
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
/**
* Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the
* watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies:
* codename, marketing name, manufacturer, model. Empty strings when absent.
*/
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
+42
View File
@@ -25,6 +25,17 @@ export interface ExifStamp {
width?: number | null;
height?: number | null;
dpi?: number | null;
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
// used when the source photo carries no blob of its own.
device?: DeviceIdentity | null;
}
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
export interface DeviceIdentity {
device: string; // ro.product.device codename, e.g. "aurora"
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
model: string; // Build.MODEL — fallback when marketName is empty
}
interface Entry {
@@ -158,6 +169,31 @@ function tzOffset(d: Date): string {
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
}
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
// will open its watermark editor: without it, a photo edited here fails with
// "cannot recognize the parameters". Only the stock camera app writes this tag
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
// same props the stock app derives it from. Shape matches the stock bytes:
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
function watermarkBlob(d: DeviceIdentity): string {
const brand = d.manufacturer.trim();
const market = d.marketName.trim();
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
? market.slice(brand.length + 1)
: market || d.model;
return JSON.stringify({
buildDevice: d.device,
customize: '',
cvLensName: '',
filterName: '',
livephotoInfo: '',
version: 2,
waterLogo: brand.toUpperCase(),
waterName,
});
}
// --- parse -----------------------------------------------------------------
interface ParsedTiff {
@@ -356,6 +392,12 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
}
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
// CameraX capture never carries it. Never overwrite a blob the source has.
if (stamp.device && !exif.has(0x889e) && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer)) {
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
}
if (stamp.gps) {
const { latitude: lat, longitude: lon } = stamp.gps;
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
+2 -1
View File
@@ -10,7 +10,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { writeJpegExif } from './exifWrite';
import { nextPhotoPath, readPhotoBytes } from './photoMeta';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
import { applyPhotoRotation } from './skiaImage';
export interface ExportResult {
@@ -721,6 +721,7 @@ export async function processAndExportPhoto(
width: resultImage.width(),
height: resultImage.height(),
dpi: options?.dpi ?? null,
device: getDeviceIdentity(),
},
await readPhotoBytes(sourceUri)
);
+19 -2
View File
@@ -2,7 +2,8 @@
// EXIF helpers both export engines share (the codec itself lives in exifWrite.ts).
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { writeJpegExif, SOFTWARE, type ExifStamp } from './exifWrite';
import RecipescamExport from '../../modules/recipescam-export';
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
// photo. The exported variant marks a photo re-exported from the library.
@@ -33,6 +34,22 @@ export async function readPhotoBytes(uri: string): Promise<Uint8Array | null> {
}
}
// Device identity for the Gallery watermark blob (exifWrite.watermarkBlob).
// Read once — the props cannot change while the app runs. null when the native
// module is older than this call, so stamping still works without it.
let deviceIdentity: DeviceIdentity | null | undefined;
export function getDeviceIdentity(): DeviceIdentity | null {
if (deviceIdentity === undefined) {
try {
deviceIdentity = RecipescamExport.deviceInfo();
} catch (e) {
console.warn('deviceInfo unavailable:', e);
deviceIdentity = null;
}
}
return deviceIdentity;
}
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
// itself, so those bytes never pass through JS). Never throws — a metadata
// failure must not cost the user the photo.
@@ -40,7 +57,7 @@ export async function stampExifOnFile(fileUri: string, stamp: ExifStamp, sourceU
try {
const target = new File(fileUri);
const source = sourceUri ? await readPhotoBytes(sourceUri) : null;
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, ...stamp }, source));
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, device: getDeviceIdentity(), ...stamp }, source));
} catch (e) {
console.warn('EXIF stamp failed:', e);
}