diff --git a/PLAN-2026-09-09.md b/PLAN-2026-09-09.md index d67aa03..d3bc5de 100644 --- a/PLAN-2026-09-09.md +++ b/PLAN-2026-09-09.md @@ -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. diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index 4f99332..40c897e 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -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() +// 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 diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index 27ef260..b1b53a9 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -91,6 +91,13 @@ export interface RecipescamExportModule { processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise; /** P1 alias: matrix + crop, no tone/cinema/enhance passes. */ processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise; + /** + * 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; materializeBenchAsset(assetName: string): Promise; diff --git a/src/utils/exifWrite.ts b/src/utils/exifWrite.ts index 544e38a..10cfb82 100644 --- a/src/utils/exifWrite.ts +++ b/src/utils/exifWrite.ts @@ -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')); diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 93cc73d..1e2091c 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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) ); diff --git a/src/utils/photoMeta.ts b/src/utils/photoMeta.ts index 55196b0..fa85e24 100644 --- a/src/utils/photoMeta.ts +++ b/src/utils/photoMeta.ts @@ -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_[_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 { } } +// 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); }