android: scan the web recipe card's QR and import the look it points at

The landing page's QR card now carries a link to `GET
/api/photos/:id/preset.recipe`, so the app can pick a look up off a
screen instead of a file. PRESETS gains a SCAN QR chip next to IMPORT:
the code is decoded, checked against the preset route, fetched, and
stored through the same `storeImportedRecipe` path IMPORT already uses,
so an imported look and a scanned look land in one place.

Scanning runs on `expo-camera`, not the viewfinder's vision-camera:
v5's object output is iOS-only, Android's `createObjectOutput` throws.
That is a second camera library in the app, so the sheet pauses the
viewfinder (`paused` prop) — CameraX will not let two clients hold one
lens. A native rebuild is required for the new module.

The code holds a URL, never the recipe, so anything that is not the
preset route is refused by name rather than silently dropped. On a
release APK the plain-HTTP link is blocked by Android's cleartext
policy; debug builds have it on. Noted in 7_SCAN_QR.md along with the
web side, the regex, and the paused-viewfinder rule.
This commit is contained in:
2026-09-18 17:01:34 +07:00
parent e0526283ca
commit 1e1ead6f54
8 changed files with 392 additions and 9 deletions
+10
View File
@@ -116,6 +116,9 @@ interface AdjustmentPanelProps {
// IMPORT: pick a recipe file shared from another phone and store it as a
// normal custom recipe.
onImportRecipe: () => void;
// SCAN QR: read the recipe link on the landing page's card with the camera
// and import the file it points at — same store, no file picker.
onScanRecipe: () => void;
// LITE: this many of its own recipes exist, so the next one is the paid
// step. The strip's trailing + chip reads it, the save paths gate on it.
recipeLimitReached: boolean;
@@ -258,6 +261,7 @@ export default function AdjustmentPanel({
onToggleWallLandscape,
onToggleGeotag,
onImportRecipe,
onScanRecipe,
recipeLimitReached,
onRecipeLimit,
onDeleteRecipe,
@@ -839,6 +843,12 @@ export default function AdjustmentPanel({
active: false,
onPress: onImportRecipe,
},
{
key: 'scan',
label: 'SCAN QR',
active: false,
onPress: onScanRecipe,
},
],
8
);
+82
View File
@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Modal, Text, TouchableOpacity, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
// SCAN QR: reads the code on the landing page's recipe card and hands the
// decoded string to App, which fetches the `.recipe` file it points at. The
// code carries a link, never the recipe itself, so the camera only ever has to
// frame a few dozen bytes.
//
// This is the app's second camera on purpose: `expo-camera` (Android's own
// barcode scanner) instead of the vision-camera session the viewfinder uses,
// because vision-camera v5's object output is iOS-only — Android's
// `createObjectOutput` throws "CameraObjectOutput is not available on
// Android!". App keeps the two from fighting over the lens by pausing the
// viewfinder while this sheet is up (`Viewfinder paused`).
interface QrScanModalProps {
visible: boolean;
onCancel: () => void;
onScanned: (value: string) => void;
}
export default function QrScanModal({ visible, onCancel, onScanned }: QrScanModalProps) {
const [permission, requestPermission] = useCameraPermissions();
// One code per opening: the scanner reports every frame that holds a code, so
// the first one trips this latch and the rest are dropped until the sheet is
// opened again.
const takenRef = useRef(false);
useEffect(() => {
if (!visible) return;
takenRef.current = false;
if (permission && !permission.granted && permission.canAskAgain) requestPermission();
}, [visible, permission, requestPermission]);
const handleScanned = useCallback(
(result: BarcodeScanningResult) => {
if (takenRef.current) return;
takenRef.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="fade" statusBarTranslucent onRequestClose={onCancel}>
<View className="flex-1 bg-black">
{permission?.granted ? (
<CameraView
style={{ flex: 1 }}
facing="back"
active={visible}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleScanned}
/>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-xs text-center leading-5">
CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE
</Text>
</View>
)}
{/* Reticle. Pointer-transparent so the touch goes to the camera. */}
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<View className="w-64 h-64 rounded-2xl border-2 border-amber-500/80" />
<Text className="mt-6 text-amber-500 font-mono text-xs tracking-widest">
POINT AT A RECIPE QR
</Text>
</View>
<TouchableOpacity
onPress={onCancel}
accessibilityLabel="Close the QR scanner"
className="absolute top-12 right-6 w-10 h-10 rounded-full bg-black/60 border border-zinc-700 items-center justify-center"
>
<X color="#ffffff" size={20} />
</TouchableOpacity>
</View>
</Modal>
);
}
+7 -2
View File
@@ -65,6 +65,10 @@ interface ViewfinderProps {
// a nonce so the panel puts the caret back in its input).
onWmEdit?: () => void;
cameraPermissionGranted: boolean;
// Another camera session wants the lens (the QR scanner's own sheet): the
// live session stops until it is up, exactly as it does for the app going
// to the background — two CameraX clients cannot hold one camera.
paused?: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
@@ -303,6 +307,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
availableHeight,
topInset,
cameraPermissionGranted,
paused = false,
onRequestCameraPermission,
photoOutput,
aspectRatio,
@@ -2871,7 +2876,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive && !ultraWideOn}
isActive={appActive && !ultraWideOn && !paused}
outputs={cameraOutputs}
pixelFormat="yuv"
targetResolution={frameTarget}
@@ -2886,7 +2891,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
turning one on means turning the other off, never both at once. */}
<UltraWidePreview
style={StyleSheet.absoluteFill}
active={appActive && ultraWideOn}
active={appActive && ultraWideOn && !paused}
cover={uwCover}
/>
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts