Files
RecipesCam/src/utils/photoMeta.ts
T
admin 17ee01b62a 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.
2026-09-11 18:47:44 +07:00

65 lines
2.8 KiB
TypeScript

// Gallery-facing photo metadata: the RC_ filename convention and the file-level
// 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 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.
export function rcPhotoName(exported: boolean, date = new Date()): string {
const p = (n: number) => String(n).padStart(2, '0');
const stamp = `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
return `RC_${stamp}${exported ? '_exported' : ''}.jpg`;
}
// Cache path for the next export. Two renders landing in the same second would
// collide on the name AND on RN's <Image> cache (keyed by URI), so the
// timestamp walks forward a second at a time instead of taking a suffix.
export function nextPhotoPath(exported: boolean): string {
let ms = Date.now();
for (;;) {
const path = `${FileSystem.cacheDirectory}${rcPhotoName(exported, new Date(ms))}`;
if (!new File(path).exists) return path;
ms += 1000;
}
}
export async function readPhotoBytes(uri: string): Promise<Uint8Array | null> {
try {
return await new File(uri).bytes();
} catch (e) {
console.warn('EXIF source read failed:', e);
return 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.
export async function stampExifOnFile(fileUri: string, stamp: ExifStamp, sourceUri?: string | null): Promise<void> {
try {
const target = new File(fileUri);
const source = sourceUri ? await readPhotoBytes(sourceUri) : null;
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, device: getDeviceIdentity(), ...stamp }, source));
} catch (e) {
console.warn('EXIF stamp failed:', e);
}
}