Sell PRO as an in-app unlock, take shared photos, and let FX run negative

- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO
  unlock, replacing the paid apk for the store channel
- Android SEND/SEND_MULTIPLE share target feeds photos into the editor,
  on cold start and on resume
- BACK button in the TopBar and press-and-hold PEEK replace the chip row
- NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens
  the frame before the grain pass, a negative NOISE REDUCTION re-grains it,
  in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
2026-09-16 06:48:22 +07:00
parent 27a9dfb65f
commit a85ebf0a81
21 changed files with 860 additions and 183 deletions
+297 -107
View File
@@ -16,6 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail';
import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import LibraryPickerModal from './src/components/LibraryPickerModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
@@ -26,8 +27,10 @@ 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 { IS_PRO, LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
import UltraWide from './modules/recipescam-ultrawide';
import RecipescamExport from './modules/recipescam-export';
// @ts-ignore
import './global.css';
@@ -97,8 +100,17 @@ export default function App() {
};
}, []);
const [recipes, setRecipes] = useState<Recipe[]>([]);
// LITE (free) vs PRO (the paid build): fixed for the life of the install.
const pro = IS_PRO;
// LITE (free) vs PRO: the PRO apk is born owned, the free one buys the tier
// through Google Play. Runtime state, read here and passed down.
const pro = useProUnlock().owned;
// The paywall sheet: opened by the SETTINGS plan row and by every alert that
// names a PRO feature, so the refusal always comes with a way to pay.
const [showPro, setShowPro] = useState(false);
// Ask the store what this account owns, once, at startup. Until it answers,
// the cached answer stands (see src/utils/proUnlock.ts).
useEffect(() => {
initProUnlock();
}, []);
// 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;
@@ -158,8 +170,12 @@ export default function App() {
// Draft edit: toggling the chip OFF also drops any accepted stamp, so a
// switched-off watermark can never come back in an export.
const handleUpdateCustomWm = (wm: CustomWatermark) => {
setCustomWm(wm);
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
// Typing coalesces into one step per pause; a chip (colour, size, ON/OFF)
// is a step of its own even when two of them land back to back.
commit(wm.text !== customWm.text, () => {
setCustomWm(wm);
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
});
};
// Leaving the watermark editor = closing its panel. The mark keeps capturing
// image touches ONLY while that panel is open (Viewfinder's wmArmed), so this
@@ -171,14 +187,14 @@ export default function App() {
// ACCEPT: the draft becomes the mark the export burns in, then the editor
// closes so the image stops being a placement surface.
const handleAcceptCustomWm = () => {
setCustomWmApplied({ ...customWm });
commit(false, () => setCustomWmApplied({ ...customWm }));
closeWatermarkEditor();
};
// CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark
// (none at all when nothing was ever accepted), then close exactly like
// ACCEPT — the escape hatch a running session previously lacked.
const handleCancelCustomWm = () => {
setCustomWm({ ...customWmApplied });
commit(false, () => setCustomWm({ ...customWmApplied }));
closeWatermarkEditor();
};
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
@@ -295,10 +311,17 @@ export default function App() {
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
// Resolved by the mount effect just below, awaited by the share intake further
// down: a share that cold-starts the app must land AFTER the session restore,
// or the restored libraryImageUri/mode would replace the shared photo — and by
// then the intent is consumed, so the photo is unreachable for good.
const sessionInitRef = useRef<Promise<void> | null>(null);
useEffect(() => {
// B5: run a single sequenced init — restore the session first, then fall
// back to the first recipe only when no recipe was restored.
initSession();
// The restored look is step zero: BACK can never walk behind the load.
sessionInitRef.current = initSession().then(clearHistory);
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
}, []);
@@ -505,12 +528,14 @@ export default function App() {
};
const applyRecipe = (recipe: Recipe) => {
setSelectedRecipe(recipe);
// EVERY knob the recipe carries lands in the panel, backfilled over the
// defaults so a recipe saved before a knob existed cannot leave that knob on
// whatever the previous look happened to have.
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
setSelectedFrame(recipe.frameId || 'none');
commit(false, () => {
setSelectedRecipe(recipe);
// EVERY knob the recipe carries lands in the panel, backfilled over the
// defaults so a recipe saved before a knob existed cannot leave that knob
// on whatever the previous look happened to have.
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
setSelectedFrame(recipe.frameId || 'none');
});
// GPS watermark is the user's own setting (FRAME tab), not part of a
// preset look: recipes carry a useGeotag field but selecting one must
// never flip the watermark on/off.
@@ -552,13 +577,21 @@ export default function App() {
setCustomWm({ ...DEFAULT_CUSTOM_WM });
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
setGpsWm({ ...DEFAULT_GPS_WM });
// A look belongs to the photo and the mode it was edited on: the next load
// starts a fresh history, even when the values come back the same.
clearHistory();
};
// RESET (panel chip, any tab): the state a default start would leave — the
// startup look, every parameter back to neutral, frame off and both
// watermarks back to their defaults. Same pieces as resetEditsToRecipe, but
// the look goes back to the app's own default rather than the recipe's.
// A RESET on an already-clean look is not a step: BACK would have nothing
// to put back and would burn a press on a no-op.
const handleResetAll = () => {
if (resetDirty) commit(false, resetLookInPlace);
};
const resetLookInPlace = () => {
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
if (provia) {
applyRecipe(filmSimToRecipe(provia));
@@ -603,29 +636,146 @@ export default function App() {
cropRatio !== 'none' ||
differs(cropRect, DEFAULT_CROP_RECT);
// ---- Hold-to-compare -----------------------------------------------------
// While a LIGHT/WB/FX panel is open, pressing and holding on the picture
// shows it with the last edit undone, so the user can flick between before
// and after. The picture and the parameter display both follow the peek;
// the stored values, RECIPE and the export never move.
const PEEK_GAP_MS = 700; // a longer pause = a new gesture, so re-snapshot
const peekRef = useRef<{ before: ColorAdjustments | null; at: number }>({
before: null,
at: 0,
// ---- Look history: BACK (undo) + PEEK (hold to compare) ------------------
// Every edit the panel can make is a step: PRESETS/RECIPES, LIGHT, WB, FX and
// FRAME (frame, crop, turn, straighten, both watermarks, the GPS stamp). BACK
// pops one step; PEEK — a hold on the picture — shows the
// look one step back, with every tab's values travelling with it. Neither
// touches the stored values, the RECIPE or the export: undo writes them, peek
// only shows them. The history is seeded at every load, so BACK stops there.
type Look = {
recipe: Recipe | null;
adjustments: ColorAdjustments;
frame: FrameId;
cropRatio: CropRatio;
cropApplied: CropRatio | null;
cropRect: CropRect;
photoRotation: 0 | 90 | 180 | 270;
photoStraighten: number;
wallLandscape: boolean;
useGeotag: boolean;
wmRotation: number;
customWm: CustomWatermark;
customWmApplied: CustomWatermark;
gpsWm: GpsWatermark;
};
const lookNow = (): Look => ({
recipe: selectedRecipe,
adjustments,
frame: selectedFrame,
cropRatio,
cropApplied,
cropRect,
photoRotation,
photoStraighten,
wallLandscape,
useGeotag,
wmRotation,
customWm,
customWmApplied,
gpsWm,
});
const [peekAdjustments, setPeekAdjustments] = useState<ColorAdjustments | null>(null);
const peekArmed = openTab === 'iq' || openTab === 'wb' || openTab === 'filters';
const handleComparePeek = useCallback((on: boolean) => {
setPeekAdjustments(on ? peekRef.current.before : null);
}, []);
// The values the panel and the picture SHOW. While the finger is down they
// fall back to the pre-edit snapshot, so the chip numbers and the open slider
// travel back with the picture and the before/after is readable, not just
// visible. The real values stay untouched: writes, RECIPE and the on-image
// drag baseline all keep reading `adjustments`.
const shownAdjustments = peekArmed && peekAdjustments ? peekAdjustments : adjustments;
const withPeek = (r: Recipe): Recipe =>
shownAdjustments === r.adjustments ? r : { ...r, adjustments: shownAdjustments };
const lookRef = useRef<Look | null>(null);
const pastRef = useRef<Look[]>([]);
const [undoDepth, setUndoDepth] = useState(0);
const [peekLook, setPeekLook] = useState<Look | null>(null);
// A gesture is one step: a slider drag (or a run of keystrokes) inside this
// gap stays the step it started as, so BACK undoes the whole gesture rather
// than one event of it.
const PEEK_GAP_MS = 700;
const lastStepRef = useRef(0);
// A step is pushed BEFORE the change lands. Nested commits (a handler whose
// work calls another committing one, e.g. RESET -> applyRecipe) belong to the
// outer step, or BACK would need one press per layer of one press.
const commitDepthRef = useRef(0);
const commit = (coalesce: boolean, mutate: () => void) => {
if (commitDepthRef.current > 0) {
mutate();
return;
}
const now = Date.now();
if (!coalesce || now - lastStepRef.current > PEEK_GAP_MS) {
if (lookRef.current) pastRef.current.push(lookRef.current);
setUndoDepth(pastRef.current.length);
}
lastStepRef.current = coalesce ? now : 0;
commitDepthRef.current++;
try {
mutate();
} finally {
commitDepthRef.current--;
}
};
// The loaded look is step zero — nothing before it can be undone.
const clearHistory = () => {
pastRef.current = [];
lastStepRef.current = 0;
setUndoDepth(0);
};
const applyLook = (l: Look) => {
setSelectedRecipe(l.recipe);
setAdjustments(l.adjustments);
setSelectedFrame(l.frame);
setCropRatio(l.cropRatio);
setCropApplied(l.cropApplied);
setCropRect(l.cropRect);
setPhotoRotation(l.photoRotation);
setPhotoStraighten(l.photoStraighten);
setWallLandscape(l.wallLandscape);
setUseGeotag(l.useGeotag);
setWmRotation(l.wmRotation);
setCustomWm(l.customWm);
setCustomWmApplied(l.customWmApplied);
setGpsWm(l.gpsWm);
};
const handleUndo = () => {
if (!pro) {
promptProFeature('Undo');
return;
}
const prev = pastRef.current.pop();
if (!prev) return;
setPeekLook(null);
lastStepRef.current = 0;
setUndoDepth(pastRef.current.length);
applyLook(prev);
};
const handlePeek = (on: boolean) => {
if (!on) {
setPeekLook(null);
return;
}
const before = pastRef.current[pastRef.current.length - 1];
setPeekLook(before ?? null);
};
// PEEK follows the finger on the picture (the chip is gone). Armed only when
// there is a step to compare against, so a long press to focus cannot turn
// into a paywall in a LITE build.
const peekArmed = pro && undoDepth > 0;
// The live look, mirrored every render so a handler can snapshot what was on
// screen just before it changes it.
const liveLook = lookNow();
lookRef.current = liveLook;
// The look the panel and the picture SHOW. While the finger is down on PEEK
// (or on the picture) every number and every tab travels back a step, so the
// before/after is readable, not just visible. The real values stay untouched:
// writes, RECIPE and the on-image drag baseline all read the live ones.
const shown = peekLook ?? liveLook;
const shownAdjustments = shown.adjustments;
const peeking = peekLook !== null;
// What the preview draws: the shown recipe's own chrome (base filter, name)
// rebuilt over the shown values, so a peek that steps over a PRESETS/RECIPES
// change brings that look's filter back with it. A camera start carries no
// recipe, so that stays the synthetic one.
const previewRecipe: Recipe = {
id: 'temp',
name: 'Custom',
baseFilter: 'none',
...(shown.recipe ?? {}),
adjustments: shown.adjustments,
frameId: shown.frame,
useGeotag: shown.useGeotag,
};
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
// Storm guard, generic across every param: same-value writes (slider
@@ -635,27 +785,24 @@ export default function App() {
// skipped identical EV needs no re-send.
const keys = Object.keys(updates) as (keyof ColorAdjustments)[];
if (keys.every((k) => updates[k] === adjustments[k])) return;
// Remember where THIS edit started. A pause longer than PEEK_GAP_MS counts
// as a new gesture, so the snapshot is retaken; within one drag the earlier
// value survives and the peek shows the drag's own starting point.
// ponytail: a >700ms stall in the middle of one drag re-snapshots mid-way.
const peekNow = Date.now();
if (peekNow - peekRef.current.at > PEEK_GAP_MS) {
peekRef.current.before = adjustments;
}
peekRef.current.at = peekNow;
// B8: never call a setState inside another setState's updater.
const next = { ...adjustments, ...updates };
setAdjustments(next);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
adjustments: next,
});
}
// One drag = one step (see commit's coalescing): the step holds the values
// the drag started from, so BACK and PEEK land on the drag's own start.
// ponytail: a >700ms stall in the middle of one drag splits it in two.
commit(true, () => {
setAdjustments(next);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
adjustments: next,
});
}
});
};
const handleUpdateFrame = (frameId: FrameId) => {
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
const applyFrame = (frameId: FrameId) => {
setSelectedFrame(frameId);
// CROP is a plain-frame tool (polaroid/wall crop through their own window).
// Keep the crop out of a frame that ignores it, or the ratio survives in the
@@ -695,13 +842,15 @@ export default function App() {
promptProFeature('The GPS watermark');
return;
}
setUseGeotag(enabled);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
useGeotag: enabled,
});
}
commit(false, () => {
setUseGeotag(enabled);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
useGeotag: enabled,
});
}
});
if (enabled && !gpsInfo) {
const locationGranted = await requestLocationPermissions();
if (locationGranted) {
@@ -968,7 +1117,8 @@ export default function App() {
const handleAutoStraighten = useCallback(() => {
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
if (tilt == null) return;
setPhotoStraighten(-tilt);
commit(false, () => setPhotoStraighten(-tilt));
// commit only reads refs, so the empty dep list stays honest.
}, []);
// OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a
@@ -1011,6 +1161,41 @@ export default function App() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
// System share intake. A shared photo arrives as the launch intent's
// EXTRA_STREAM, which only the Kotlin module can read (takeSharedImage): it
// copies the stream into the cache and this feeds it to the library pipeline
// as an ordinary photo — same path as a pick, GPS and all.
const sharedPickedRef = useRef(handleLibraryPicked);
sharedPickedRef.current = handleLibraryPicked;
useEffect(() => {
let stale = false;
let busy = false;
const importShared = async () => {
if (stale || busy) return;
busy = true;
try {
await sessionInitRef.current;
if (stale) return;
const shared = await RecipescamExport.takeSharedImage();
if (stale || !shared?.path) return;
sharedPickedRef.current({ uri: `file://${shared.path}`, assetId: shared.assetId || null });
} finally {
busy = false;
}
};
importShared();
// The other half is a share landing on an app that is already alive: no
// mount pass sees that intent, only this background -> active transition.
// The native read clears the extra, so later resumes import nothing.
const sub = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') importShared();
});
return () => {
stale = true;
sub.remove();
};
}, []);
const activeRecipe: Recipe =
selectedRecipe || {
id: 'temp',
@@ -1040,14 +1225,21 @@ export default function App() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert(
'PRO LOOK',
`${feature} can be previewed and edited for free, but exporting it needs the PRO build.`
`${feature} can be previewed and edited, but exporting it needs PRO.`,
[
{ text: 'NOT NOW', style: 'cancel' },
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
]
);
};
// 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 the PRO build.`);
Alert.alert('PRO FEATURE', `${feature} needs PRO.`, [
{ text: 'NOT NOW', style: 'cancel' },
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
]);
};
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
@@ -1509,51 +1701,42 @@ export default function App() {
ref={viewfinderRef}
mode={mode}
facing={cameraFacing}
recipe={withPeek(
selectedRecipe || {
id: 'temp',
name: 'Custom',
baseFilter: 'none',
adjustments,
frameId: selectedFrame,
useGeotag,
}
)}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
recipe={previewRecipe}
selectedFrame={shown.frame}
useGeotag={shown.useGeotag}
gpsInfo={gpsInfo}
libraryImageUri={libraryImageUri}
availableHeight={deckTop}
photoRotation={photoRotation}
photoStraighten={photoStraighten}
photoRotation={shown.photoRotation}
photoStraighten={shown.photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
aspectRatio={aspectRatio}
cropRatio={cropRatio}
cropApplied={cropApplied}
cropRect={cropRect}
onCropRectChange={setCropRect}
cropRatio={shown.cropRatio}
cropApplied={shown.cropApplied}
cropRect={shown.cropRect}
onCropRectChange={(r) => commit(true, () => setCropRect(r))}
onSessionError={handleCameraSessionError}
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
imageAdjustTarget={imageAdjustTarget}
onComparePeek={peekArmed ? handleComparePeek : undefined}
onComparePeek={peekArmed ? handlePeek : undefined}
// Touch-to-shutter: tap the live image -> focus locks -> capture.
onTouchShutter={touchToShutter ? handleCapture : undefined}
customWm={customWm}
wmRotation={wmRotation}
gpsWm={gpsWm}
onGpsWmChange={(x, y, size) => setGpsWm((g) => ({ ...g, x, y, size }))}
onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))}
wallLandscape={wallLandscape}
customWm={shown.customWm}
wmRotation={shown.wmRotation}
gpsWm={shown.gpsWm}
onGpsWmChange={(x, y, size) => commit(true, () => setGpsWm((g) => ({ ...g, x, y, size })))}
onWmSizeChange={(size) => commit(true, () => setCustomWm((p) => ({ ...p, size })))}
wallLandscape={shown.wallLandscape}
// The mark only captures image touches while its panel is open, so a
// watermark left ON cannot block tap-to-focus or pinch/zoom.
wmEditing={openTab === 'frame' && wmPanelOpen}
frameTabActive={openTab === 'frame'}
onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))}
onWmPositionChange={(x, y) => commit(true, () => setCustomWm((p) => ({ ...p, x, y })))}
onWmEdit={() => {
// Double-tap on the mark re-opens its editor (and with it the
// placement layer), then focuses the input.
@@ -1567,13 +1750,15 @@ export default function App() {
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
<TopBar
mode={mode}
recipeName={activeRecipe.name}
recipeName={peeking ? previewRecipe.name : activeRecipe.name}
onSaveRecipe={handleSaveRecipe}
onOverwriteRecipe={selectedRecipe?.isCustom ? handleOverwriteRecipe : undefined}
onEditRecipe={
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
}
onExportShare={handleExportShare}
onUndo={handleUndo}
undoDepth={undoDepth}
/>
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
@@ -1598,25 +1783,26 @@ export default function App() {
adjustments={shownAdjustments}
onResetAll={handleResetAll}
resetDirty={resetDirty}
selectedFrame={selectedFrame}
cropRatio={cropRatio}
onUpdateCropRatio={setCropRatio}
cropApplied={cropApplied}
onApplyCrop={() => setCropApplied(cropRatio)}
selectedFrame={shown.frame}
cropRatio={shown.cropRatio}
onUpdateCropRatio={(r) => commit(false, () => setCropRatio(r))}
cropApplied={shown.cropApplied}
onApplyCrop={() => commit(false, () => setCropApplied(cropRatio))}
cropEnabled={
mode === 'library' &&
(selectedFrame === 'none' ||
selectedFrame === 'classic-white' ||
selectedFrame === 'cinematic')
}
photoRotation={photoRotation}
photoStraighten={photoStraighten}
onPickRotation={setPhotoRotation}
onStraighten={setPhotoStraighten}
photoRotation={shown.photoRotation}
photoStraighten={shown.photoStraighten}
onPickRotation={(d) => commit(false, () => setPhotoRotation(d))}
onStraighten={(d) => commit(true, () => setPhotoStraighten(d))}
onAutoStraighten={handleAutoStraighten}
wallLandscape={wallLandscape}
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
useGeotag={useGeotag}
wallLandscape={shown.wallLandscape}
onToggleWallLandscape={() => commit(false, () => setWallLandscape((v) => !v))}
useGeotag={shown.useGeotag}
pro={pro}
openParam={openParamKey}
onOpenParamChange={setOpenParamKey}
currentBaseFilter={selectedRecipe?.baseFilter}
@@ -1635,15 +1821,15 @@ export default function App() {
recipeLimitReached={liteSaveBlocked}
onRecipeLimit={promptRecipeLimit}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
customWm={shown.customWm}
onUpdateCustomWm={handleUpdateCustomWm}
gpsWm={gpsWm}
onUpdateGpsWm={setGpsWm}
gpsWm={shown.gpsWm}
onUpdateGpsWm={(wm) => commit(true, () => setGpsWm(wm))}
onAcceptCustomWm={handleAcceptCustomWm}
onCancelCustomWm={handleCancelCustomWm}
wmEditNonce={wmEditNonce}
wmRotation={wmRotation}
onRotateWm={() => setWmRotation((r) => (r + 90) % 360)}
wmRotation={shown.wmRotation}
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
onWmPanelChange={setWmPanelOpen}
/>
</View>
@@ -1692,8 +1878,12 @@ export default function App() {
startupMode={startupMode}
onStartupMode={handleStartupMode}
pro={pro}
onUpgrade={() => setShowPro(true)}
/>
{/* The paywall: the only place the tier is sold. */}
<ProModal visible={showPro} onClose={() => setShowPro(false)} />
{/* Session photo viewer (swipe across captured/exported photos) */}
<PhotoViewerModal
visible={viewerIndex !== null && photoHistory.length > 0}
+2 -1
View File
@@ -22,6 +22,7 @@ Generated 2026-09-09 from node_modules audit, rev. 2026-09-14 — 31 shipped com
| expo-file-system | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/filesystem/ |
| expo-font | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/font/ |
| expo-haptics | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/haptics/ |
| expo-iap | 5.6.0 | MIT | Copyright (c) 2025 hyochan | https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme |
| expo-keep-awake | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/keep-awake/ |
| expo-location | 57.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/location/ |
| expo-media-library | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/media-library/ |
@@ -130,7 +131,7 @@ SOFTWARE.
## MIT
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-iap (Copyright (c) 2025 hyochan), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
```text
MIT License
+11
View File
@@ -27,6 +27,17 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Share target. Without this filter the app never appears in the
system share sheet at all; image/* only, the editor takes photos.
Goes on the launcher activity: it is singleTask, so a share
re-delivered there replaces its intent (onNewIntent -> setIntent)
and the running app picks the photo up without a cold start. -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
@@ -1,5 +1,6 @@
package com.locphamtran.recipescamera
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -19,6 +20,17 @@ class MainActivity : ReactActivity() {
super.onCreate(null)
}
/**
* A share delivered to the running app lands here, and React Native never hands
* the new intent to the activity (nothing in ReactActivity/ReactDelegate calls
* setIntent) — so getIntent() keeps returning the launch intent and the share
* intake would read a photo the user shared minutes ago, or none at all.
*/
override fun onNewIntent(intent: Intent) {
setIntent(intent)
super.onNewIntent(intent)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
@@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
@@ -11,6 +12,7 @@ import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
@@ -77,6 +79,23 @@ class RecipescamExportModule : Module() {
Name("RecipesCamExport")
Log.i(TAG, "module defined RecipesCamExport")
// System share sheet intake. AndroidManifest declares this app a share
// target (ACTION_SEND / ACTION_SEND_MULTIPLE, image/*), and the shared photo
// exists only as the intent's EXTRA_STREAM — a content:// Uri that no JS API
// here can read (expo-linking surfaces VIEW data, never stream extras, and on
// 13+ the sender's uri is short-lived and redacted). So the stream is copied
// natively into the cache and handed to JS as an ordinary file that the normal
// library pipeline (path + assetId -> EXIF/MediaStore GPS) reads.
AsyncFunction("takeSharedImage") Coroutine { ->
// Read live, never cached: launchMode=singleTask re-delivers a share that
// arrives while we are alive through onNewIntent -> setIntent, so the
// activity's intent is always the current one and no native hook is needed.
val ctx = appContext.reactContext
val activity = appContext.currentActivity
if (ctx == null || activity == null) emptyMap<String, String>()
else withContext(Dispatchers.IO) { consumeSharedImage(ctx, activity.intent) }
}
// Save an encoded JPEG/PNG into DCIM/Camera. expo-media-library's JS API
// only ever inserts into DCIM (root) or Pictures/Camera, so a camera-roll
// export lands where the stock camera puts its shots by inserting the
@@ -337,6 +356,9 @@ private suspend fun processPipeline(
val aspect = adjust["aspect"] as? String
val dpi = (adjust["dpi"] as? Number)?.toInt() ?: 0
val sharpen = (adjust["sharpen"] as? Number)?.toDouble() ?: 0.0
// Negative SHARPENING knob (engine step 4): pre-grain gaussian soften, must
// run before the grain pass or the blur eats the re-grained noise.
val soften = (adjust["soften"] as? Number)?.toDouble() ?: 0.0
require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" }
val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f }
val cinemaFlat = FloatArray(16) { i -> (cinemaList?.getOrNull(i) as? Number)?.toFloat() ?: 0f }
@@ -434,11 +456,13 @@ private suspend fun processPipeline(
Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})")
bmp.recycle()
// 5. Denoise / clarity / grain (exportEngine #4 + #6), CPU passes on the
// matrix output. Engine chain: denoise blur -> clarity conv (or mist blur),
// then a grain Overlay rect on top.
val needEnhance = denoise > 0 || clarity != 0.0
val needGrain = grain > 0
// 5. Denoise / clarity / soften / grain (exportEngine #4 + #6), CPU passes
// on the matrix output. Engine chain: denoise blur -> clarity conv (or mist
// blur) -> soften blur for a negative SHARPENING knob, then a grain Overlay
// rect on top. A negative NOISE REDUCTION knob adds re-grain instead of
// blurring, so a -10/-10 photo comes out soft AND grainy.
val needEnhance = denoise > 0 || clarity != 0.0 || soften > 0
val needGrain = grain > 0 || denoise < 0
if (needEnhance || needGrain) {
val t6 = System.nanoTime()
val wO = out.width
@@ -456,10 +480,16 @@ private suspend fun processPipeline(
val mistSigma = (abs(clarity) / 10.0) * 4.0 // bloom/mist
p = boxBlur(p, wO, hO, max(1, mistSigma.roundToInt()))
}
// Negative SHARPENING: same 3px-max gaussian the engine puts in the
// filter chain before grain (MakeBlur sigma = |knob|/10*3).
if (soften > 0) p = boxBlur(p, wO, hO, max(1, soften.roundToInt()))
if (needEnhance) timings["clarityMs"] = ((System.nanoTime() - t6) / 1_000_000).toInt()
val t7 = System.nanoTime()
if (grain > 0) {
val opacity = (grain / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
// Engine #6 grain amount: positive grain plus the re-grain a negative
// NOISE REDUCTION knob asks for, clamped to the 0..10 knob range.
val g = min(10.0, max(0.0, grain) + max(0.0, -denoise))
if (g > 0) {
val opacity = (g / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
p = grainOverlay(p, wO, hO, opacity)
timings["grainMs"] = ((System.nanoTime() - t7) / 1_000_000).toInt()
Log.i(TAG, "grain applied opacity=$opacity")
@@ -1216,3 +1246,71 @@ private fun parityCompare(ctx: Context, pathA: String, pathB: String, outPath: S
"gt32pct" to (gt[2] * 100.0 / total),
)
}
// ---- system share intake (AndroidManifest ACTION_SEND) ---------------------
/**
* The photo a share handed this launch, copied out of the sender's content://
* stream into our own cache and returned as { path, assetId, type } — all empty
* when this launch was not a share, or when the share was already taken.
*
* The copy is what lets the rest of the app work unchanged: every other entry
* point (LibraryPickerModal, the export pipeline, the EXIF read) wants a real
* file, and the sender's read grant only ever covers this one activity.
*
* ponytail: only the FIRST stream is read, so ACTION_SEND_MULTIPLE's 2nd..nth
* images are dropped — the editor works on one photo at a time; add when a
* multi-photo queue exists.
*/
private fun consumeSharedImage(ctx: Context, intent: Intent?): Map<String, String> {
if (intent == null) return emptyMap()
val action = intent.action
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return emptyMap()
val uri = sharedStreamUri(intent) ?: return emptyMap()
// Consumed: the activity keeps this intent for its whole life and JS re-asks
// on every foreground return, so the extra goes — otherwise every resume
// re-imported the same photo and threw the edits already made on it away.
intent.removeExtra(Intent.EXTRA_STREAM)
intent.clipData = null
// The sender's declared type, not the file name: several apps share HEIC or
// PNG streams with no usable name at all.
val type = ctx.contentResolver.getType(uri) ?: intent.type ?: "image/*"
val ext = when {
type.contains("png") -> "png"
type.contains("webp") -> "webp"
type.contains("heic") || type.contains("heif") -> "heic"
else -> "jpg"
}
val dst = File(File(ctx.cacheDir, "shared").apply { mkdirs() }, "shared-${System.currentTimeMillis()}.$ext")
try {
ctx.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "unreadable share stream" }
dst.outputStream().use { input.copyTo(it) }
}
} catch (e: Throwable) {
Log.w(TAG, "shared image copy failed: ${e.message}")
dst.delete()
return emptyMap()
}
// MediaStore ids are the trailing number of the uri — "content://media/
// external/images/media/12345", or the document form ".../image%3A12345"
// (percent-decoded by Uri itself). The library pipeline uses it for the
// MediaStore location lookup when the file carries no EXIF GPS.
val id = (uri.lastPathSegment ?: "").substringAfterLast(':')
val assetId = if (id.isNotEmpty() && id.all { c -> c.isDigit() }) id else ""
Log.i(TAG, "shared image -> ${dst.absolutePath} (type=$type, assetId=$assetId)")
return mapOf("path" to dst.absolutePath, "assetId" to assetId, "type" to type)
}
/**
* The sender's stream. Every API level puts it in EXTRA_STREAM — one Uri for
* SEND, a ParcelableArrayList for SEND_MULTIPLE — and a few senders fill in
* only clipData, so all three shapes are accepted.
*/
@Suppress("DEPRECATION")
private fun sharedStreamUri(intent: Intent): Uri? {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { return it }
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.firstOrNull()?.let { return it }
val clip = intent.clipData
return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null
}
+12 -1
View File
@@ -42,7 +42,7 @@ export interface PhotoAdjust {
toneSh?: number;
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
cinema?: number[] | null;
/** ColorAdjustments.denoise 0..10 (0 = skip). */
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
denoise?: number;
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
clarity?: number;
@@ -52,6 +52,8 @@ export interface PhotoAdjust {
quality?: number;
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
sharpen?: number;
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
soften?: number;
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
dpi?: number;
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
@@ -104,6 +106,15 @@ export interface RecipescamExportModule {
*/
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
/**
* The image a system share handed this launch (AndroidManifest
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
* stream is copied into the cache and returned as an ordinary file.
* `path` is "" when this launch was not a share, or when the share was
* already taken — the intent is read and cleared exactly once.
*/
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
+24 -2
View File
@@ -1,12 +1,12 @@
{
"name": "recipescamera",
"version": "1.2",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recipescamera",
"version": "1.2",
"version": "1.2.1",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/slider": "5.2.0",
@@ -19,6 +19,7 @@
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-haptics": "~57.0.1",
"expo-iap": "^5.6.0",
"expo-keep-awake": "~57.0.1",
"expo-location": "~57.0.4",
"expo-media-library": "~57.0.2",
@@ -3487,6 +3488,27 @@
"expo": "*"
}
},
"node_modules/expo-iap": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/expo-iap/-/expo-iap-5.6.0.tgz",
"integrity": "sha512-Rqz8wrxAMuOTNpdOxSpizFRQmNQIQ+cpHY/120rT8HY8XZmlp7EiCnNT9Ybh61W4XIpGVSwNLm4ETH01DfmRCA==",
"license": "MIT",
"peerDependencies": {
"@amazon-devices/keplerscript-appstore-iap-lib": "~2.13.0",
"@amazon-devices/package-manager-lib": "~1.0.1767254401",
"expo": "*",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"@amazon-devices/keplerscript-appstore-iap-lib": {
"optional": true
},
"@amazon-devices/package-manager-lib": {
"optional": true
}
}
},
"node_modules/expo-json-utils": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
+1
View File
@@ -14,6 +14,7 @@
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-haptics": "~57.0.1",
"expo-iap": "^5.6.0",
"expo-keep-awake": "~57.0.1",
"expo-location": "~57.0.4",
"expo-media-library": "~57.0.2",
+14 -7
View File
@@ -6,7 +6,7 @@ import { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { IS_PRO, PRO_FRAMES } from '../utils/entitlement';
import { PRO_FRAMES } from '../utils/entitlement';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
import RecipeCreateModal from './RecipeCreateModal';
@@ -66,6 +66,10 @@ interface AdjustmentPanelProps {
onApplyCrop: () => void;
cropEnabled?: boolean;
useGeotag: boolean;
// Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build
// keeps every PRO chip on screen, greyed with its corner tag, so the panel
// has to read the value at render time rather than at build time.
pro: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
// tool-rail tabs.
@@ -141,6 +145,9 @@ interface AdjustmentPanelProps {
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
onWmPanelChange: (open: boolean) => void;
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
// one parameter, and both left this panel: BACK is a button in the top bar,
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
}
interface ChipDef {
@@ -229,6 +236,7 @@ export default function AdjustmentPanel({
onApplyCrop,
cropEnabled,
useGeotag,
pro,
openParam,
onOpenParamChange,
currentBaseFilter,
@@ -338,9 +346,8 @@ export default function AdjustmentPanel({
? 'border-amber-500/70 bg-amber-500/15'
: 'border-zinc-700/60 bg-titan/80';
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
// LITE only: corner tag on every chip whose look LITE cannot export. PRO
// never sees it (there the condition is a constant false).
const proTag = c.pro && !IS_PRO;
// LITE only: corner tag on every chip whose look LITE cannot export.
const proTag = c.pro && !pro;
return (
<View
key={c.key}
@@ -424,7 +431,7 @@ export default function AdjustmentPanel({
// A PRO-only parameter stays visible in LITE as a greyed chip with its
// corner PRO tag — named where it is, which is the only place it can be.
pro: p.pro,
disabled: !!p.pro && !IS_PRO,
disabled: !!p.pro && !pro,
onPress: () => toggleParam(p.key),
}));
@@ -560,7 +567,7 @@ export default function AdjustmentPanel({
const wmFontOptions = useMemo(() => {
// LITE ships a single basic face: the platform's own families (what
// DEFAULT is not) are the paid half of the FONT strip.
if (!IS_PRO) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
const mgr = Skia.FontMgr.System();
const names = new Set<string>();
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
@@ -571,7 +578,7 @@ export default function AdjustmentPanel({
{ v: 'DEFAULT', d: 'DEFAULT' },
...[...names].sort().map((n) => ({ v: n, d: n })),
];
}, []);
}, [pro]);
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
cx: {
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { View, Text, Modal, ScrollView, TouchableOpacity } from 'react-native';
import { X, Zap, RefreshCw } from 'lucide-react-native';
import { buyPro, restorePro, useProUnlock } from '../utils/proUnlock';
import { PRO_FRAMES } from '../utils/entitlement';
// The paywall. LITE keeps every look previewable and editable; this sheet is
// the only place the tier is sold, and Google Play does the selling — the price
// shown is the one Play returned for the product.
//
// Play requires a way back to an existing purchase on any screen that sells
// one, so RESTORE is a peer of BUY, not a footnote.
const PERKS = [
'No RECIPESCAM mark on your exports',
'HDF effect',
`${PRO_FRAMES.length} printed frames`,
'GPS location watermark',
'Every platform font for your watermark',
'Unlimited recipes and favorites',
];
export default function ProModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { owned, price, busy, error } = useProUnlock();
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-4">
<TouchableOpacity className="absolute inset-0" activeOpacity={1} onPress={onClose} />
<View
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5"
style={{ backgroundColor: 'rgba(8,8,10,0.97)', maxHeight: '85%' }}
>
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center">
<Zap size={13} color="#f59e0b" strokeWidth={1.75} />
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-widest ml-2">
RECIPESCAM PRO
</Text>
</View>
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<X size={15} color="#71717a" />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<Text className="text-zinc-500 font-mono text-[10px] leading-4">
One payment, kept on your Google account. Nothing to type in, and it comes back on
every phone that has the app.
</Text>
<View className="mt-3 space-y-1.5">
{PERKS.map((p) => (
<View key={p} className="flex-row items-start">
<Text className="text-amber-500 font-mono text-[11px] mr-2">+</Text>
<Text className="text-zinc-300 font-mono text-[11px] flex-1">{p}</Text>
</View>
))}
</View>
{owned ? (
<View className="mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-2.5">
<Text className="text-amber-500 font-mono text-[11px] font-bold tracking-widest">
PRO ACTIVE
</Text>
<Text className="text-zinc-500 font-mono text-[10px] mt-1">
Every look exports clean: no mark, no limits.
</Text>
</View>
) : (
<TouchableOpacity
onPress={buyPro}
disabled={busy}
activeOpacity={0.7}
className={`mt-4 rounded-xl border px-3 py-3 items-center ${
busy ? 'border-zinc-800/70 bg-black/30' : 'border-amber-500/70 bg-amber-500/15'
}`}
>
<Text
className={`font-mono text-[11px] font-bold tracking-widest ${
busy ? 'text-zinc-500' : 'text-amber-500'
}`}
>
{busy ? 'CONTACTING GOOGLE PLAY…' : price ? `UNLOCK PRO — ${price}` : 'UNLOCK PRO'}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={restorePro}
disabled={busy}
activeOpacity={0.7}
className="mt-2 flex-row items-center justify-center rounded-xl border border-zinc-800/70 bg-black/30 px-3 py-2.5"
>
<RefreshCw size={11} color="#a1a1aa" strokeWidth={1.75} />
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest ml-2">
RESTORE PURCHASES
</Text>
</TouchableOpacity>
{error ? (
<Text className="text-zinc-500 font-mono text-[10px] mt-2.5 leading-4">{error}</Text>
) : null}
<Text className="text-zinc-600 font-mono text-[10px] mt-3 leading-4">
Bought already but the app still says LITE? Restore, or check that you are signed in
to the Google account that paid.
</Text>
</ScrollView>
</View>
</View>
</Modal>
);
}
+4 -4
View File
@@ -286,8 +286,8 @@ export default function RecipeCreateModal({
highlight: int(fHigh, simAdj.highlight, -10, 10),
shadow: int(fShadow, simAdj.shadow, -10, 10),
saturation: int(fColor, simAdj.saturation, -10, 10),
denoise: int(fNr, simAdj.denoise, 0, 10),
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
denoise: int(fNr, simAdj.denoise, -10, 10),
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
clarity: int(fClarity, simAdj.clarity, -10, 10),
grain: int(grain, seedAdj.grain, 0, 10),
colorChrome: cc as ColorAdjustments['colorChrome'],
@@ -313,8 +313,8 @@ export default function RecipeCreateModal({
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
];
+9 -3
View File
@@ -37,8 +37,11 @@ interface SettingsModalProps {
onGpsEnabled: (enabled: boolean) => void;
startupMode: StartupMode;
onStartupMode: (mode: StartupMode) => void;
// LITE vs PRO, decided at build time (no key, nothing to enter here).
// LITE vs PRO: the PRO apk is born owned, the free one buys the tier through
// Google Play (see src/utils/proUnlock.ts). Nothing to enter here either way —
// the LITE row just opens the paywall.
pro: boolean;
onUpgrade: () => void;
}
function ChipRow({ chips }: { chips: Chip[] }) {
@@ -101,6 +104,7 @@ export default function SettingsModal({
startupMode,
onStartupMode,
pro,
onUpgrade,
}: SettingsModalProps) {
const [showCredits, setShowCredits] = useState(false);
// The RN Modal is its own dialog window, so the IME does not resize it and
@@ -262,13 +266,15 @@ export default function SettingsModal({
<SectionLabel>PRO</SectionLabel>
<ChipRow
chips={[
{ key: 'plan', label: pro ? 'PRO ACTIVE' : 'LITE', active: pro, disabled: true, onPress: () => {} },
pro
? { key: 'plan', label: 'PRO ACTIVE', active: true, disabled: true, onPress: () => {} }
: { key: 'plan', label: 'UPGRADE TO PRO', active: false, onPress: onUpgrade },
]}
/>
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
{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.'}
: '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. One payment unlocks everything.'}
</Text>
</View>
+27 -2
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Plus, Pencil } from 'lucide-react-native';
import { Plus, Pencil, Undo2 } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
interface TopBarProps {
@@ -16,9 +16,23 @@ interface TopBarProps {
// SAVE & SHARE -> EXPORT: write this look to an encrypted recipe file and
// hand it to the system share sheet so it can reach another phone.
onExportShare: (name: string) => void;
// BACK (undo): one step back through the look's history. PRO only — App
// refuses and offers the paywall when a LITE build taps it. No count is
// drawn: the icon turns amber while there is a step to go back to.
onUndo: () => void;
undoDepth: number;
}
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) {
export default function TopBar({
mode,
recipeName,
onSaveRecipe,
onOverwriteRecipe,
onEditRecipe,
onExportShare,
onUndo,
undoDepth,
}: TopBarProps) {
const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = useState(false);
const [newRecipeName, setNewRecipeName] = useState('');
@@ -75,6 +89,17 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
</View>
<View className="flex-row items-center space-x-2">
{/* BACK: the history control the chip row used to carry, sitting
between the status block and SAVE & SHARE. Lucide (ISC) vector
— no icon font to ship or license. */}
<TouchableOpacity
onPress={onUndo}
accessibilityLabel="BACK"
className="rounded-full border border-zinc-700/80 bg-black/45 p-2.5"
activeOpacity={0.7}
>
<Undo2 size={13} color={undoDepth > 0 ? '#f59e0b' : '#52525b'} />
</TouchableOpacity>
{onEditRecipe && (
<TouchableOpacity
onPress={onEditRecipe}
+61 -30
View File
@@ -468,13 +468,22 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Sharpening (-10..10): below 0 the knob softens the frame, the same blur the
// export applies. Own sync so it wraps the clarity filter instead of the two
// knobs clobbering each other.
const softenSync = useMemo(
() => createSynchronizable<number>(adjustments.sharpening ?? 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
colorMatrixSync.setBlocking(colorMatrix);
toneSync.setBlocking(toneParams);
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
hdfSync.setBlocking(adjustments.hdf ?? 0);
claritySync.setBlocking(adjustments.clarity ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
softenSync.setBlocking(adjustments.sharpening ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, adjustments.sharpening, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, softenSync]);
// Camera controller lives behind SkiaCamera. The camera AE bias is never
// written: EV (user + metering offset) is purely software matrix gain, for
@@ -1134,38 +1143,50 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
console.warn('Color matrix build failed: ' + e);
}
// CLARITY: the export pass 4 twin — unsharp convolution above 0, mist
// blur below. Applied to the base paint so it sits under the glow and
// the panel actually shows what the file will get.
// blur below. SHARPENING < 0 rides the same slot: it softens the frame
// the exported file gets too. Both sit on the base paint, under the
// glow, and compose (soften wraps whatever clarity built).
try {
const clarityV = claritySync.getDirty();
if (typeof clarityV === 'number' && Number.isFinite(clarityV) && clarityV !== 0) {
const clarityFilter =
clarityV > 0
? (() => {
const a = (clarityV / 10) * 0.8;
return Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
1,
0,
0,
0,
T_CLAMP,
true,
null
);
})()
: Skia.ImageFilter.MakeBlur(
Math.abs(clarityV / 10) * 4,
Math.abs(clarityV / 10) * 4,
const softenV = softenSync.getDirty();
const cv = typeof clarityV === 'number' && Number.isFinite(clarityV) ? clarityV : 0;
const sv = typeof softenV === 'number' && Number.isFinite(softenV) ? softenV : 0;
if (cv !== 0 || sv < 0) {
const a = (cv / 10) * 0.8;
let lensFilter =
cv > 0
? Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
1,
0,
0,
0,
T_CLAMP,
true,
null
);
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
)
: cv < 0
? Skia.ImageFilter.MakeBlur(
Math.abs(cv / 10) * 4,
Math.abs(cv / 10) * 4,
T_CLAMP,
null
)
: null;
if (sv < 0) {
lensFilter = Skia.ImageFilter.MakeBlur(
Math.abs(sv / 10) * 3,
Math.abs(sv / 10) * 3,
T_CLAMP,
lensFilter
);
}
if (lensFilter != null) paint.setImageFilter(lensFilter);
}
} catch (e) {
console.warn('Clarity filter build failed: ' + e);
console.warn('Lens filter build failed: ' + e);
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
@@ -1393,7 +1414,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
);
const grainOpacity = adjustments.grain / 20;
// NOISE REDUCTION below 0 puts grain back instead of smoothing it away, so a
// negative NR and GRAIN share one overlay (capped at 10 → 0.5), exactly as
// the export pass does.
const grainOpacity =
Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20;
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
// point / double-tap again to reset to 100%. Transform is a Skia matrix
@@ -2844,10 +2869,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
);
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
if (libClarity < 0) {
// Sharpening < 0 rides the same wrap: the export runs a second Gaussian
// after the mist, and two Gaussians in series are one of sqrt(a^2 + b^2),
// so a single pass with that sigma matches the file.
const libMist = libClarity < 0 ? Math.abs(libClarity / 10) * 4 * sx : 0;
const libSoften = (Math.max(0, -(adjustments.sharpening ?? 0)) / 10) * 3 * sx;
const libBlur = Math.sqrt(libMist * libMist + libSoften * libSoften);
if (libBlur > 0) {
photo = (
<Group>
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
<Blur blur={libBlur} mode="clamp" />
{photo}
</Group>
);
+2 -2
View File
@@ -71,13 +71,13 @@ export interface ColorAdjustments {
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
denoise: number; // 0 to 10 (mapped to blur sigma)
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
sharpening: number; // 0 to 10 (output screen sharpen strength)
sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame)
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
+7
View File
@@ -101,6 +101,13 @@ export const CREDITS: CreditEntry[] = [
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
url: "https://docs.expo.dev/versions/latest/sdk/haptics/",
},
{
name: "expo-iap",
version: "5.6.0",
license: "MIT",
copyright: "Copyright (c) 2025 hyochan",
url: "https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme",
},
{
name: "expo-keep-awake",
version: "57.0.1",
+5 -9
View File
@@ -1,14 +1,10 @@
import { FrameId } from '../types';
import { IS_PRO_BUILD } from '../provariant';
// 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.
// 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;
// LITE (free) vs PRO. Two apks come out of this one tree: the PRO apk
// (src/provariant.ts, rewritten by tools/set-variant.mjs) is born owned, while
// the LITE apk is the free build and buys the tier through Google Play. The
// tier itself is runtime state, so it lives in src/utils/proUnlock.ts and every
// caller takes it from there (useProUnlock) or by prop.
// The gate is on the FILE, not on the preview: a LITE user composes with any
// look and compares it before/after — only the render/export of a PRO look is
+34 -4
View File
@@ -335,6 +335,24 @@ export async function processAndExportPhoto(
}
}
// Negative SHARPENING is the amount of digital edge enhancement to take
// back OUT. It runs HERE, inside the photo's own filter chain, because the
// post-grain twin (step 9) blurred the grain overlay away: NR < 0 plus
// SHARPENING < 0 exported as flat mush with no grain at all.
const sharpKnob = adjustments.sharpening ?? 0;
if (sharpKnob < 0) {
const softenSigma = (Math.abs(sharpKnob) / 10) * 3;
const softenFilter = Skia.ImageFilter.MakeBlur(
softenSigma,
softenSigma,
TileMode.Clamp,
imageFilter
);
if (softenFilter) {
imageFilter = softenFilter;
}
}
if (imageFilter) {
paint.setImageFilter(imageFilter);
}
@@ -423,8 +441,18 @@ export async function processAndExportPhoto(
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
// NOISE REDUCTION below 0 works the other way round: it hands back the grain
// the denoiser would have flattened (a JPEG cannot be un-smoothed, but it
// can be re-grained). Same overlay as GRAIN, capped at the GRAIN slider's
// own ceiling so a negative NR plus GRAIN never double up.
// ponytail: re-grain, not de-noise; add a real grain-preserving NR the day
// the pipeline gets a pre-ISP raw frame.
const grainAmount = Math.min(
10,
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
);
if (grainAmount > 0) {
const grainOpacity = grainAmount / 20; // up to 0.5 opacity
const grainPaint = Skia.Paint();
grainPaint.setBlendMode(BlendMode.Overlay);
grainPaint.setAlphaf(grainOpacity);
@@ -732,8 +760,10 @@ export async function processAndExportPhoto(
let resultImage = outputSurface.makeImageSnapshot();
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
// A negative knob means "no sharpening at all" (its blur already ran at
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
+5 -2
View File
@@ -73,10 +73,11 @@ export async function processAndExportPhotoNative(
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (0..10) overrides
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
const aspect: '4:3' | '3:2' | undefined =
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
? options.aspect
@@ -99,6 +100,8 @@ export async function processAndExportPhotoNative(
? { locality: gpsInfo.locality || 'STREET VIEW', timestamp: gpsInfo.timestamp }
: null,
sharpen: sharpenAmount > 0 ? sharpenAmount : undefined,
// Negative knob -> Kotlin soften blur, pre-grain (engine step 4).
soften: sharpKnob < 0 ? (Math.abs(sharpKnob) / 10) * 3 : undefined,
dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined,
aspect,
};
+2 -2
View File
@@ -134,7 +134,7 @@ export const PARAM_DEFS: {
{
key: 'denoise',
label: 'NOISE REDUCTION',
min: 0,
min: -10,
max: 10,
step: 1,
defaultValue: 0,
@@ -156,7 +156,7 @@ export const PARAM_DEFS: {
{
key: 'sharpening',
label: 'SHARPENING',
min: 0,
min: -10,
max: 10,
step: 1,
defaultValue: 0,
+111
View File
@@ -0,0 +1,111 @@
import { useSyncExternalStore } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
fetchProducts,
finishTransaction,
getAvailablePurchases,
initConnection,
purchaseErrorListener,
purchaseUpdatedListener,
requestPurchase,
restorePurchases,
} from 'expo-iap';
import { IS_PRO_BUILD } from '../provariant';
// One product buys the whole tier: PRO removes the mark and opens every gated
// look. Google Play owns the purchase — the app only reads it back.
export const PRO_SKU = 'recipescam_pro_unlock';
// Last known answer, so the app opens unlocked while the store query is still
// in flight (and offline). The store is the authority: it can revoke it below.
const CACHE_KEY = '@recipescam_pro:unlock';
export interface ProState {
owned: boolean;
price: string | null;
busy: boolean;
error: string | null;
}
// The PRO apk is sold as-is: that build is born owned and never talks to Play.
let state: ProState = { owned: IS_PRO_BUILD, price: null, busy: false, error: null };
const listeners = new Set<() => void>();
const set = (patch: Partial<ProState>) => {
state = { ...state, ...patch };
listeners.forEach((l) => l());
};
const subscribe = (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
};
export const getProState = () => state;
export const useProUnlock = () => useSyncExternalStore(subscribe, getProState);
const grant = async () => {
set({ owned: true, error: null });
await AsyncStorage.setItem(CACHE_KEY, '1').catch(() => {});
};
// Play hands the purchase to the listener. Grant it, then finalise: an Android
// purchase left unacknowledged for three days is automatically refunded.
// ponytail: the grant is client-side. Server-side verification of
// purchaseToken through the Play Developer API is the upgrade if piracy shows up.
const handlePurchase = async (purchase: { productId: string } & Record<string, unknown>) => {
if (purchase.productId !== PRO_SKU) return;
await grant();
set({ busy: false });
await finishTransaction({ purchase: purchase as never, isConsumable: false }).catch(() => {});
};
let started = false;
// Called once at startup. A sideloaded build, a phone with no Play account or a
// store hiccup must not take the app down: every failure leaves the tier LITE
// and lands in `error` for the paywall to show.
export async function initProUnlock() {
if (started || IS_PRO_BUILD) return;
started = true;
if ((await AsyncStorage.getItem(CACHE_KEY).catch(() => null)) === '1') set({ owned: true });
try {
await initConnection();
purchaseUpdatedListener((p) => handlePurchase(p as never));
purchaseErrorListener((e) => set({ busy: false, error: e.message }));
const [products, purchases] = await Promise.all([
fetchProducts({ skus: [PRO_SKU], type: 'in-app' }),
getAvailablePurchases(),
]);
const listed = (Array.isArray(products) ? products : []).find((p) => p.id === PRO_SKU);
set({ price: listed?.displayPrice ?? null });
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
} catch (e) {
set({ error: e instanceof Error ? e.message : 'Store unavailable' });
}
}
export async function buyPro() {
if (state.owned || state.busy) return;
set({ busy: true, error: null });
try {
// Event-based: the result arrives on purchaseUpdatedListener above.
await requestPurchase({ request: { google: { skus: [PRO_SKU] } }, type: 'in-app' });
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : 'Purchase failed' });
}
}
export async function restorePro() {
if (IS_PRO_BUILD || state.busy) return;
set({ busy: true, error: null });
try {
await restorePurchases();
const purchases = await getAvailablePurchases();
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
set({ busy: false, error: purchases.some((p) => p.productId === PRO_SKU) ? null : 'No PRO purchase found on this account' });
} catch (e) {
set({ busy: false, error: e instanceof Error ? e.message : 'Restore failed' });
}
}