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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user