Keep LITE exports clean of PRO looks, and gate CREATE, FAVORITED and + SAVE behind PRO

LITE previews every look and feature but export/render is blocked while a PRO
look is in use, and every LITE export carries the RECIPESCAM mark. Keys are
generated offline (FNV-1a checksum over a fixed secret, 16 chars from a
no-ambiguity alphabet) and activate one machine or a thousand: nothing is bound
to a device, so revoking a leaked key needs Play Billing plus server validation.

CREATE, FAVORITED, the star chip and the TopBar + SAVE now answer with a PRO
prompt instead of a silent no-op; CREATE still drafts and applies the look so
LITE can try it, it just cannot persist.

The SETTINGS card also lifts above the keyboard now: RN Modal is its own dialog
window, so the IME never resizes or pans it and the UNLOCK row was sitting under
the keys.
This commit is contained in:
2026-09-12 11:21:14 +07:00
parent a662674478
commit 59e2719245
4 changed files with 313 additions and 10 deletions
+86
View File
@@ -0,0 +1,86 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { FrameId } from '../types';
// Lite (free, forever) vs PRO (one unlock key). The gate is on the FILE, not on
// the preview: a Lite user may pick any preset, frame or watermark, compose
// with it and compare before/after — only the render/export of that look is
// held back, and every Lite export carries the mark below.
const PRO_KEY = '@camrecipe_pro:pro';
// Frames on the Lite side: the plain export only. The three printed looks are
// PRO (the artwork/card geometry is the paid half of the FRAME tab).
export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
// Presets: the eight film SIMS are free (a base-filter matrix is the camera's
// own look); a saved RECIPE — bundled or user-created — is a dialled-in look
// and belongs to PRO. Ids are `sim-*` for sims, anything else for recipes.
export const isFreeRecipe = (id: string | null | undefined): boolean =>
!id || id.startsWith('sim-');
// Burned into every Lite export, bottom-right of the photo/frame area. Fractions
// of the stamp canvas, same space the custom mark uses.
export const LITE_MARK = {
text: 'RECIPESCAM',
x: 0.55,
y: 0.94,
color: '#ffffff',
size: 0.8,
};
export interface Look {
recipeId?: string | null;
frame: FrameId;
customWm: boolean;
}
// The first PRO feature a look is using, or null when it is Lite-clean. One
// string, so the caller can name it back to the user in the alert.
export function proLookInUse(look: Look): string | null {
if (!isFreeRecipe(look.recipeId)) return 'A PRO preset';
if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame';
if (look.customWm) return 'The custom watermark';
return null;
}
export const loadPro = async (): Promise<boolean> =>
(await AsyncStorage.getItem(PRO_KEY)) === '1';
// ---- unlock key -------------------------------------------------------------
// Offline product key: 12 payload chars + 4 checksum chars, any punctuation
// ignored (`XXXX-XXXX-XXXX-XXXX`). No server, no clock — whoever buys gets a key
// from tools/keygen.mjs. A reverse engineer can mint their own: that is the
// ceiling of a client-only check. Move to Play Billing + server validation when
// the app ships on Play (ponytail).
// Keep KEY_ALPHABET / KEY_SECRET / checksum byte-identical to tools/keygen.mjs.
const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const KEY_SECRET = 'recipes-cam-key-2026';
const checksum = (payload: string): number => {
let h = 0x811c9dc5; // FNV-1a 32
const src = KEY_SECRET + payload;
for (let i = 0; i < src.length; i++) {
h ^= src.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h & 0xfffff; // low 20 bits -> 4 chars of 5 bits
};
export function keyIsValid(raw: string): boolean {
const s = (raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (s.length !== 16) return false;
for (let i = 0; i < 16; i++) if (KEY_ALPHABET.indexOf(s[i]) < 0) return false;
const bits = checksum(s.slice(0, 12));
for (let i = 0; i < 4; i++) {
if (KEY_ALPHABET[(bits >>> (i * 5)) & 31] !== s[12 + i]) return false;
}
return true;
}
// Returns false for a wrong key; on success PRO is persisted and every later
// export stops being marked.
export async function activatePro(raw: string): Promise<boolean> {
if (!keyIsValid(raw)) return false;
await AsyncStorage.setItem(PRO_KEY, '1');
return true;
}