Drop the in-app unlock key: only the PRO apk opens PRO

The set is two apks and the unlock key was a second, weaker lock on the
same door. A verifier that runs offline has to carry its secret inside
the lite apk, so anyone who unpacked it could mint a key. Nothing in the
lite build can flip the gate now.

- entitlement.ts keeps the gate (which look is PRO, what an export
  carries in lite) and exports IS_PRO straight from the build flag.
- SETTINGS loses the key field; the plan chip still says LITE / PRO
  ACTIVE and the helper text says what lite cannot export.
- The upsell alerts just name the build that has the feature.
- tools/keygen.mjs goes with the verifier it fed.
This commit is contained in:
2026-09-14 20:09:12 +07:00
parent 97949e449d
commit 1b60ca2f22
6 changed files with 35 additions and 210 deletions
+7 -45
View File
@@ -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<boolean>;
}
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({
<CameraOnlyNote cameraMode={cameraMode} />
</View>
{/* 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. */}
<View>
<SectionLabel>PRO</SectionLabel>
<ChipRow
@@ -271,42 +265,10 @@ export default function SettingsModal({
{ key: 'plan', label: pro ? 'PRO ACTIVE' : 'LITE', active: pro, disabled: true, onPress: () => {} },
]}
/>
{!pro && (
<View className="flex-row items-center mt-2">
<TextInput
value={keyDraft}
onChangeText={(t) => {
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'
}`}
/>
<TouchableOpacity
onPress={async () => {
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"
>
<Text className="font-mono text-[11px] font-bold text-amber-500">UNLOCK</Text>
</TouchableOpacity>
</View>
)}
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
{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.'}
</Text>
</View>
+1 -1
View File
@@ -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;
+17 -56
View File
@@ -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<boolean> =>
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<boolean> {
if (!keyIsValid(raw)) return false;
await AsyncStorage.setItem(PRO_KEY, '1');
return true;
}