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.4 KiB
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 --noEmittrên cây Android crashRangeError: Maximum call stack size exceeded— lỗi có sẵn, phải truyền--stack-size.)- Phía web:
preset-roundtrip.cjs11/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".