# KẾ HOẠCH SỬA TOÀN BỘ + TÍNH NĂNG GPS GỐC ẢNH CAMERA OS App: `RecipesCam` (Expo SDK 57 / RN 0.86 / Skia / vision-camera). Nguyên tắc: sửa nhỏ nhất, từng Phase có tiêu chí verify trên thiết bị thật, không thay đổi stack. --- ## 0. TÓM TẮT - **Trục trặc chính**: luồng "Save ảnh đã lọc" và "đọc GPS ảnh cũ" đều chết — gồm 1 lỗi API Skia (`toString` không tồn tại) và **1 lỗi tương thích SDK 57 chưa từng được xử lý**: từ `expo-media-library@57`, toàn bộ hàm legacy (`getAssetInfoAsync`, `saveToLibraryAsync`, ...) import từ `expo-media-library` **chỉ là stub ném lỗi** (xem `node_modules/expo-media-library/src/legacyWarnings.ts`); phải import từ `expo-media-library/legacy` hoặc dùng class API mới (`Asset`, `Query`, `Album`). - **Tính năng mới**: đọc & dùng GPS gốc ghi trong EXIF của ảnh do camera hệ điều hành chụp. Chìa khóa Android: cần quyền `ACCESS_MEDIA_LOCATION` (bật qua plugin option `isAccessMediaLocationEnabled`) — nếu không, Android 10+ **giấu** GPS trong metadata. --- ## 1. PHÁT HIỆN BỔ SUNG (ngoài báo cáo trước) | Lỗi | Bằng chứng | |---|---| | `App.tsx:263 MediaLibrary.getAssetInfoAsync(...)` → **throw ngay** (stub SDK 57) → Tier-2 GPS chưa bao giờ chạy | `legacyWarnings.ts`: `errorOnLegacyMethodUse('getAssetInfoAsync')` | | `exportEngine.ts:183 MediaLibrary.saveToLibraryAsync(...)` → **throw ngay** → kể cả sửa xong B1 (encodeToBase64), save vẫn chết | cùng nguồn: stub `saveToLibraryAsync` | | `exportEngine.ts:181 requestPermissionsAsync` vẫn chạy (hàm mới của module mới) nhưng không đủ | `index.ts` chỉ export mới: `requestPermissionsAsync`, `getPermissionsAsync`, `presentPermissionsPicker`, class `Asset/Query/Album` | | Android 10+: đọc GPS từ MediaStore cần `android.permission.ACCESS_MEDIA_LOCATION` trong manifest, runtime được auto-request khi có trong manifest | plugin `withMediaLibrary.js` option `isAccessMediaLocationEnabled`; `MediaLibraryModule.kt:298-323` | → Kết luận: lỗi B1 + SDK57 phải sửa **cùng nhau**, nếu không ảnh không bao giờ lưu được. --- ## 2. PHASE 1 — SỬA LUỒNG XUẤT & LƯU ẢNH (blocker) File: `src/utils/exportEngine.ts` 1. **B1 — base64 sai** (dòng 173-178): - Bỏ `Skia.Data.fromBytes(jpegBytes).toString()` (SkData chỉ có `dispose`, `.toString()` rơi về `[object Object]`). - Dùng API có sẵn: `const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);` (có trong `Image.d.ts:115` + native `JsiSkImage.h`). - Ghi file: `FileSystem.writeAsStringAsync(tempFileUri, base64, { encoding: EncodingType.Base64 })`. 2. **SDK57 — save**: đổi import `* as MediaLibrary from 'expo-media-library'` → 2 nguồn: - `import { requestPermissionsAsync } from 'expo-media-library';` (hàm mới, request đủ read + auto `ACCESS_MEDIA_LOCATION` nếu manifest có). - `import { saveToLibraryAsync } from 'expo-media-library/legacy';` (hoặc migrate sang `Asset.create(uri)` — chọn legacy để diff nhỏ, đánh dấu `ponytail:` chuyển class API khi bỏ legacy). 3. **Giữ nguyên phần còn lại**: pipeline Skia surface → filter → grain → frame → GPS text đúng hướng, không đụng. Verify: build dev, chọn ảnh → SAVE → mở Gallery thấy file JPEG xem được, watermark đúng. (Không cần thiết bị thật đặc biệt; máy Android bất kỳ.) --- ## 3. PHASE 2 — TÍNH NĂNG: ĐỌC & DÙNG NATIVE GPS ẢNH CHỤP BỞI CAMERA OS ### 3.1 Yêu cầu chức năng - Chọn ảnh (bất kể nguồn: thư viện/DCIM do Camera OS chụp, ứng dụng file...) trong **library mode** → geotag watermark phải ưu tiên **tọa độ GPS gốc nhúng trong ảnh**, kèm địa danh (reverse geocode), thời gian chụp ảnh — KHÔNG lấy GPS thiết bị hiện tại. - GPS thiết bị chỉ còn là **phương án cuối** khi: ảnh không có GPS VÀ user đang bật geotag. - Hiển thị nguồn dữ liệu để QA: watermark line 3 thêm tag `[EXIF]`/`[MEDIA]`/`[DEVICE]` (giữ mặc định tắt nếu muốn sạch UI). ### 3.2 Chuỗi phân giải GPS (thay `resolvePhotoLocation` hiện tại) ``` Tier EXIF (image-picker asset.exif) → có GPSLatitude/Longitude? parse chuẩn rational → dùng luôn (source=exif, timestamp=ảnh) Tier MEDIA (asset.assetId != null) → request read permission (main requestPermissionsAsync) → MediaLibrary.getAssetInfoAsync(assetId) [import legacy] → assetInfo.location → dùng (source=mediaLibrary) Tier DEVICE (chỉ khi useGeotag=true) → getCurrentGPS() → dùng (source=device) Mọi tier: reverse geocode ra locality (uppercase) nếu chưa có; formatCoordinate như cũ ``` Tách hẳn sang util mới `src/utils/photoGps.ts` (không nhét tiếp vào App.tsx), giữ `App.tsx` chỉ gọi. ### 3.3 Thay đổi cụ thể 1. `app.json` plugin `expo-media-library` thêm: `"isAccessMediaLocationEnabled": true` → sinh `android.permission.ACCESS_MEDIA_LOCATION`. 2. `App.tsx:236-280`: xóa logic cũ → `const gps = await resolvePhotoGPS(asset, useGeotag)`. 3. Hardening `parseGPS` (bug B7): nhận dạng 3 dạng EXIF: - số `21.5`, - array số `[21, 30, 0]`, - array rational string `["21/1","30/1","0/1"]` → bóc `num/den`. - Trả `null` thay vì `NaN`; chỉ chấp nhận khi `isFinite`; bỏ qua `0,0` (thiếu dữ liệu). 4. GPSInfo mở rộng (`src/types/index.ts`): `source?: 'exif' | 'mediaLibrary' | 'device'; photoTakenAt?: number;` — timestamp mặc định = thời điểm chụp ảnh nếu đọc được EXIF. 5. Watermark (`Viewfinder.tsx:152-170` + `exportEngine.ts:149-159`): hiển thị giờ từ `gpsInfo.timestamp`; thêm tag nguồn (tham số debug). 6. Permission UX: nếu đọc ảnh mà user chưa grant read/location → Alert giải thích + `requestPermissionsAsync()` đúng thời điểm (không hỏi lúc khởi động). ### 3.4 Ma trận kiểm thử trên máy thật | Nền tảng | Ảnh camera OS có GPS | Kỳ vọng | |---|---|---| | Android 10+ | bật Location khi chụp | cho phép `ACCESS_MEDIA_LOCATION` → watermark = đúng nơi chụp `[MEDIA/EXIF]` | | Android 10+ | không bật Location | không watermark hoặc `[DEVICE]` nếu fallback bật | | Android (Photo Picker mới) | có GPS | Picker thường giữ EXIF khi app có location-access; nếu `assetId=null` → nhảy Tier EXIF; thiếu cả 2 → ghi log + fallback | | iOS full access | có GPS | `assetId` có → Tier MEDIA chạy | | iOS limited access | — | `assetId=null`, exif không kèm GPS → fallback device; ghi chú UX "cấp full quyền để đọc vị trí ảnh" | | File/cloud (không qua thư viện OS) | có GPS trong file | tùy chọn Tier EXIF binary (xem 3.5) | ### 3.5 (Tùy chọn, sau khi Phase 2 core chạy) EXIF binary fallback Ảnh không qua MediaStore (URI tạm/cloud) mà picker không trả exif: parse GPS IFD trực tiếp từ file bằng parser nhỏ (`exif-reader` — đã ghi trong PROJECT_OVERVIEW, chưa cài) hoặc tự đọc 2 IFD. Chỉ thêm khi có nhu cầu thật (YAGNI). --- ## 4. PHASE 3 — BUGS STATE/UI CÒN LẠI | # | Sửa | File | |---|---|---| | B3 | **Quyết định rõ số phận 3 slider**: (a) map `contrast` vào ma trận (scale offset, nhân sau base) + highlight/shadow giữ nguyên hoặc (b) ẩn slider khỏi UI để không bán tính năng ảo. Đề xuất (a) contrast; highlight/shadow triển khai bằng `ComponentTransfer` hoặc ẩn tạm — chốt với người dùng | `src/utils/colorUtils.ts`, `src/components/AdjustmentPanel.tsx` | | B3b | Monochrome: đưa bước Exposure **ra trước** early-return mono (WB/tint/chrome vẫn skip), để Exposure slider có tác dụng trên ACROS | `colorUtils.ts:113-115` | | B4 | (Quyết định) Denoise/Clarity hiện chỉ áp lúc export. Tối thiểu: đổi nhãn slider thành "chỉ áp khi SAVE"; tối đa: preview song song bằng ImageFilter Skia. Đề xuất Phase 3 làm nhãn + ghi chú, preview để Phase 4 | `AdjustmentPanel.tsx` | | B5 | Tuần tự hoá init: `restoreSession()` chạy trước, chỉ `applyRecipe(list[0])` khi **không** có session hợp lệ; bỏ `loadRecipes` tự apply mù | `App.tsx:60-122` | | B8 | Tách side-effect khỏi updater: tính `next` trước, rồi `setAdjustments(next)` + `setSelectedRecipe({...})` độc lập | `App.tsx:138-149` | | B6/B9 | (đã gộp vào Phase 1/2) | | | B10 | Nút DONE giữ ý nghĩa đóng; nếu media permission bị từ chối → hiện trạng thái "chưa lưu vào thư viện" + nút LƯU LẠI | `PreviewModal.tsx` | | B11 | Xoá file tạm cũ trước khi ghi mới (cache dir 1 file `camrecipe_pro_export_*.jpg` thôi) | `exportEngine.ts` | | B12 | Restore URI không tồn tại → tự chuyển về `mode='camera'` + reset `libraryImageUri` | `App.tsx:89-114` | | B13 | Dọn quyền thừa: `expo-image-picker` plugin thêm `microphonePermission: false`; bỏ `RECORD_AUDIO/READ_MEDIA_AUDIO` khỏi app.json | `app.json` | | B14 | Kelvin 10000K: chốt có giữ `r=1.55` (kẹt kênh) hay giảm hệ số — quyết định thẩm mỹ, mặc định giữ nguyên | `colorUtils.ts` | | Share | Chuyển `Share.share` file → `expo-sharing` (`shareAsync(uri, {mimeType})`) | `PreviewModal.tsx` | | Typecheck | TS 6.0.3 trong package-lock làm `tsc` crash (`RangeError`); hạ `typescript` về `~5.9` để `npx tsc --noEmit` dùng được | `package.json` | --- ## 5. PHASE 4 — CAMERA MODE (cần chốt sản phẩm) Hiện shutter camera mode chỉ Alert. Hai hướng: - **(A) Implement chụp thật** bằng vision-camera: `device.takePhoto()` (frame processor đang chạy → photo raw chưa filter) → chạy cùng `processAndExportPhoto()` trên file ảnh vừa chụp → lưu gallery. GPS khi chụp: dùng `getCurrentGPS()` (vision-camera không ghi EXIF GPS) — hoặc chấp nhận ảnh tự chụp không có GPS gốc. - **(B) Khóa UX hiện tại**: ẩn/đổi nút shutter thành hướng dẫn sang LIBRARY. Khuyến nghị (A) nhưng xếp sau Phase 1-3 vì cần thiết bị thật để verify ổn định. --- ## 6. QA TỔNG THỂ (sau mỗi Phase) ```bash cd /home/locpham/RecipesCam npx tsc --noEmit # sau khi hạ TS ~5.9 (Phase 3) npx expo run:android # máy thật USB (không có emulator image trong máy này) ``` Checklist thủ công: 1. Library → chọn ảnh bất kỳ → kéo Exposure/Sat/WB → SAVE → Gallery mở file đẹp, đúng filter + frame + watermark (Phase 1). 2. Dùng **Camera OS chụp ảnh mới với Location ON** → chọn trong app → watermark hiện đúng nơi chụp + `[EXIF/MEDIA]` (Phase 2). 3. Lặp lại khi ảnh chụp Location OFF → không watermark / `[DEVICE]` đúng chính sách. 4. Background app giữa lúc chỉnh → mở lại → recipe + slider khôi phục đúng (B5). 5. Chọn ACROS MONO → kéo Exposure → ảnh sáng/tối đổi (B3b). 6. Toggle geotag tắt → không watermark; bật lại khi chưa grant location → Alert + fallback đúng. 7. Chụp 2 ảnh liên tiếp → không đè file, không treo (B1/B11). 8. SHARE ảnh → mở được qua app khác (expo-sharing). Rủi ro: (i) GPS Photo Picker Android/iOS limited-access hành vi khác nhau → giữ log + QA ma trận 3.4; (ii) vision-camera trên máy thiếu GPU frame processor cũ vẫn preview được nhưng `takePhoto` cần test riêng; (iii) `expo-media-library/legacy` sẽ bị gỡ ở SDK tương lai → đã đánh dấu `ponytail:`. ## 7. THỨ TỰ THỰC HIỆN Phase 1 → verify → Phase 2 (+app.json/plugin) → verify → Phase 3 → verify → Phase 4 (cần chốt A/B). Mỗi Phase là 1 commit riêng, giữ diff nhỏ.