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
+52 -7
View File
@@ -16,6 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail';
import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import LibraryPickerModal from './src/components/LibraryPickerModal';
import QrScanModal from './src/components/QrScanModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
@@ -277,6 +278,9 @@ export default function App() {
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
// it is paused while it is up (one lens, two camera libraries).
const [scanVisible, setScanVisible] = useState(false);
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
// the viewer anymore — the thumbnail button does.
const [settingsVisible, setSettingsVisible] = useState(false);
@@ -1028,6 +1032,18 @@ export default function App() {
}
};
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
// picked off storage or fetched from a scanned link — becomes a normal custom
// recipe, and the look on screen switches to it.
const storeImportedRecipe = async (xml: string) => {
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
};
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
// storage, decoded and stored as a normal custom recipe.
const handleImportRecipe = async () => {
@@ -1038,13 +1054,30 @@ export default function App() {
try {
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
if (picked.canceled) return;
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
};
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
// photo's own `.recipe` file, so a scan is the same import with the file
// fetched instead of picked. The code holds a URL, never the recipe, so
// anything that is not the preset route — a shop link, a wifi code — is
// refused with the reason. The sheet closes either way; a failure is a
// dialog, not a scanner left running.
const handleScanRecipe = async (value: string) => {
setScanVisible(false);
if (liteSaveBlocked) {
promptRecipeLimit();
return;
}
try {
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
@@ -1821,6 +1854,9 @@ export default function App() {
photoStraighten={shown.photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
// One lens, two camera libraries: while the QR sheet is up its own
// scanner owns the camera and this session has to let go.
paused={scanVisible}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
@@ -1939,6 +1975,7 @@ export default function App() {
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onImportRecipe={handleImportRecipe}
onScanRecipe={() => setScanVisible(true)}
recipeLimitReached={liteSaveBlocked}
onRecipeLimit={promptRecipeLimit}
onDeleteRecipe={handleDeleteRecipe}
@@ -1965,6 +2002,14 @@ export default function App() {
onSelect={handleLibraryPicked}
/>
{/* QR sheet: reads a recipe link off another screen (the landing page's
card) and imports the file it points at. */}
<QrScanModal
visible={scanVisible}
onCancel={() => setScanVisible(false)}
onScanned={handleScanRecipe}
/>
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}