From a85ebf0a81d9a0e343979f91275ffe6002a7e7f4 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 16 Sep 2026 06:48:22 +0700 Subject: [PATCH] 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 --- App.tsx | 404 +++++++++++++----- THIRD_PARTY_NOTICES.md | 3 +- android/app/src/main/AndroidManifest.xml | 11 + .../locphamtran/recipescamera/MainActivity.kt | 12 + .../export/RecipescamExportModule.kt | 112 ++++- modules/recipescam-export/index.ts | 13 +- package-lock.json | 26 +- package.json | 1 + src/components/AdjustmentPanel.tsx | 21 +- src/components/ProModal.tsx | 115 +++++ src/components/RecipeCreateModal.tsx | 8 +- src/components/SettingsModal.tsx | 12 +- src/components/TopBar.tsx | 29 +- src/components/Viewfinder.tsx | 91 ++-- src/types/index.ts | 4 +- src/utils/credits.ts | 7 + src/utils/entitlement.ts | 14 +- src/utils/exportEngine.ts | 38 +- src/utils/nativeExport.ts | 7 +- src/utils/paramDefs.ts | 4 +- src/utils/proUnlock.ts | 111 +++++ 21 files changed, 860 insertions(+), 183 deletions(-) create mode 100644 src/components/ProModal.tsx create mode 100644 src/utils/proUnlock.ts diff --git a/App.tsx b/App.tsx index 43259e7..34e34e8 100644 --- a/App.tsx +++ b/App.tsx @@ -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([]); - // 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 | 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(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(null); + const pastRef = useRef([]); + const [undoDepth, setUndoDepth] = useState(0); + const [peekLook, setPeekLook] = useState(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) => { // 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) */} 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} /> @@ -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. */} + setShowPro(false)} /> + {/* Session photo viewer (swipe across captured/exported photos) */} 0} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5d2c80b..42bfcd8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion ), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy (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 (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion ), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.) ```text MIT License diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7103303..285e352 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,17 @@ + + + + + + + diff --git a/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt b/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt index 827e30e..a715ff7 100644 --- a/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt +++ b/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt @@ -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. diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index efa2f50..16c3df7 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -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() + 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 { + 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(Intent.EXTRA_STREAM)?.let { return it } + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.firstOrNull()?.let { return it } + val clip = intent.clipData + return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null +} diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index a53bdfb..1c90161 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -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; materializeBenchAsset(assetName: string): Promise; diff --git a/package-lock.json b/package-lock.json index 52123bf..c10dc51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1c1c7fa..653ffd1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 3e169a5..a1d9622 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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 ( 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(); 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, { 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, diff --git a/src/components/ProModal.tsx b/src/components/ProModal.tsx new file mode 100644 index 0000000..6d293c4 --- /dev/null +++ b/src/components/ProModal.tsx @@ -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 ( + + + + + + + + + RECIPESCAM PRO + + + + + + + + + + One payment, kept on your Google account. Nothing to type in, and it comes back on + every phone that has the app. + + + + {PERKS.map((p) => ( + + + + {p} + + ))} + + + {owned ? ( + + + PRO ACTIVE + + + Every look exports clean: no mark, no limits. + + + ) : ( + + + {busy ? 'CONTACTING GOOGLE PLAY…' : price ? `UNLOCK PRO — ${price}` : 'UNLOCK PRO'} + + + )} + + + + + RESTORE PURCHASES + + + + {error ? ( + {error} + ) : null} + + + Bought already but the app still says LITE? Restore, or check that you are signed in + to the Google account that paid. + + + + + + ); +} diff --git a/src/components/RecipeCreateModal.tsx b/src/components/RecipeCreateModal.tsx index 51d6d01..7c83e37 100644 --- a/src/components/RecipeCreateModal.tsx +++ b/src/components/RecipeCreateModal.tsx @@ -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' }, ]; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3236126..149fc8d 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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({ PRO {} }, + pro + ? { key: 'plan', label: 'PRO ACTIVE', active: true, disabled: true, onPress: () => {} } + : { key: 'plan', label: 'UPGRADE TO PRO', active: false, onPress: onUpgrade }, ]} /> {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.'} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index d378ac6..188635b 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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 + {/* 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. */} + + 0 ? '#f59e0b' : '#52525b'} /> + {onEditRecipe && ( (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(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(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(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(function Viewfi ); // 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 = ( - + {photo} ); diff --git a/src/types/index.ts b/src/types/index.ts index 55bda1d..f3e8da4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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) diff --git a/src/utils/credits.ts b/src/utils/credits.ts index f9784aa..708b097 100644 --- a/src/utils/credits.ts +++ b/src/utils/credits.ts @@ -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", diff --git a/src/utils/entitlement.ts b/src/utils/entitlement.ts index d596509..ff131dc 100644 --- a/src/utils/entitlement.ts +++ b/src/utils/entitlement.ts @@ -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 diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 00bcf74..2b890ea 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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); } diff --git a/src/utils/nativeExport.ts b/src/utils/nativeExport.ts index 4310c14..62d8507 100644 --- a/src/utils/nativeExport.ts +++ b/src/utils/nativeExport.ts @@ -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, }; diff --git a/src/utils/paramDefs.ts b/src/utils/paramDefs.ts index 0d16150..464fa79 100644 --- a/src/utils/paramDefs.ts +++ b/src/utils/paramDefs.ts @@ -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, diff --git a/src/utils/proUnlock.ts b/src/utils/proUnlock.ts new file mode 100644 index 0000000..778b55a --- /dev/null +++ b/src/utils/proUnlock.ts @@ -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) => { + 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) => { + 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' }); + } +}