From 1e1ead6f54c61bb21d6d2f3f9d606f2bc3bdce18 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 17:01:34 +0700 Subject: [PATCH] android: scan the web recipe card's QR and import the look it points at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 7_SCAN_QR.md | 158 +++++++++++++++++++++++++++++ App.tsx | 59 +++++++++-- app.json | 6 ++ package-lock.json | 76 ++++++++++++++ package.json | 1 + src/components/AdjustmentPanel.tsx | 10 ++ src/components/QrScanModal.tsx | 82 +++++++++++++++ src/components/Viewfinder.tsx | 9 +- 8 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 7_SCAN_QR.md create mode 100644 src/components/QrScanModal.tsx diff --git a/7_SCAN_QR.md b/7_SCAN_QR.md new file mode 100644 index 0000000..efe0964 --- /dev/null +++ b/7_SCAN_QR.md @@ -0,0 +1,158 @@ +# 7 — SCAN QR: quét mã trên trang web để import recipe vào app + +Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06. +Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card +hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở +`http://localhost:8090`. + +Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác +các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra +cái gì, và app đọc cái đó bằng cách nào. + +--- + +## 1. Web đã mở ra cái gì (phía `recipes-web`) + +Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`. +Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page. + +Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có, +thẻ QR trên landing trỏ tới một route công khai mới của backend: + +``` +GET /api/photos/:id/preset.recipe +``` + +| Trường hợp | Trả về | +| --- | --- | +| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` | +| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-.recipe"` | + +Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc +payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của +`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã +ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng +envelope này (+12 check, backend 134/134). + +Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải +vài KB base64 — camera chỉ cần bắt được một liên kết ngắn. + +Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`, +`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`. + +--- + +## 2. Vì sao KHÔNG dùng vision-camera để quét + +Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không +được: **vision-camera v5 trên Android không có object output.** + +```text +modules/.../HybridCameraFactory.kt:112 +createObjectOutput → throw "CameraObjectOutput is not available on Android!" +``` + +`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác, +nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android. + +```bash +npx expo install expo-camera # ~57.0.5 +``` + +`app.json` thêm plugin trước `expo-sharing`: + +```json +["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }] +``` + +**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native +module mới; bundle JS cũ không có nó. + +App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một +ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4. + +### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug + +Route preset hiện phục vụ qua **HTTP thuần** (`http://:8090/...`). Android +9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn: + +```xml + + +``` + +`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là: + +| Bản build | Quét thẻ QR | +| --- | --- | +| dev client / APK debug | chạy — cleartext được bật | +| APK release | `fetch` bị chặn, `Import failed` | + +Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua +HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng +`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa +cài, nên chỉ thêm khi thật sự cần. + +--- + +## 3. `src/components/QrScanModal.tsx` (mới) + +Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ +`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt +`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để +không import mười lần. Mở lại sheet thì chốt reset. + +Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng +`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú +chạm rơi xuống camera bên dưới. + +--- + +## 4. Nối vào App + +| File | Thay đổi | +| --- | --- | +| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render ``; truyền `paused={scanVisible}` xuống viewfinder | +| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` | +| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` | +| `app.json` | plugin `expo-camera` + câu xin quyền | +| `package.json` | `expo-camera ~57.0.5` | + +`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp +thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` / +`promptRecipeLimit()` đứng trước, giống đường IMPORT. + +Liên kết phải đúng route preset, không thì từ chối kèm lý do: + +```ts +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()); +``` + +Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu +`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt. + +`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import +`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` → +Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và +quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách +custom như recipe import bằng file. + +--- + +## 5. Kiểm thử + +- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15 + lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở + `App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`. + (`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.) +- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng + chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments, + `frameId`, `useGeotag`), và envelope lạ bị từ chối. +- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing → + recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link + RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy → + không có lỗi CameraX "camera already in use". diff --git a/App.tsx b/App.tsx index bf6ec21..1d4f3b3 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 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(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. */} + setScanVisible(false)} + onScanned={handleScanRecipe} + /> + =22" } }, + "node_modules/barcode-detector": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz", + "integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.3" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3380,6 +3396,26 @@ "react-native": "*" } }, + "node_modules/expo-camera": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.5.tgz", + "integrity": "sha512-0JaUXfDWMytmqm4pVKesZfLTSjAumfS2UGhnc1M4tqJ0qATCS0jxc7KjaoyKEzWJY+FEc8GlPCAGprS/7tNWZQ==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "57.0.6", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", @@ -7420,6 +7456,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -7976,6 +8024,34 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz", + "integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.8.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 56ed4b1..3bb4d84 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "babel-preset-expo": "~57.0.0", "expo": "~57.0.7", "expo-asset": "~57.0.5", + "expo-camera": "~57.0.5", "expo-dev-client": "~57.0.6", "expo-document-picker": "~57.0.2", "expo-file-system": "~57.0.1", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index a1d9622..f526143 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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 ); diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx new file mode 100644 index 0000000..bf61fbd --- /dev/null +++ b/src/components/QrScanModal.tsx @@ -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 ( + + + {permission?.granted ? ( + + ) : ( + + + CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE + + + )} + + {/* Reticle. Pointer-transparent so the touch goes to the camera. */} + + + + POINT AT A RECIPE QR + + + + + + + + + ); +} diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 6048686..8552edf 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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(function Viewfi availableHeight, topInset, cameraPermissionGranted, + paused = false, onRequestCameraPermission, photoOutput, aspectRatio, @@ -2871,7 +2876,7 @@ const Viewfinder = forwardRef(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(function Viewfi turning one on means turning the other off, never both at once. */} {/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts