1e1ead6f54
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.
159 lines
7.4 KiB
Markdown
159 lines
7.4 KiB
Markdown
# 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.**
|
||
|
||
```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://<host>:8090/...`). Android
|
||
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
|
||
|
||
```xml
|
||
<!-- 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:
|
||
|
||
```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".
|