Ship HDF EFFECT to PRO only, and clean the build for Play

Rename LIGHT GLOW to HDF EFFECT and gate it behind PRO: LITE renders the
chip greyed out with a PRO tag and cannot open its slider; restoration of
older settings that carry hdf > 0 still blocks export in LITE.

Play/licence pass:
- drop unused permissions (ACTIVITY_RECOGNITION, READ_MEDIA_AUDIO/VIDEO,
  SYSTEM_ALERT_WINDOW) and requestLegacyExternalStorage
- rename the Leica sims to LC STREETLIFE CLASSIC/VIVID, drop the brand from
  comments; fix app name text, canvaskit copyright, LICENSE owner
- remove expo-image-picker and expo-sensors (no JS import), delete dead
  exports (computeMatrixTint, cinemaSeasonName, base64 helpers,
  formatCoordinate)
- versionCode 3 / versionName 1.2.1, drop the personal email from About
- add docs/privacy-policy.html for the Play listing

Size: R8 + shrinkResources + bundle compression + ABI trim, 161 MB -> 76 MB.
This commit is contained in:
2026-09-15 11:36:52 +07:00
parent 42756ac7c3
commit 27a9dfb65f
25 changed files with 177 additions and 172 deletions
+6
View File
@@ -168,6 +168,7 @@ interface ParamDef {
step: number;
display: (v: number) => string;
onChange: (v: number) => void;
pro?: boolean; // PRO-only: the LITE build cannot open this row
}
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@@ -420,6 +421,10 @@ export default function AdjustmentPanel({
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
active: openParam === p.key,
amberValue: openParam !== p.key && p.value !== p.default,
// 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,
onPress: () => toggleParam(p.key),
}));
@@ -465,6 +470,7 @@ export default function AdjustmentPanel({
label: d.label,
value: d.get(a),
default: d.defaultValue,
pro: d.pro,
min: d.min,
max: d.max,
step: d.step,
+2 -12
View File
@@ -8,8 +8,8 @@ import { CREDITS, LICENSE_TEXTS, CreditEntry } from '../utils/credits';
// each project row links to its homepage, each license group expands to the
// full license text embedded in the app.
const APP_NAME = 'recipescamera';
const APP_VERSION = '1.2'; // keep in sync with app.json
const APP_NAME = 'RecipesCam';
const APP_VERSION = '1.2.1'; // keep in sync with app.json
function Group({
license,
@@ -134,16 +134,6 @@ export default function CreditsModal({
</Text>
<View className="flex-row flex-wrap items-center mt-1">
<Text className="text-zinc-400 font-mono text-[10px]">Author: Lộc Phạm</Text>
<TouchableOpacity
onPress={() => Linking.openURL('mailto:tranloclqd@gmail.com')}
accessibilityLabel="Email author"
className="flex-row items-center ml-3"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Text className="text-amber-500/90 font-mono text-[10px] underline">
tranloclqd@gmail.com
</Text>
</TouchableOpacity>
</View>
<Text className="text-zinc-600 font-mono text-[10px] mt-1">
© 2026 Lộc Phạm · Built with React Native + Expo; icons by Lucide;
+2 -2
View File
@@ -72,13 +72,13 @@ export default function PhotoViewerModal({
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(photo.uri, {
mimeType: 'image/jpeg',
dialogTitle: `Share with CamRecipe Pro - Preset: ${photo.recipeName}`,
dialogTitle: `Share with RecipesCam - Preset: ${photo.recipeName}`,
UTI: 'public.jpeg',
});
} else {
await Share.share({
url: photo.uri,
message: `Captured with CamRecipe Pro - Preset: ${photo.recipeName}`,
message: `Captured with RecipesCam - Preset: ${photo.recipeName}`,
});
}
} catch (error) {
+1 -1
View File
@@ -343,7 +343,7 @@ export default function RecipeCreateModal({
value={name}
onChangeText={setName}
selectTextOnFocus
placeholder="Recipe name (e.g. Fuji Retro)"
placeholder="Recipe name (e.g. Retro Film)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
/>
+1 -1
View File
@@ -115,7 +115,7 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
<TextInput
value={newRecipeName}
onChangeText={setNewRecipeName}
placeholder="Recipe name (e.g. Fuji Retro)"
placeholder="Recipe name (e.g. Retro Film)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
autoFocus
+1 -1
View File
@@ -2797,7 +2797,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
// ---- CLARITY + LIGHT GLOW on the LIBRARY preview -----------------------
// ---- CLARITY + HDF EFFECT on the LIBRARY preview -----------------------
// The camera worklet and exportEngine both have these two passes; the three
// library branches below draw the photo declaratively and had neither, so a
// photo opened from the library ignored both knobs. Same maths as the camera:
-4
View File
@@ -118,10 +118,6 @@ const KEYS = [
'fogR', 'fogG', 'fogB', 'fogAmt',
] as const;
export function cinemaSeasonName(season: CinemaSeason | null | undefined): string | null {
return season ? SEASONS[season]?.name ?? null : null;
}
export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null {
if (!season) return null;
const s = SEASONS[season];
+3 -23
View File
@@ -139,8 +139,8 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica') {
// LEITZ STREETLIFE CLASSIC — the Leica character rather than a saturation
// push. What makes a Leica file read as one is the separation BETWEEN the
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
// push. What makes this file read as one is the separation BETWEEN the
// hues, so the deepening lives in the off-diagonal terms, not in a global
// chroma gain:
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
@@ -158,7 +158,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica-vivid') {
// LEITZ STREETLIFE VIVID — the same Leica channel separation pushed into the
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
// VIVID is a brighter read of one look rather than a second, neutral one.
@@ -285,26 +285,6 @@ export function applyExposureGain(matrix: number[], evStops: number): number[] {
return multiplyMatrices(gainMat, matrix);
}
// Compute a tint color + opacity from the matrix for camera overlay preview
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
const apply = (r: number, g: number, b: number, a: number) => {
const row = (offset: number) =>
matrix[offset + 0] * r + matrix[offset + 1] * g + matrix[offset + 2] * b + matrix[offset + 3] * a + matrix[offset + 4];
return { r: row(0), g: row(5), b: row(10), a: row(15) };
};
// Apply matrix to neutral white to get the filter's color cast
const white = apply(1, 1, 1, 1);
// Clamp and compute perceived intensity to determine opacity
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const intensity = (white.r + white.g + white.b) / 3;
// Opacity is higher when the filter deviates from neutral
const deviation = Math.abs(white.r - intensity) + Math.abs(white.g - intensity) + Math.abs(white.b - intensity);
const opacity = Math.min(0.35, deviation * 2.5);
return { r: clamp(white.r), g: clamp(white.g), b: clamp(white.b), a: opacity };
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
+8 -8
View File
@@ -21,7 +21,7 @@ export const CREDITS: CreditEntry[] = [
name: "canvaskit-wasm",
version: "0.41.0",
license: "BSD-3-Clause",
copyright: "Copyright 2020 Google LLC",
copyright: "Copyright (c) 2011 Google Inc. All rights reserved.",
url: "https://skia.org/",
},
{
@@ -87,6 +87,13 @@ export const CREDITS: CreditEntry[] = [
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
url: "https://docs.expo.dev/versions/latest/sdk/font/",
},
{
name: "expo-document-picker",
version: "57.0.2",
license: "MIT",
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
url: "https://docs.expo.dev/versions/latest/sdk/document-picker/",
},
{
name: "expo-haptics",
version: "57.0.1",
@@ -94,13 +101,6 @@ 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-image-picker",
version: "57.0.4",
license: "MIT",
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
url: "https://docs.expo.dev/versions/latest/sdk/imagepicker/",
},
{
name: "expo-keep-awake",
version: "57.0.1",
+4 -4
View File
@@ -44,19 +44,19 @@ export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// Leica digital look (M/Q/SL) is not a film stock, so it ships as its own
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
// pair instead of one look that has to compromise:
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
// renderers), never a global saturation boost, and a whisker of grain
// keeps it feeling like film rather than digital sharpness.
// VIVID — the same Leica separation opened up for the digital era: lifted
// VIVID — the same channel separation opened up for the digital era: lifted
// exposure, lifted shadows, a little colour. It reads bright and flattering
// straight off the shutter, no editing pass needed.
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
// own adjustments, which every apply path now carries into the panel.
{ id: 'sim-leica', name: 'LEITZ STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
{ id: 'sim-leica-vivid', name: 'LEITZ STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
+9 -3
View File
@@ -16,9 +16,10 @@ export const IS_PRO = IS_PRO_BUILD;
//
// What LITE owns outright: the eight film sims, the bundled recipes, its own
// recipes up to LITE_RECIPE_LIMIT, its own watermark text (in one face), the
// plain frames, one favorite and every parameter. What needs PRO: the three
// printed frames, the GPS stamp, the platform's other fonts, the next recipe
// past the limit, and the second favorite.
// plain frames, one favorite and every parameter except the HDF effect.
// What needs PRO: the three printed frames, the GPS stamp, the HDF effect,
// the platform's other fonts, the next recipe past the limit, and the second
// favorite.
// Frames on the LITE side: the plain export only. The three printed looks are
// PRO (the artwork/card geometry is the paid half of the FRAME tab).
@@ -47,6 +48,7 @@ export const LITE_MARK = {
export interface Look {
frame: FrameId;
gpsWm: boolean;
hdf: number;
}
// The first PRO feature a look is using, or null when it is LITE-clean. One
@@ -54,5 +56,9 @@ export interface Look {
export function proLookInUse(look: Look): string | null {
if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame';
if (look.gpsWm) return 'The GPS watermark';
// A LITE build can no longer reach the HDF row (paramDefs marks it PRO and
// the panel greys the chip out), but a session or recipe stored before the
// gate existed still carries a value: it is caught here, at the render.
if (look.hdf > 0) return 'The HDF effect';
return null;
}
-35
View File
@@ -8,41 +8,6 @@
// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is
// inserted right after SOI.
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function base64ToBytes(b64: string): Uint8Array {
const clean = b64.replace(/\s+/g, '');
const out: number[] = [];
let buffer = 0;
let bits = 0;
for (const ch of clean) {
if (ch === '=') break;
const v = B64.indexOf(ch);
if (v < 0) continue;
buffer = (buffer << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push((buffer >> bits) & 0xff);
}
}
return Uint8Array.from(out);
}
export function bytesToBase64(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i];
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
out += B64[b0 >> 2];
out += B64[((b0 & 3) << 4) | (b1 >> 4)];
out += i + 1 < bytes.length ? B64[((b1 & 15) << 2) | (b2 >> 6)] : '=';
out += i + 2 < bytes.length ? B64[b2 & 63] : '=';
}
return out;
}
function u16(bytes: Uint8Array, p: number): number {
return (bytes[p] << 8) | bytes[p + 1];
}
-9
View File
@@ -97,12 +97,3 @@ export async function reverseGeocode(latitude: number, longitude: number): Promi
return null;
}
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
const absolute = Math.abs(val);
const degrees = Math.floor(absolute);
const minutes = Math.floor((absolute - degrees) * 60);
const seconds = ((absolute - degrees - minutes / 60) * 3600).toFixed(2);
const direction = type === 'lat' ? (val >= 0 ? 'N' : 'S') : (val >= 0 ? 'E' : 'W');
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
}
+5 -1
View File
@@ -14,6 +14,9 @@ export interface ContinuousParamDef {
display: (v: number) => string;
get: (a: ColorAdjustments) => number;
set: (v: number) => Partial<ColorAdjustments>;
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
// cannot open its row, so the value stays at its default there.
pro?: boolean;
}
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
@@ -174,7 +177,8 @@ export const PARAM_DEFS: {
},
{
key: 'hdf',
label: 'LIGHT GLOW',
label: 'HDF EFFECT',
pro: true,
min: 0,
max: 10,
step: 1,
+1 -1
View File
@@ -106,7 +106,7 @@ vec4 main(vec2 xy) {
}
`;
// Bright Pass Filter for the LIGHT GLOW pass (HDF), SkSL over a child image
// Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image
// shader — the pattern TONE_SKSL above already proved on device.
//
// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark