Files
RecipesCam/7_SCAN_QR.md
T
3dtours 1e1ead6f54 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.
2026-09-18 17:01:34 +07:00

7.4 KiB
Raw Blame History

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-<id>.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.

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.

npx expo install expo-camera     # ~57.0.5

app.json thêm plugin trước expo-sharing:

["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://<host>:8090/...). Android 9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:

<!-- android/app/src/debug/AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... />

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 <QrScanModal>; 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:

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