diff --git a/App.tsx b/App.tsx index 11c5530..829b2f2 100644 --- a/App.tsx +++ b/App.tsx @@ -26,7 +26,7 @@ import { processAndExportPhoto } from './src/utils/exportEngine'; import { processAndExportPhotoNative } from './src/utils/nativeExport'; import { exportRecipeXml, importRecipeXml } from './src/utils/recipeShare'; import { PARAM_DEFS } from './src/utils/paramDefs'; -import { LITE_MARK, LITE_RECIPE_LIMIT, activatePro, loadPro, proLookInUse } from './src/utils/entitlement'; +import { IS_PRO, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement'; import UltraWide from './modules/recipescam-ultrawide'; // @ts-ignore @@ -97,12 +97,8 @@ export default function App() { }; }, []); const [recipes, setRecipes] = useState([]); - // Lite (free) vs PRO (unlocked key). Read once at startup; the unlock key - // flips it in place, so every later export drops the mark and the look gate. - const [pro, setPro] = useState(false); - useEffect(() => { - loadPro().then(setPro); - }, []); + // LITE (free) vs PRO (the paid build): fixed for the life of the install. + const pro = IS_PRO; // Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save // paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows. const customRecipeCount = recipes.filter((r) => r.isCustom).length; @@ -1035,41 +1031,20 @@ export default function App() { // The native Kotlin exporter has no text-watermark draw, so a Lite export // (which always carries the mark) must take the Skia engine. const nativeExport = NATIVE_EXPORT && pro; - // Shutter/export with a PRO look: nothing is rendered — name the feature and - // point at the unlock. + // Shutter/export with a PRO look: nothing is rendered — name the feature so + // the user knows which PRO build would render it. const promptPro = (feature: string) => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); Alert.alert( 'PRO LOOK', - `${feature} can be previewed and edited for free, but exporting it needs PRO. Your own key unlocks it in SETTINGS.`, - [ - { text: 'LATER', style: 'cancel' }, - { text: 'SETTINGS', onPress: () => setSettingsVisible(true) }, - ] + `${feature} can be previewed and edited for free, but exporting it needs the PRO build.` ); }; - // Saving, starring, spawning: the action IS the feature, so there is no - // look to preview first — a Lite tap names it and points at the unlock, - // and nothing else happens. + // Saving, starring, spawning: the action IS the feature, so there is no look + // to preview first — a LITE tap names it and nothing else happens. const promptProFeature = (feature: string) => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - Alert.alert( - 'PRO FEATURE', - `${feature} needs PRO. Your own key unlocks it in SETTINGS.`, - [ - { text: 'LATER', style: 'cancel' }, - { text: 'SETTINGS', onPress: () => setSettingsVisible(true) }, - ] - ); - }; - // Settings unlock: a valid key persists PRO and returns true. - const handleUnlock = async (key: string): Promise => { - const ok = await activatePro(key); - if (ok) { - setPro(true); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - return ok; + Alert.alert('PRO FEATURE', `${feature} needs the PRO build.`); }; // Watermark handed to the export engines (Skia + native): the ACCEPTED mark @@ -1712,7 +1687,6 @@ export default function App() { startupMode={startupMode} onStartupMode={handleStartupMode} pro={pro} - onUnlock={handleUnlock} /> {/* Session photo viewer (swipe across captured/exported photos) */} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b693aaf..3236126 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, Keyboard, Modal, ScrollView, TouchableOpacity, TextInput } from 'react-native'; +import { View, Text, Keyboard, Modal, ScrollView, TouchableOpacity } from 'react-native'; import * as Haptics from 'expo-haptics'; import { X, Volume2, Info, ChevronRight, MapPin, Settings as SettingsIcon } from 'lucide-react-native'; import { AspectRatio, MeterMode, ShutterSound, StartupMode } from '../types'; @@ -37,11 +37,8 @@ interface SettingsModalProps { onGpsEnabled: (enabled: boolean) => void; startupMode: StartupMode; onStartupMode: (mode: StartupMode) => void; - // Lite vs PRO. The unlock key is verified in App (the entitlement module owns - // the maths) and comes back false for a bad key, which is all this sheet needs - // to know — let alone store. + // LITE vs PRO, decided at build time (no key, nothing to enter here). pro: boolean; - onUnlock: (key: string) => Promise; } function ChipRow({ chips }: { chips: Chip[] }) { @@ -104,11 +101,8 @@ export default function SettingsModal({ startupMode, onStartupMode, pro, - onUnlock, }: SettingsModalProps) { const [showCredits, setShowCredits] = useState(false); - const [keyDraft, setKeyDraft] = useState(''); - const [keyError, setKeyError] = useState(false); // The RN Modal is its own dialog window, so the IME does not resize it and // the card ends up under the keyboard. Track the keyboard height and lift the // card by that much instead. @@ -262,8 +256,8 @@ export default function SettingsModal({ - {/* Lite vs PRO. Every look is free to preview and edit; the key buys - the export: no mark, and PRO presets/frames/watermarks allowed. */} + {/* LITE vs PRO. Every look is free to preview and edit; the PRO build + buys the export: no mark, and PRO presets/frames/watermarks. */} PRO {} }, ]} /> - {!pro && ( - - { - setKeyDraft(t); - setKeyError(false); - }} - placeholder="XXXX-XXXX-XXXX-XXXX" - placeholderTextColor="#52525b" - autoCapitalize="characters" - autoCorrect={false} - className={`flex-1 rounded-md border bg-black/40 px-3 py-2 font-mono text-xs font-bold ${ - keyError ? 'border-red-500/70 text-red-400' : 'border-zinc-700 text-zinc-200' - }`} - /> - { - haptic(); - const ok = await onUnlock(keyDraft); - setKeyError(!ok); - if (ok) setKeyDraft(''); - }} - activeOpacity={0.7} - className="ml-2 rounded-md border border-amber-500/70 bg-amber-500/15 px-3 py-2" - > - UNLOCK - - - )} - {keyError - ? 'That key is not valid. Check all 16 characters.' - : pro - ? 'Every look exports clean: no mark, no limits. Thanks!' - : 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark. Enter your key to lift both.'} + {pro + ? 'Every look exports clean: no mark, no limits.' + : 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark.'} diff --git a/src/provariant.ts b/src/provariant.ts index 9966ce4..067f781 100644 --- a/src/provariant.ts +++ b/src/provariant.ts @@ -1,3 +1,3 @@ // Generated by tools/set-variant.mjs - do not edit by hand. -// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is). +// false = Lite apk (free, PRO looks are preview-only), true = Pro apk (sold as-is). export const IS_PRO_BUILD = false; diff --git a/src/utils/entitlement.ts b/src/utils/entitlement.ts index 5423d1d..95144a3 100644 --- a/src/utils/entitlement.ts +++ b/src/utils/entitlement.ts @@ -1,15 +1,21 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { FrameId } from '../types'; import { IS_PRO_BUILD } from '../provariant'; -// 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. +// LITE (free) vs PRO (the paid build). The two are separate apks built from this +// one tree: the tier is fixed at build time by src/provariant.ts (rewritten by +// tools/set-variant.mjs, `npm run apk:lite|apk:pro`) and nothing in a LITE apk +// can change it afterwards — no key, no server, no stored flag. -const PRO_KEY = '@camrecipe_pro:pro'; +// Constant for the life of the build, so every `!IS_PRO` branch below is dead +// code the bundler is free to drop. +export const IS_PRO = IS_PRO_BUILD; -// Frames on the Lite side: the plain export only. The three printed looks are +// 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. + +// 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']; @@ -19,11 +25,11 @@ export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe']; export const isFreeRecipe = (id: string | null | undefined): boolean => !id || id.startsWith('sim-'); -// Lite may store its own recipes, up to this many: creating and editing them is -// free, deleting one is PRO — so a Lite list only ever grows to this size. +// LITE may store its own recipes, up to this many: creating and editing them is +// free, deleting one is PRO — so a LITE list only ever grows to this size. export const LITE_RECIPE_LIMIT = 3; -// Burned into every Lite export, bottom-right of the photo/frame area. Fractions +// 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', @@ -39,7 +45,7 @@ export interface Look { customWm: boolean; } -// The first PRO feature a look is using, or null when it is Lite-clean. One +// 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'; @@ -47,48 +53,3 @@ export function proLookInUse(look: Look): string | null { if (look.customWm) return 'The custom watermark'; return null; } - -// The Pro apk is the product: it is unlocked by being the paid build, and its -// SETTINGS sheet never shows a key field. The Lite apk unlocks the same features -// with a keygen key, which is what a buyer of the cheap tier gets instead. -export const loadPro = async (): Promise => - IS_PRO_BUILD || (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 { - if (!keyIsValid(raw)) return false; - await AsyncStorage.setItem(PRO_KEY, '1'); - return true; -} diff --git a/tools/keygen.mjs b/tools/keygen.mjs deleted file mode 100644 index 4089fc0..0000000 --- a/tools/keygen.mjs +++ /dev/null @@ -1,72 +0,0 @@ -// Mint PRO unlock keys for RecipesCam. Dev-side only — never bundled. -// node tools/keygen.mjs 5 mint 5 keys -// node tools/keygen.mjs --from MYCHOSEN12 key for a payload you pick -// node tools/keygen.mjs --check mint 3 keys and verify them (parity guard) -// The key is 12 payload chars + 4 checksum chars, so any 12-char payload works. -// Keep KEY_ALPHABET / KEY_SECRET / checksum identical to -// src/utils/entitlement.ts — the app verifies with the same maths. -import { randomInt } from 'node:crypto'; - -const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const KEY_SECRET = 'recipes-cam-key-2026'; - -const checksum = (payload) => { - 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; -}; - -const keyIsValid = (raw) => { - 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; -}; - -const format = (s) => s.replace(/(.{4})/g, '$1-').slice(0, -1); - -const checkOf = (payload) => { - const bits = checksum(payload); - let check = ''; - for (let i = 0; i < 4; i++) check += KEY_ALPHABET[(bits >>> (i * 5)) & 31]; - return check; -}; - -const mint = () => { - let payload = ''; - for (let i = 0; i < 12; i++) payload += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)]; - return format(payload + checkOf(payload)); -}; - -// Mint the key for a payload you picked yourself — must be 12 chars, alphabet only. -const from = (payload) => { - const p = (payload || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); - if (p.length !== 12) throw new Error(`payload must be 12 chars, got ${p.length}: ${p}`); - for (const ch of p) if (KEY_ALPHABET.indexOf(ch) < 0) throw new Error(`'${ch}' is not in ${KEY_ALPHABET}`); - return format(p + checkOf(p)); -}; - -const args = process.argv.slice(2); -if (args[0] === '--check') { - const keys = [mint(), mint(), mint()]; - for (const k of keys) { - if (!keyIsValid(k)) throw new Error(`keygen/verify mismatch on ${k}`); - if (keyIsValid(k.slice(0, -1) + (k.endsWith('A') ? 'B' : 'A'))) { - throw new Error(`tampered key accepted: ${k}`); - } - } - console.log('ok', keys.join(' ')); -} else if (args[0] === '--from') { - console.log(from(args[1])); -} else { - const n = Number(args[0]) || 1; - for (let i = 0; i < n; i++) console.log(mint()); -} diff --git a/tools/set-variant.mjs b/tools/set-variant.mjs index fab6043..22a7dc2 100644 --- a/tools/set-variant.mjs +++ b/tools/set-variant.mjs @@ -13,6 +13,6 @@ if (tier !== 'lite' && tier !== 'pro') { writeFileSync( new URL('../src/provariant.ts', import.meta.url), - `// Generated by tools/set-variant.mjs - do not edit by hand.\n// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is).\nexport const IS_PRO_BUILD = ${tier === 'pro'};\n` + `// Generated by tools/set-variant.mjs - do not edit by hand.\n// false = Lite apk (free, PRO looks are preview-only), true = Pro apk (sold as-is).\nexport const IS_PRO_BUILD = ${tier === 'pro'};\n` ); console.log('src/provariant.ts ->', tier);