# 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".