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', 'wallframe-landscape']; 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 [ '', ``, ` ${scramble(JSON.stringify(shareable), salt)}`, '', '', ].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 { const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1]; const 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) }; const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none'; return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag }; }