diff --git a/App.tsx b/App.tsx index c17f0cb..87fb30c 100644 --- a/App.tsx +++ b/App.tsx @@ -24,6 +24,7 @@ import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; import { processAndExportPhotoNative } from './src/utils/nativeExport'; import { PARAM_DEFS } from './src/utils/paramDefs'; +import { LITE_MARK, activatePro, loadPro, proLookInUse } from './src/utils/entitlement'; // @ts-ignore import './global.css'; @@ -80,6 +81,12 @@ 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); + }, []); // FAVORITED: ids of the starred recipes. Metadata only — never saved into the // session look, so RESET and applying a recipe leave it untouched. const [favoriteIds, setFavoriteIds] = useState([]); @@ -758,6 +765,10 @@ export default function App() { }; const handleSaveRecipe = async (name: string) => { + if (!pro) { + promptProFeature('Saving a recipe'); + return; + } if (!selectedRecipe) return; const newRecipe = await saveCustomRecipe({ name, @@ -832,6 +843,14 @@ export default function App() { // PRESETS → CREATE quick-input: build a brand-new recipe from the modal's // typed fields, persist it, and apply it (the user sees the look immediately). const handleCreateRecipe = async (recipe: Omit) => { + // Lite composes the recipe and applies it as a throwaway draft so the look + // can be tried; only STORING it is PRO. 'draft' is no free id, so the + // shutter gate already treats the look as the PRO preset it is. + if (!pro) { + applyRecipe({ ...recipe, id: 'draft', isCustom: false }); + promptProFeature('Saving a custom recipe'); + return; + } const newRecipe = await saveCustomRecipe(recipe); const list = await getAllRecipes(); setRecipes(list); @@ -852,6 +871,10 @@ export default function App() { // the background. The write is the only failure worth logging — the look is // untouched either way. const handleToggleFavorite = (id: string) => { + if (!pro) { + promptProFeature('Favoriting recipes'); + return; + } const on = !favoriteIds.includes(id); setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id))); setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e)); @@ -941,6 +964,54 @@ export default function App() { // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; + // The PRO feature the current look is using, or null while it is Lite-clean. + // Only the EXPORT consults it: the preview shows every look for free, so the + // answer is computed where the file is about to be written, not on the chips. + const proLook = proLookInUse({ + recipeId: selectedRecipe?.id, + frame: selectedFrame, + customWm: customWmApplied.enabled, + }); + // 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. + 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) }, + ] + ); + }; + // 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. + 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; + }; + // Watermark handed to the export engines (Skia + native): the ACCEPTED mark // (not the live draft), null while disabled or empty, else trimmed text + // position. Trimming here keeps the stamped text identical to what the @@ -953,7 +1024,11 @@ export default function App() { size?: number; font?: string | null; } | null => - customWmApplied.enabled && customWmApplied.text.trim().length > 0 + // Lite: the user's own mark can never reach the file (that look is gated at + // the shutter); every Lite export carries the RECIPESCAM mark instead. + !pro + ? LITE_MARK + : customWmApplied.enabled && customWmApplied.text.trim().length > 0 ? { text: customWmApplied.text.trim(), x: customWmApplied.x, @@ -967,6 +1042,13 @@ export default function App() { const handleCapture = async () => { if (isProcessing) return; + // A PRO look can be composed but not rendered: stop before the shutter, so + // no capture, no queue entry and no cache file is produced. + if (proLook) { + promptPro(proLook); + return; + } + // Camera mode (Phase 4, option A): real capture through vision-camera. if (mode === 'camera') { if (!cameraPermissionGranted) { @@ -1051,7 +1133,7 @@ export default function App() { // opening: save the same crop the preview showed (null = 1x). frameWindowZoom: frameZoom, }; - return NATIVE_EXPORT + return nativeExport ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) : processAndExportPhoto(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); }; @@ -1118,7 +1200,7 @@ export default function App() { ? { cropRect: ratioCrop } : { cropWH: CROP_W_H[cropRatio] }; - const result = NATIVE_EXPORT && !cropOptions.cropRect + const result = nativeExport && !cropOptions.cropRect ? await processAndExportPhotoNative( libraryImageUri, { @@ -1300,6 +1382,10 @@ export default function App() { // parameter row — otherwise a stale slider row (and its image-drag target) // would survive into the next panel. const handleToggleTab = (tab: TabId) => { + if (tab === 'favorites' && !pro) { + promptProFeature('The FAVORITED tab'); + return; + } setOpenTab((prev) => (prev === tab ? null : tab)); setOpenParamKey(null); }; @@ -1498,6 +1584,8 @@ export default function App() { onGpsEnabled={handleToggleGps} 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 6aa791a..f0d3e6c 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { View, Text, Modal, TouchableOpacity } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Text, Keyboard, Modal, ScrollView, TouchableOpacity, TextInput } 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'; @@ -35,6 +35,11 @@ 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. + pro: boolean; + onUnlock: (key: string) => Promise; } function ChipRow({ chips }: { chips: Chip[] }) { @@ -94,20 +99,40 @@ export default function SettingsModal({ onGpsEnabled, 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. + const [kb, setKb] = useState(0); + useEffect(() => { + const show = Keyboard.addListener('keyboardDidShow', (e) => setKb(e.endCoordinates.height)); + const hide = Keyboard.addListener('keyboardDidHide', () => setKb(0)); + return () => { + show.remove(); + hide.remove(); + }; + }, []); return ( - + - @@ -213,6 +238,54 @@ 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. */} + + 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.'} + + + {/* About & open-source credits */} - + setShowCredits(false)} /> diff --git a/src/utils/entitlement.ts b/src/utils/entitlement.ts new file mode 100644 index 0000000..8630bd9 --- /dev/null +++ b/src/utils/entitlement.ts @@ -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 => + (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 new file mode 100644 index 0000000..43481d3 --- /dev/null +++ b/tools/keygen.mjs @@ -0,0 +1,56 @@ +// Mint PRO unlock keys for RecipesCam. Dev-side only — never bundled. +// node tools/keygen.mjs 5 mint 5 keys +// 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 mint = () => { + let payload = ''; + for (let i = 0; i < 12; i++) payload += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)]; + const bits = checksum(payload); + let check = ''; + for (let i = 0; i < 4; i++) check += KEY_ALPHABET[(bits >>> (i * 5)) & 31]; + const s = payload + check; + return s.replace(/(.{4})/g, '$1-').slice(0, -1); +}; + +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 { + const n = Number(args[0]) || 1; + for (let i = 0; i < n; i++) console.log(mint()); +}