97949e449d
Both flavors build from one JS tree: `lite` hands out the free apk (com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label "RecipesCam Pro"). Release is signed from android/keystore.properties, both files gitignored - lose either and no later build can install over this one. The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor alone would ship a Pro apk that thinks it is Lite. The dev scaffolding goes: the three src/dev probes and their App effects, the EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix side-by-side install hook. 'balanced' stays the shipped capture mode, which is what those probes measured on the 12S Ultra. Looking at the picture: WB now converts kelvin through the Planckian locus into a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a recipe loads every knob it carries over the defaults and stays editable, and RESET hands the panel back to the sim's own values. The in-app library picker, the ultra-wide native module and recipe share/import land here too. CLARITY and LIGHT GLOW existed only on the camera worklet and the export engine, so a photo opened from the library ignored both. They now run in all three library preview branches (libPhoto), and the preview clips them to the drawn image - the export engine always clipped its bloom to the canvas, the preview drew it over the whole rect, so the bloom bled past the photo onto the black letterbox and over the frame's mat.
100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
|
|
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
|
|
|
|
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
|
|
// recipe JSON, so a shared recipe imports back into the app on another phone.
|
|
//
|
|
// ponytail: obfuscation, not cryptography — the key ships inside the app and
|
|
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
|
|
// which is all a shared look needs; swap in expo-crypto/AES at the same two
|
|
// functions if the format ever has to resist a determined attacker.
|
|
const KEY = 'RecipesCam::recipe-share::v1';
|
|
const ALGORITHM = 'xor16-v1';
|
|
|
|
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
|
|
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'];
|
|
|
|
const hash = (s: string): number => {
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
return h >>> 0;
|
|
};
|
|
|
|
// xorshift32: one 16-bit word of keystream per call.
|
|
const stream = (seed: number) => {
|
|
let s = seed >>> 0 || 0x9e3779b9;
|
|
return () => {
|
|
s ^= s << 13;
|
|
s >>>= 0;
|
|
s ^= s >>> 17;
|
|
s ^= s << 5;
|
|
s >>>= 0;
|
|
return s & 0xffff;
|
|
};
|
|
};
|
|
|
|
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
|
// with Vietnamese diacritics included) survives the round trip.
|
|
const scramble = (text: string, salt: number): string => {
|
|
const k = stream(hash(`${KEY}|${salt}`));
|
|
let out = '';
|
|
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
|
return out;
|
|
};
|
|
|
|
const unscramble = (hex: string, salt: number): string => {
|
|
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
|
|
const k = stream(hash(`${KEY}|${salt}`));
|
|
let out = '';
|
|
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
|
|
return out;
|
|
};
|
|
|
|
// The shareable half of a recipe: never the local id, and only known fields, so
|
|
// a file from a newer version cannot smuggle anything into storage.
|
|
export function exportRecipeXml(recipe: Recipe): string {
|
|
const shareable = {
|
|
name: recipe.name,
|
|
baseFilter: recipe.baseFilter,
|
|
adjustments: recipe.adjustments,
|
|
frameId: recipe.frameId,
|
|
useGeotag: recipe.useGeotag,
|
|
};
|
|
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
|
return [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
|
` <payload>${scramble(JSON.stringify(shareable), salt)}</payload>`,
|
|
'</recipescam-recipe>',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// Throws with a user-readable reason. Fields are validated and clamped to what
|
|
// this build understands: a recipe from a newer app imports as best it can
|
|
// instead of poisoning the store.
|
|
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
|
|
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
|
|
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
|
|
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
|
|
|
|
let raw: any;
|
|
try {
|
|
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
|
|
} catch {
|
|
raw = null;
|
|
}
|
|
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
|
|
throw new Error('Recipe file is damaged or from another app.');
|
|
}
|
|
|
|
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
|
|
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
|
|
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
|
|
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
|
|
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
|
|
}
|