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.
This commit is contained in:
2026-09-18 17:01:34 +07:00
parent e0526283ca
commit 1e1ead6f54
8 changed files with 392 additions and 9 deletions
+158
View File
@@ -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-<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".
+52 -7
View File
@@ -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<number | null>(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. */}
<QrScanModal
visible={scanVisible}
onCancel={() => setScanVisible(false)}
onScanned={handleScanRecipe}
/>
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
+6
View File
@@ -49,6 +49,12 @@
"isAccessMediaLocationEnabled": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
}
],
"expo-sharing"
],
"extra": {
+76
View File
@@ -14,6 +14,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",
@@ -2042,6 +2043,12 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2448,6 +2455,15 @@
"node": "18 || 20 || >=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"
}
}
}
}
+1
View File
@@ -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",
+10
View File
@@ -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
);
+82
View File
@@ -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 (
<Modal visible={visible} animationType="fade" statusBarTranslucent onRequestClose={onCancel}>
<View className="flex-1 bg-black">
{permission?.granted ? (
<CameraView
style={{ flex: 1 }}
facing="back"
active={visible}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleScanned}
/>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-xs text-center leading-5">
CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE
</Text>
</View>
)}
{/* Reticle. Pointer-transparent so the touch goes to the camera. */}
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<View className="w-64 h-64 rounded-2xl border-2 border-amber-500/80" />
<Text className="mt-6 text-amber-500 font-mono text-xs tracking-widest">
POINT AT A RECIPE QR
</Text>
</View>
<TouchableOpacity
onPress={onCancel}
accessibilityLabel="Close the QR scanner"
className="absolute top-12 right-6 w-10 h-10 rounded-full bg-black/60 border border-zinc-700 items-center justify-center"
>
<X color="#ffffff" size={20} />
</TouchableOpacity>
</View>
</Modal>
);
}
+7 -2
View File
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
availableHeight,
topInset,
cameraPermissionGranted,
paused = false,
onRequestCameraPermission,
photoOutput,
aspectRatio,
@@ -2871,7 +2876,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
turning one on means turning the other off, never both at once. */}
<UltraWidePreview
style={StyleSheet.absoluteFill}
active={appActive && ultraWideOn}
active={appActive && ultraWideOn && !paused}
cover={uwCover}
/>
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts