diff --git a/App.tsx b/App.tsx index b0da9d8..a210d96 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useCameraPermissions } from 'expo-camera'; +import { useCameraPermission, type Camera } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; -import * as MediaLibrary from 'expo-media-library'; +import * as FileSystem from 'expo-file-system/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -16,7 +16,8 @@ import PreviewModal from './src/components/PreviewModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; -import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils'; +import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; +import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; // @ts-ignore @@ -51,15 +52,21 @@ export default function App() { const [libraryImageUri, setLibraryImageUri] = useState(null); const [lastPhotoUri, setLastPhotoUri] = useState(null); + const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false); const [previewVisible, setPreviewVisible] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - // Hardware Permissions (expo-camera) - const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + // Hardware Permissions (vision-camera) + const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); + const cameraRef = useRef(null); + + // vision-camera may return a raw absolute path on Android; surface a file:// URI. + const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`); useEffect(() => { - loadRecipes(); - restoreSession(); + // B5: run a single sequenced init — restore the session first, then fall + // back to the first recipe only when no recipe was restored. + initSession(); // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); @@ -86,13 +93,32 @@ export default function App() { return () => subscription.remove(); }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]); - const restoreSession = async () => { + const initSession = async () => { + const list = await getAllRecipes(); + setRecipes(list); + + let session: any = null; try { - const saved = await AsyncStorage.getItem(SESSION_KEY); - if (!saved) return; - const session = JSON.parse(saved); - if (session.libraryImageUri) { - setLibraryImageUri(session.libraryImageUri); + const raw = await AsyncStorage.getItem(SESSION_KEY); + if (raw) session = JSON.parse(raw); + } catch (e) { + console.warn('Failed to read session:', e); + } + + let restoredRecipe: Recipe | null = null; + if (session) { + // B12: the restored library image may no longer exist on disk + let imageUri: string | null = session.libraryImageUri || null; + if (imageUri) { + try { + const info = await FileSystem.getInfoAsync(imageUri); + if (!info.exists) imageUri = null; + } catch { + imageUri = null; + } + } + if (imageUri) { + setLibraryImageUri(imageUri); setMode('library'); } else { setMode(session.mode || 'camera'); @@ -102,21 +128,13 @@ export default function App() { if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.selectedRecipeId) { - const allRecipes = await getAllRecipes(); - const found = allRecipes.find((r: Recipe) => r.id === session.selectedRecipeId); - if (found) { - setSelectedRecipe(found); - } + restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } - } catch (e) { - console.warn('Failed to restore session:', e); } - }; - const loadRecipes = async () => { - const list = await getAllRecipes(); - setRecipes(list); - if (list.length > 0) { + if (restoredRecipe) { + applyRecipe(restoredRecipe); + } else if (list.length > 0) { applyRecipe(list[0]); } }; @@ -124,7 +142,7 @@ export default function App() { const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { - setGpsInfo(info); + setGpsInfo({ ...info, source: 'device' }); } }; @@ -136,16 +154,15 @@ export default function App() { }; const handleUpdateAdjustments = (updates: Partial) => { - setAdjustments((prev) => { - const next = { ...prev, ...updates }; - if (selectedRecipe) { - setSelectedRecipe({ - ...selectedRecipe, - adjustments: next, - }); - } - return next; - }); + // B8: never call a setState inside another setState's updater. + const next = { ...adjustments, ...updates }; + setAdjustments(next); + if (selectedRecipe) { + setSelectedRecipe({ + ...selectedRecipe, + adjustments: next, + }); + } }; const handleUpdateFrame = (frameId: FrameId) => { @@ -202,6 +219,7 @@ export default function App() { }; const handlePickImage = async () => { + if (isProcessing) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, @@ -213,130 +231,129 @@ export default function App() { const asset = result.assets[0]; setLibraryImageUri(asset.uri); setMode('library'); - // Clear old GPS — will be replaced if photo has location data + // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); - // Multi-tier GPS resolution: EXIF → MediaLibrary.getAssetInfoAsync - const resolvedGps = await resolvePhotoLocation(asset); - if (resolvedGps) { - setGpsInfo(resolvedGps); - } else if (useGeotag) { - // Tier 3: photo has no GPS — fall back to device location - const deviceGps = await getCurrentGPS(); - if (deviceGps) { - setGpsInfo(deviceGps); - } + // Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback + const { gps, permissionDenied } = await resolvePhotoGPS(asset, { + useGeotag, + allowDeviceFallback: useGeotag, + }); + setGpsInfo(gps); + if (permissionDenied) { + console.warn('Media library read permission denied — photo GPS unavailable.'); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; - // Multi-tier GPS resolver: EXIF → MediaLibrary → Device GPS (fallback inside handlePickImage) - const resolvePhotoLocation = async (asset: any): Promise => { - // Tier 1: Extract GPS from ImagePicker EXIF - if (asset.exif && (asset.exif.GPSLatitude || asset.exif.GPSLongitude)) { - const lat = typeof asset.exif.GPSLatitude === 'number' - ? asset.exif.GPSLatitude - : parseGPS(asset.exif.GPSLatitudeRef || '', asset.exif.GPSLatitude); - const lon = typeof asset.exif.GPSLongitude === 'number' - ? asset.exif.GPSLongitude - : parseGPS(asset.exif.GPSLongitudeRef || '', asset.exif.GPSLongitude); - if (lat !== 0 || lon !== 0) { - return { - latitude: lat, - longitude: lon, - locality: undefined, - country: undefined, - timestamp: Date.now(), - }; - } - } - - // Tier 2: Use MediaLibrary.getAssetInfoAsync for richer metadata - // On Android, assetId is a content URI like "content://media/external/images/media/12345" - // but MediaLibrary expects just the numeric part "12345" - const rawId = asset.assetId || ''; - const numericId = rawId.replace(/^.*\/media\//, '').replace(/\/.*$/, ''); - if (numericId && /^\d+$/.test(numericId)) { - try { - const assetInfo = await MediaLibrary.getAssetInfoAsync(numericId); - if (assetInfo.location) { - const locality = await reverseGeocode(assetInfo.location.latitude, assetInfo.location.longitude) || undefined; - return { - latitude: assetInfo.location.latitude, - longitude: assetInfo.location.longitude, - locality, - country: undefined, - timestamp: Date.now(), - }; - } - } catch (e) { - console.warn('MediaLibrary getAssetInfoAsync failed:', e); - } - } - - return null; - }; - - // Helper to parse GPS coordinates from EXIF string format - const parseGPS = (ref: string, val: any): number => { - try { - if (Array.isArray(val)) { - const [degrees, minutes, seconds] = val.map(Number); - const decimal = degrees + minutes / 60 + seconds / 3600; - return (ref === 'S' || ref === 'W') ? -decimal : decimal; - } - if (typeof val === 'number') return val; - return 0; - } catch { - return 0; - } - }; + const activeRecipe: Recipe = + selectedRecipe || { + id: 'temp', + name: 'Custom', + baseFilter: 'none', + adjustments, + frameId: selectedFrame, + useGeotag, + }; const handleCapture = async () => { + if (isProcessing) return; + + // Camera mode (Phase 4, option A): real capture through vision-camera. if (mode === 'camera') { - // For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture. - // In this setup, we simulate taking a photo or request library image if running in simulator. - // Let's create a beautiful simulation that pulls a mock asset or lets you select one if in simulator. - Alert.alert( - 'Capture Mode', - 'Camera capture requires native device hardware. For testing on emulators, please use the LIBRARY mode to load a photo and apply filters.', - [{ text: 'OK' }] - ); - } else { - // Library mode capture/export: processes selected image with recipe, overlays, text - if (!libraryImageUri || !selectedRecipe) { - Alert.alert('Error', 'Please select an image from the library first.'); + if (!cameraPermissionGranted) { + Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); + return; + } + if (!cameraRef.current) { + Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } - setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + try { + // A photo taken now is at the device's current spot — resolve device GPS + // fresh instead of reusing GPS left over from a previously loaded photo. + let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null; + if (useGeotag && !captureGps) { + const deviceGps = await getCurrentGPS(); + if (deviceGps) { + captureGps = { ...deviceGps, source: 'device' as const }; + setGpsInfo(captureGps); + } + } - const exportedUri = await processAndExportPhoto( - libraryImageUri, - { - ...selectedRecipe, - adjustments, - frameId: selectedFrame, + const photo = await cameraRef.current.takePhoto({}); + const sourceUri = toUri(photo.path); + + const result = await processAndExportPhoto( + sourceUri, + { ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag }, + selectedFrame, useGeotag, - }, - selectedFrame, - useGeotag, - gpsInfo - ); + captureGps + ); - setIsProcessing(false); - - if (exportedUri) { - setLastPhotoUri(exportedUri); - setPreviewVisible(true); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } else { - Alert.alert('Failed', 'Error occurred while processing image.'); + if (result) { + setLastPhotoUri(result.uri); + setLastSavedToLibrary(result.savedToLibrary); + setPreviewVisible(true); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } else { + Alert.alert('Failed', 'Error occurred while processing the captured photo.'); + } + } catch (error) { + console.error('Camera capture failed:', error); + Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.'); + } finally { + setIsProcessing(false); } + return; } + + // Library mode capture/export: processes selected image with recipe, overlays, text + if (!libraryImageUri || !selectedRecipe) { + Alert.alert('Error', 'Please select an image from the library first.'); + return; + } + + setIsProcessing(true); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + const result = await processAndExportPhoto( + libraryImageUri, + { + ...selectedRecipe, + adjustments, + frameId: selectedFrame, + useGeotag, + }, + selectedFrame, + useGeotag, + gpsInfo + ); + + setIsProcessing(false); + + if (result) { + setLastPhotoUri(result.uri); + setLastSavedToLibrary(result.savedToLibrary); + setPreviewVisible(true); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } else { + Alert.alert('Failed', 'Error occurred while processing image.'); + } + }; + + // Switching back to camera: drop GPS that belongs to a library photo, so the + // live view (and any capture) never stamps a stale location. + const handleSetMode = (next: 'camera' | 'library') => { + if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { + setGpsInfo(null); + } + setMode(next); }; return ( @@ -344,7 +361,7 @@ export default function App() { {/* Top Header */} -
+
{/* Main Viewfinder Frame */} @@ -364,8 +381,9 @@ export default function App() { useGeotag={useGeotag} gpsInfo={gpsInfo} libraryImageUri={libraryImageUri} - cameraPermissionGranted={!!cameraPermission?.granted} - onRequestCameraPermission={requestCameraPermission as any} + cameraPermissionGranted={cameraPermissionGranted} + onRequestCameraPermission={requestCameraPermission} + cameraRef={cameraRef} /> @@ -397,7 +415,7 @@ export default function App() { onPickImage={handlePickImage} onOpenPreview={() => setPreviewVisible(true)} isLibraryMode={mode === 'library'} - onSwitchToCamera={() => setMode('camera')} + onSwitchToCamera={() => handleSetMode('camera')} /> {/* Preview Modal */} @@ -406,6 +424,7 @@ export default function App() { onClose={() => setPreviewVisible(false)} photoUri={lastPhotoUri} recipeName={selectedRecipe?.name || 'Custom'} + savedToLibrary={lastSavedToLibrary} /> {/* Global Processing Loader Overlay */} diff --git a/FIX_PLAN.md b/FIX_PLAN.md new file mode 100644 index 0000000..326d935 --- /dev/null +++ b/FIX_PLAN.md @@ -0,0 +1,144 @@ +# 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ỏ. diff --git a/app.json b/app.json index e58b9e3..4819c34 100644 --- a/app.json +++ b/app.json @@ -19,15 +19,13 @@ "predictiveBackGestureEnabled": false, "permissions": [ "android.permission.CAMERA", - "android.permission.RECORD_AUDIO", "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", "android.permission.READ_MEDIA_IMAGES", - "android.permission.READ_MEDIA_VIDEO", - "android.permission.READ_MEDIA_AUDIO" + "android.permission.READ_MEDIA_VIDEO" ], "package": "com.locphamtran.recipescamera" }, @@ -37,9 +35,10 @@ "plugins": [ "expo-asset", [ - "expo-camera", + "react-native-vision-camera", { - "cameraPermission": "Allow CamRecipe Pro to access your camera to take photos." + "enableFrameProcessors": true, + "cameraPermissionText": "Allow CamRecipe Pro to access your camera." } ], [ @@ -51,15 +50,18 @@ [ "expo-media-library", { - "photosPermission": "Allow CamRecipe Pro to save processed photos to your library." + "photosPermission": "Allow CamRecipe Pro to save processed photos to your library.", + "isAccessMediaLocationEnabled": true } ], [ "expo-image-picker", { - "photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets." + "photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets.", + "microphonePermission": false } - ] + ], + "expo-sharing" ], "extra": { "eas": { diff --git a/package-lock.json b/package-lock.json index 3c45332..00be1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@react-native-community/slider": "5.2.0", "@shopify/react-native-skia": "2.6.2", "babel-preset-expo": "~57.0.0", - "expo": "~57.0.6", + "expo": "~57.0.7", "expo-asset": "~57.0.5", "expo-camera": "~57.0.2", "expo-dev-client": "~57.0.6", @@ -22,20 +22,23 @@ "expo-image-picker": "~57.0.4", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", "nativewind": "^4.2.6", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", - "react-native-reanimated": "4.5.0", + "react-native-reanimated": "^4.5.0", "react-native-safe-area-context": "^5.8.0", + "react-native-vision-camera": "^4.7.3", "react-native-web": "^0.21.2", + "react-native-worklets-core": "^1.6.3", "tailwindcss": "^3.4.19" }, "devDependencies": { "@types/react": "~19.2.2", - "typescript": "~6.0.3" + "typescript": "^5.9.3" } }, "node_modules/@alloc/quick-lru": { @@ -1219,15 +1222,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.5.tgz", - "integrity": "sha512-xhUGgzpFWRghDUH98+Wl4RDakYhTsbyMg6aOYiBjRzPO/THH8tKMw3vlksgFYlU2PkiAdABJN3tNPf5qmvOQhA==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz", + "integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==", "license": "MIT", "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", - "@expo/require-utils": "^57.0.3", + "@expo/require-utils": "^57.0.5", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1422,9 +1425,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.5.tgz", - "integrity": "sha512-KyiYvQ9rwT3X3CFTRP1grSs/z2gizJuKbo7akPCgqACC4XtG/MWYsvbOB2fH6cfeRn8Zv3JNHgRGF4AmCOIzFw==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.6.tgz", + "integrity": "sha512-liXA9axM3aykAdil4qdHOYKmQqTDdXYkAoT3Eny+SQEo3btERJCbOk8VH48/G0sVbXCj82AbSS8s3XNEQNqbDQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1512,9 +1515,9 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.7.tgz", - "integrity": "sha512-VrsRKc+je3bAZp8ocM8fVjRbXAQqYnBLiy3gm10VY0WoNspQwxcE9gllTu2jtMcqf6pNfAxGaSSp9I74SNOEzg==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.8.tgz", + "integrity": "sha512-NQjRuTLvxUnggK57pKTHLtzS8YYtjrQSGYu+CjgWaO8lNlqdoBbvHrxGYtc9tBuOI3xVPaTfuIvjikxX1r+UhQ==", "license": "MIT", "dependencies": { "@expo/config": "~57.0.5", @@ -1524,15 +1527,15 @@ "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.0", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.7", + "expo-modules-autolinking": "~57.0.8", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "node_modules/@expo/require-utils": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.3.tgz", - "integrity": "sha512-ns05X1K8tM+Qtzp6dNloUFOopSdh3J+HC61BtOR8WHhgtPFyX8TKuO2diqZUqVg9K8yfkWug7g8tBS0qRniSTA==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz", + "integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1540,7 +1543,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2167,9 +2170,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz", - "integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.3.tgz", + "integrity": "sha512-XjBe6lT5sK9xhAn0ToFvdwicsaGvzZkax1iviPnL8sFFYHoagnTTn2E4YTFiyM956iqkOkFcgcWCiER0kkShbQ==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -3312,13 +3315,13 @@ } }, "node_modules/expo": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.6.tgz", - "integrity": "sha512-4NKM1ArfRAmmY82Xcw/guGHlTSItD5mzNxbK4Qd3aOPuRAPAkCgJVLC/eqccZCEGS5aJn0tyjHNKE65k2GrcFw==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.7.tgz", + "integrity": "sha512-PJdE0EjoX878OqClmsigVKdT0jCNOAQDRLcvFkeBakxaVyEDhjcQ8baKq2YysYH2g0YNB1rEIpHUiKtluO918A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.8", + "@expo/cli": "^57.0.9", "@expo/config": "~57.0.5", "@expo/config-plugins": "~57.0.5", "@expo/devtools": "~57.0.1", @@ -3327,16 +3330,16 @@ "@expo/local-build-cache-provider": "^57.0.4", "@expo/log-box": "^57.0.1", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.5", + "@expo/metro-config": "~57.0.6", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.3", - "expo-asset": "~57.0.5", - "expo-constants": "~57.0.5", + "expo-asset": "~57.0.6", + "expo-constants": "~57.0.6", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", - "expo-modules-autolinking": "~57.0.7", - "expo-modules-core": "~57.0.5", + "expo-modules-autolinking": "~57.0.8", + "expo-modules-core": "~57.0.6", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -3374,13 +3377,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.5.tgz", - "integrity": "sha512-vRwG+QvoW1u1vd6yY1oQ4f5doNGGpuCQHvzSY25eCNlQjNJtHJyjWcllzn1KdQ8aQc9TMMYedxeLoYZshSI7MA==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz", + "integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.11.3", - "expo-constants": "~57.0.5" + "expo-constants": "~57.0.6" }, "peerDependencies": { "expo": "*", @@ -3409,9 +3412,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.5.tgz", - "integrity": "sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", + "integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.2" @@ -3579,9 +3582,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.7.tgz", - "integrity": "sha512-arYvWy3odY0JxFwjosVeu8OrS/CvYy+Voroe126X/fYlkdXryJZSYbOsmM1ruol3Eo4ji8HKcfO+mvEVKGxUbQ==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.8.tgz", + "integrity": "sha512-YBDgbJHlhhhr3JaKErW6znsIL8zQsh206LaDpWrLHV/WluRwhYfZuF/fhkVN7b/DsRxIu0UeCPExaEPMxlC2Hw==", "license": "MIT", "dependencies": { "@expo/require-utils": "^57.0.3", @@ -3594,9 +3597,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.5.tgz", - "integrity": "sha512-jyx2yAKUO5wJRlRTj74GC8P6NFhXWw/wB42LaPgAco7tjvWujl4O5D8fM7jCE+VFRa3OV/4SZUyp7H0aeuqYdg==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.6.tgz", + "integrity": "sha512-hePwOh2+i+EpWrVnv95sQeQ0OD5PYuEuIhmglVLzQVaVBV3zHcRvLAc1rV8ROqUL6YtAQUXjPkoSx1ly2ZdTuQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -3632,6 +3635,22 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.18.tgz", + "integrity": "sha512-Q09Gab1yn+OLR7sqcHBNyjM/4MMQa290hDKCV2pf2THnTLkLLuUmC0AKgMgxygqU0uuzhOgVqfnE70dePCLq9A==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/plist": "^0.8.1" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", @@ -3653,9 +3672,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.8.tgz", - "integrity": "sha512-RzE6o39O6Ual4nw1LbUyrIMY5VzQlW4CbOyoww/RhKclMRVItnfkLi89cVKQoPFw+5EKW2KKC6LLtA4or7Trwg==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz", + "integrity": "sha512-41z9z68SynNXasZOjuT1si5Sq5OKL6SLf40ZjikbtZgDuvBO8HaUsaDzsJ0c1UZJ3N+vMzYCc1JUIDyRkVBjkA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -3668,12 +3687,12 @@ "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.1", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.5", + "@expo/metro-config": "~57.0.6", "@expo/metro-file-map": "^57.0.1", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", - "@expo/prebuild-config": "^57.0.7", + "@expo/prebuild-config": "^57.0.8", "@expo/require-utils": "^57.0.3", "@expo/router-server": "^57.0.3", "@expo/schema-utils": "^57.0.2", @@ -4758,9 +4777,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4781,9 +4797,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4804,9 +4817,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4827,9 +4837,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6473,9 +6480,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6496,9 +6500,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6519,9 +6520,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6542,9 +6540,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6672,6 +6667,30 @@ "react-native": "*" } }, + "node_modules/react-native-vision-camera": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", + "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==", + "license": "MIT", + "peerDependencies": { + "@shopify/react-native-skia": "*", + "react": "*", + "react-native": "*", + "react-native-reanimated": "*", + "react-native-worklets-core": "*" + }, + "peerDependenciesMeta": { + "@shopify/react-native-skia": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-worklets-core": { + "optional": true + } + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -6731,6 +6750,19 @@ "react-native": "0.83 - 0.86" } }, + "node_modules/react-native-worklets-core": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz", + "integrity": "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA==", + "license": "MIT", + "dependencies": { + "string-hash-64": "^1.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -7262,6 +7294,12 @@ "node": ">= 0.10.0" } }, + "node_modules/string-hash-64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string-hash-64/-/string-hash-64-1.0.3.tgz", + "integrity": "sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7555,9 +7593,9 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index f903d11..efcf640 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@react-native-community/slider": "5.2.0", "@shopify/react-native-skia": "2.6.2", "babel-preset-expo": "~57.0.0", - "expo": "~57.0.6", + "expo": "~57.0.7", "expo-asset": "~57.0.5", "expo-camera": "~57.0.2", "expo-dev-client": "~57.0.6", @@ -17,20 +17,23 @@ "expo-image-picker": "~57.0.4", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", "nativewind": "^4.2.6", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", - "react-native-reanimated": "4.5.0", + "react-native-reanimated": "^4.5.0", "react-native-safe-area-context": "^5.8.0", + "react-native-vision-camera": "^4.7.3", "react-native-web": "^0.21.2", + "react-native-worklets-core": "^1.6.3", "tailwindcss": "^3.4.19" }, "devDependencies": { "@types/react": "~19.2.2", - "typescript": "~6.0.3" + "typescript": "^5.9.3" }, "scripts": { "start": "expo start", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index cdc719e..9542e9c 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -242,16 +242,13 @@ export default function AdjustmentPanel({ case 'filters': return ( - {renderSlider('Highlight Contrast', adjustments.highlight, -10, 10, 1, (val) => - onUpdateAdjustments({ highlight: val }) - )} - {renderSlider('Shadow Contrast', adjustments.shadow, -10, 10, 1, (val) => - onUpdateAdjustments({ shadow: val }) - )} - {renderSlider('Denoise (Blur)', adjustments.denoise, 0, 10, 1, (val) => + + NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY — LIVE PREVIEW UNCHANGED + + {renderSlider('Denoise (Export)', adjustments.denoise, 0, 10, 1, (val) => onUpdateAdjustments({ denoise: val }) )} - {renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) => + {renderSlider('Clarity (Export)', adjustments.clarity, -10, 10, 1, (val) => onUpdateAdjustments({ clarity: val }) )} {renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) => diff --git a/src/components/PreviewModal.tsx b/src/components/PreviewModal.tsx index 2a71f05..8ac1bef 100644 --- a/src/components/PreviewModal.tsx +++ b/src/components/PreviewModal.tsx @@ -1,5 +1,7 @@ -import React from 'react'; -import { View, Text, Modal, Image, TouchableOpacity, Share } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Text, Modal, Image, TouchableOpacity, Share, Alert } from 'react-native'; +import * as Sharing from 'expo-sharing'; +import * as MediaLibrary from 'expo-media-library/legacy'; import { X, Share2, Download } from 'lucide-react-native'; interface PreviewModalProps { @@ -7,6 +9,7 @@ interface PreviewModalProps { onClose: () => void; photoUri: string | null; recipeName: string; + savedToLibrary: boolean; // whether the export was already saved to the gallery } export default function PreviewModal({ @@ -14,32 +17,86 @@ export default function PreviewModal({ onClose, photoUri, recipeName, + savedToLibrary: initialSaved, }: PreviewModalProps) { + const [saved, setSaved] = useState(initialSaved); + + // Modal stays mounted — refresh state whenever a new export is shown. + useEffect(() => { + if (visible) setSaved(initialSaved); + }, [visible, initialSaved]); + const handleShare = async () => { if (!photoUri) return; try { - await Share.share({ - url: photoUri, - message: `Captured with CamRecipe Pro - Preset: ${recipeName}`, - }); + // expo-sharing actually shares the file; React Native's Share.share cannot + // attach local files. Fall back to Share on platforms where it's unavailable. + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(photoUri, { + mimeType: 'image/jpeg', + dialogTitle: `Share with CamRecipe Pro - Preset: ${recipeName}`, + UTI: 'public.jpeg', + }); + } else { + await Share.share({ + url: photoUri, + message: `Captured with CamRecipe Pro - Preset: ${recipeName}`, + }); + } } catch (error) { console.error('Sharing failed:', error); } }; + const handleDone = async () => { + if (!photoUri || saved) { + onClose(); + return; + } + try { + const perm = await MediaLibrary.requestPermissionsAsync(); + if (!perm.granted) { + Alert.alert( + 'Not Saved', + 'Media library access was denied, so this photo was not saved to your gallery. You can still share it from here.' + ); + onClose(); + return; + } + await MediaLibrary.saveToLibraryAsync(photoUri); + setSaved(true); + onClose(); + } catch (error) { + console.error('Save to gallery failed:', error); + Alert.alert('Save Failed', 'Could not save this photo to your gallery.'); + onClose(); + } + }; + + const handleClose = () => { + if (photoUri && !saved) { + Alert.alert( + 'Not Saved to Gallery', + 'This photo lives in the app cache only. Tap DONE to save a copy to your gallery, or SHARE it now.' + ); + return; + } + onClose(); + }; + return ( {/* Top bar */} PRESET: {recipeName.toUpperCase()} @@ -61,25 +118,34 @@ export default function PreviewModal({ {/* Bottom bar */} - - - - SHARE - + + {photoUri && !saved && ( + + NOT SAVED TO GALLERY — TAP DONE TO SAVE + + )} + + + + SHARE + - - - DONE - + + + + {saved ? 'DONE' : 'SAVE & DONE'} + + + diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index e6d436a..aeaedc7 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -1,6 +1,7 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; -import { CameraView } from 'expo-camera'; +import { Camera, useCameraDevice, useSkiaFrameProcessor } from 'react-native-vision-camera'; +import { useSharedValue } from 'react-native-reanimated'; import { Canvas, useImage, @@ -27,6 +28,7 @@ interface ViewfinderProps { libraryImageUri: string | null; cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; + cameraRef?: React.RefObject; } const { width: screenWidth } = Dimensions.get('window'); @@ -40,7 +42,7 @@ const noiseEffect = Skia.RuntimeEffect.Make(` } `); -const CAPTURE_INTERVAL_MS = 500; // 20fps capture +const containerStyle = { width: viewfinderWidth, height: viewfinderHeight }; export default function Viewfinder({ mode, @@ -51,13 +53,10 @@ export default function Viewfinder({ libraryImageUri, cameraPermissionGranted, onRequestCameraPermission, + cameraRef, }: ViewfinderProps) { - const cameraRef = useRef(null); - const [capturedFrameUri, setCapturedFrameUri] = useState(null); - const [isCapturing, setIsCapturing] = useState(false); - - const skiaImage = useImage(capturedFrameUri || libraryImageUri || ''); - + const device = useCameraDevice('back'); + const skiaImage = useImage(libraryImageUri || ''); const customFont = useFont( require('../../assets/CourierPrime-Regular.ttf'), Math.round(viewfinderWidth * 0.032) @@ -67,6 +66,7 @@ export default function Viewfinder({ const colorMatrix = getSkiaColorMatrix( recipe.baseFilter, adjustments.exposure, + adjustments.contrast, adjustments.saturation, adjustments.temperature, adjustments.tint, @@ -74,59 +74,35 @@ export default function Viewfinder({ ); const grainOpacity = adjustments.grain / 20; - // Periodic frame capture for camera live view with full filter effects + const colorMatrixSV = useSharedValue(colorMatrix); + useEffect(() => { - if (mode !== 'camera') { - setCapturedFrameUri(null); - setIsCapturing(false); - return; - } - - const captureFrame = async () => { - if (isCapturing || !cameraRef.current) return; - setIsCapturing(true); - try { - const picture = await cameraRef.current.takePictureAsync({ - quality: 0.4, - base64: false, - skipProcessing: true, - }); - if (picture?.uri) { - setCapturedFrameUri(picture.uri); - } - } catch { - // Camera not ready yet - } finally { - setIsCapturing(false); - } - }; - - // Capture immediately on mount - captureFrame(); - - // Then capture periodically - const intervalId = setInterval(captureFrame, CAPTURE_INTERVAL_MS); - - return () => { - clearInterval(intervalId); - setCapturedFrameUri(null); - }; - }, [mode]); - - // Re-capture when recipe changes for immediate feedback - useEffect(() => { - if (mode !== 'camera' || !cameraRef.current || isCapturing) return; - const timer = setTimeout(captureFrame, 100); - return () => clearTimeout(timer); + colorMatrixSV.value = getSkiaColorMatrix( + recipe.baseFilter, + adjustments.exposure, + adjustments.contrast, + adjustments.saturation, + adjustments.temperature, + adjustments.tint, + adjustments.colorChrome + ); }, [ recipe.baseFilter, adjustments.exposure, + adjustments.contrast, adjustments.saturation, adjustments.temperature, adjustments.tint, adjustments.colorChrome, ]); + const frameProcessor = useSkiaFrameProcessor((frame) => { + 'worklet'; + const paint = Skia.Paint(); + paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value)); + frame.render(paint); + }, [colorMatrixSV]); + const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05; const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06; const bottomBorder = viewfinderHeight * 0.18; @@ -185,6 +161,17 @@ export default function Viewfinder({ const lonStr = formatCoordinate(gpsInfo.longitude, 'lon'); const locationName = gpsInfo.locality || 'STREET VIEW'; const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN'); + // Only tag the provenance when we know it (photos from the OS camera/library); + // old saved sessions without a source keep the original retro caption. + const sourceTag = + gpsInfo.source === 'exif' + ? 'EXIF' + : gpsInfo.source === 'mediaLibrary' + ? 'MEDIA LIB' + : gpsInfo.source === 'device' + ? 'DEVICE GPS' + : null; + const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8'; const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24); const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16; @@ -193,16 +180,45 @@ export default function Viewfinder({ - + ); }; + const renderCameraOverlays = () => ( + <> + + {grainOpacity > 0 && noiseEffect && ( + + + + + + )} + {renderFrameOverlay()} + {renderGPSWatermark()} + + + + + + + + + + + + + LIVE + + + ); + if (mode === 'camera') { if (!cameraPermissionGranted) { return ( @@ -219,75 +235,32 @@ export default function Viewfinder({ ); } - // Show captured and processed frame with Skia - if (capturedFrameUri && skiaImage) { + if (!device) { return ( - - - - - - {grainOpacity > 0 && noiseEffect && ( - - - - - - )} - {renderFrameOverlay()} - {renderGPSWatermark()} - - - {/* Grid overlay */} - - - - - - - - - - {/* Capture indicator */} - - LIVE - + + Camera not available on this device + ); } - // Fallback to CameraView while first frame is capturing return ( - - {renderFrameOverlay()} - {renderGPSWatermark()} - - {/* Grid */} - - - - - - - - - - {/* Loading indicator */} - - CAPTURING... - + + {renderCameraOverlays()} ); } @@ -295,7 +268,7 @@ export default function Viewfinder({ // Library mode return ( {libraryImageUri && skiaImage ? ( diff --git a/src/types/index.ts b/src/types/index.ts index 4adeb68..5150338 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,4 +30,5 @@ export interface GPSInfo { locality?: string; country?: string; timestamp: number; + source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from } diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index f22d1ef..30d2e2e 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -27,6 +27,7 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } export function getSkiaColorMatrix( baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none', exposure: number, // -10 to +10 + contrast: number, // -10 to +10 saturation: number, // -10 to +10 temperature: number, // 2500 to 10000 tint: number, // -10 to +10 @@ -110,34 +111,36 @@ export function getSkiaColorMatrix( } // 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage - if (baseFilter === 'monochrome') { - return matrix; - } - const rgbTemp = kelvinToRGB(temperature); - // Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B) - const tintR = tint > 0 ? (tint / 10) * 0.08 : 0; - const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0; - const tintB = tint > 0 ? (tint / 10) * 0.08 : 0; + if (baseFilter !== 'monochrome') { + const rgbTemp = kelvinToRGB(temperature); + // Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B) + const tintR = tint > 0 ? (tint / 10) * 0.08 : 0; + const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0; + const tintB = tint > 0 ? (tint / 10) * 0.08 : 0; - const wbMat = [ - rgbTemp.r + tintR, 0, 0, 0, 0, - 0, rgbTemp.g + tintG, 0, 0, 0, - 0, 0, rgbTemp.b + tintB, 0, 0, - 0, 0, 0, 1, 0, - ]; - matrix = multiplyMatrices(wbMat, matrix); - - // 6. Exposure adjustment (Simple offset in fifth column and slight scaling) - if (exposure !== 0) { - const scale = 1 + (exposure / 10) * 0.2; // scaling multiplier - const offset = (exposure / 10) * 0.15; // translation offset - const expMat = [ - scale, 0, 0, 0, offset, - 0, scale, 0, 0, offset, - 0, 0, scale, 0, offset, + const wbMat = [ + rgbTemp.r + tintR, 0, 0, 0, 0, + 0, rgbTemp.g + tintG, 0, 0, 0, + 0, 0, rgbTemp.b + tintB, 0, 0, 0, 0, 0, 1, 0, ]; - matrix = multiplyMatrices(expMat, matrix); + matrix = multiplyMatrices(wbMat, matrix); + } + + // 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so + // both keep affecting mono shots. Contrast scales around mid-grey 0.5. + if (exposure !== 0 || contrast !== 0) { + const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x + const expOffset = (exposure / 10) * 0.15; + const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x + const conOffset = 0.5 * (1 - conScale); + const adjMat = [ + expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale, + 0, expScale * conScale, 0, 0, expOffset + conOffset * expScale, + 0, 0, expScale * conScale, 0, expOffset + conOffset * expScale, + 0, 0, 0, 1, 0, + ]; + matrix = multiplyMatrices(adjMat, matrix); } return matrix; diff --git a/src/utils/exifGps.ts b/src/utils/exifGps.ts new file mode 100644 index 0000000..577cb24 --- /dev/null +++ b/src/utils/exifGps.ts @@ -0,0 +1,98 @@ +// Pure EXIF GPS helpers — tolerant of the varied shapes EXIF parsers return: +// numbers (37.7749), arrays ([d, m, s]), rational strings ("37/1", "46/1", "26964/1000") +// and arrays of rational strings. + +export function parseRational(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'string') { + const parts = value.trim().split('/'); + if (parts.length === 2) { + const num = Number(parts[0]); + const den = Number(parts[1]); + if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) return num / den; + } + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function toDecimal(value: unknown, ref: string): number | null { + // value: single decimal (number|string) OR array [deg, min, sec] + if (Array.isArray(value)) { + const d = parseRational(value[0]); + const m = parseRational(value[1]); + const s = value.length > 2 ? parseRational(value[2]) : 0; + if (d === null || m === null || s === null || d < 0 || m < 0 || s < 0) return null; + return d + m / 60 + s / 3600; + } + const n = parseRational(value); + return n === null || n < 0 ? null : n; +} + +export function parseExifCoordinate( + raw: unknown, + ref: unknown +): number | null { + const absolute = toDecimal(raw, String(ref)); + if (absolute === null || absolute === 0) return null; // ignore 0,0 placeholders + const negative = ref === 'S' || ref === 'W'; + return negative ? -absolute : absolute; +} + +export interface ExifLike { + GPSLatitude?: unknown; + GPSLatitudeRef?: unknown; + GPSLongitude?: unknown; + GPSLongitudeRef?: unknown; + GPSDateStamp?: unknown; + GPSTimeStamp?: unknown; + DateTimeOriginal?: unknown; +} + +export function parseExifGPS(exif: ExifLike | null | undefined): { latitude: number; longitude: number } | null { + if (!exif) return null; + const lat = parseExifCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef); + const lon = parseExifCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef); + if (lat === null || lon === null) return null; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; // invalid ranges + if (lat === 0 && lon === 0) return null; + return { latitude: lat, longitude: lon }; +} + +const parseTimeStamp = (value: unknown): [number, number, number] | null => { + if (!Array.isArray(value)) return null; + const h = parseRational(value[0]); + const m = parseRational(value[1]); + const s = value.length > 2 ? parseRational(value[2]) : 0; + if (h === null || m === null || s === null) return null; + if (h > 23 || m > 59 || s > 61) return null; + return [h, m, s]; +}; + +// EXIF stores dates as "2023:01:02 15:04:05" — colons, not dashes. +export function parseExifTimestamp(exif: ExifLike | null | undefined): number | null { + if (!exif) return null; + const raw = typeof exif.DateTimeOriginal === 'string' ? exif.DateTimeOriginal : ''; + let dateText = raw.trim(); + if (!dateText && typeof exif.GPSDateStamp === 'string') { + const gpsDate = (exif.GPSDateStamp as string).trim(); + const gpsTime = parseTimeStamp(exif.GPSTimeStamp); + if (gpsTime) { + dateText = `${gpsDate} ${gpsTime[0]}:${gpsTime[1]}:${gpsTime[2]}`; + } else if (gpsDate) { + dateText = `${gpsDate} 00:00:00`; + } + } + if (!dateText) return null; + // Normalize "2023:1:2 15:4:5" (any of : / - separators, unpadded parts) + // into an ISO string "2023-01-02T15:04:05" before parsing. + const m = dateText.match(/^(\d{4})[:/-](\d{1,2})[:/-](\d{1,2})(?:[ T]+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/); + if (!m) return null; + const two = (v: string) => v.padStart(2, '0'); + const [, y, mo, d, h = '0', mi = '0', s = '0'] = m; + const time = Date.parse(`${y}-${two(mo)}-${two(d)}T${two(h)}:${two(mi)}:${two(s)}`); + return Number.isFinite(time) ? time : null; +} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 7ee4a3a..16e35d0 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -1,19 +1,28 @@ import { Skia, ImageFormat, BlendMode, TileMode } from '@shopify/react-native-skia'; import * as FileSystem from 'expo-file-system/legacy'; -import * as MediaLibrary from 'expo-media-library'; +import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; import { Recipe, GPSInfo, FrameId } from '../types'; import { getSkiaColorMatrix } from './colorUtils'; import { drawFrameOnCanvas } from './frameUtils'; import { formatCoordinate } from './locationUtils'; +export interface ExportResult { + uri: string; + savedToLibrary: boolean; +} + +// Fixed filename: every export overwrites the previous one, so temp files +// never accumulate in the cache directory. +const EXPORT_FILE = `${FileSystem.cacheDirectory}camrecipe_pro_last_export.jpg`; + export async function processAndExportPhoto( sourceUri: string, recipe: Recipe, frameId: FrameId, useGeotag: boolean, gpsInfo: GPSInfo | null -): Promise { +): Promise { try { // 1. Read source image file into Skia const skiaData = await Skia.Data.fromURI(sourceUri); @@ -47,6 +56,7 @@ export async function processAndExportPhoto( const matrix = getSkiaColorMatrix( recipe.baseFilter, adjustments.exposure, + adjustments.contrast, adjustments.saturation, adjustments.temperature, adjustments.tint, @@ -55,9 +65,9 @@ export async function processAndExportPhoto( const colorFilter = Skia.ColorFilter.MakeMatrix(matrix); paint.setColorFilter(colorFilter); - // 4. Highlight & Shadow / Denoise / Clarity Image Filters + // 4. Denoise / Clarity Image Filters (export-only enhancements) let imageFilter = null; - + // Denoise (Subtle blur to smooth noise) if (adjustments.denoise > 0) { const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px @@ -134,12 +144,12 @@ export async function processAndExportPhoto( if (!fontAsset.localUri) { await fontAsset.downloadAsync(); } - + const fontData = await FileSystem.readAsStringAsync(fontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64, }); const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData)); - + if (typeface) { const fontSize = Math.round(width * 0.032); // 3.2% of image width const font = Skia.Font(typeface, fontSize); @@ -150,42 +160,53 @@ export async function processAndExportPhoto( const lonStr = formatCoordinate(gpsInfo.longitude, 'lon'); const locationName = gpsInfo.locality || 'STREET VIEW'; const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN'); + const sourceTag = + gpsInfo.source === 'exif' + ? 'EXIF' + : gpsInfo.source === 'mediaLibrary' + ? 'MEDIA LIB' + : gpsInfo.source === 'device' + ? 'DEVICE GPS' + : null; + const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8'; const yOffset = height - (frameId === 'polaroid' ? height * 0.15 : fontSize * 1.5); const xOffset = frameId === 'polaroid' ? width * 0.08 : fontSize * 1.2; canvas.drawText(`📍 ${locationName}`, xOffset, yOffset - fontSize * 2.2, textPaint, font); canvas.drawText(`${latStr}, ${lonStr}`, xOffset, yOffset - fontSize * 1.1, textPaint, font); - canvas.drawText(`${timestampStr} | SS 1/125 f/2.8`, xOffset, yOffset, textPaint, font); + canvas.drawText(`${timestampStr}${captionSuffix}`, xOffset, yOffset, textPaint, font); } } - // 9. Snapshot & Encode to JPEG + // 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip) const resultImage = surface.makeImageSnapshot(); - const jpegBytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95); + const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95); - if (!jpegBytes) { - console.error('Failed to encode image to JPEG bytes'); + if (!base64) { + console.error('Failed to encode image to JPEG'); return null; } - // 10. Write binary bytes to temporary local file - const tempFileUri = `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; - const base64Bytes = Skia.Data.fromBytes(jpegBytes).toString(); // Convert to base64 - - await FileSystem.writeAsStringAsync(tempFileUri, base64Bytes, { + // 10. Write binary bytes to temporary local file (overwrites previous export) + await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, { encoding: FileSystem.EncodingType.Base64, }); // 11. Request Media Library permission & Save to device gallery const mediaPermission = await MediaLibrary.requestPermissionsAsync(); if (mediaPermission.granted) { - await MediaLibrary.saveToLibraryAsync(tempFileUri); + try { + await MediaLibrary.saveToLibraryAsync(EXPORT_FILE); + return { uri: EXPORT_FILE, savedToLibrary: true }; + } catch (e) { + console.warn('Save to gallery failed:', e); + } } else { console.warn('Media Library permission denied. Image saved to temporary cache only.'); } - return tempFileUri; + return { uri: EXPORT_FILE, savedToLibrary: false }; } catch (error) { console.error('Error during photo processing and export:', error); return null; diff --git a/src/utils/photoGps.ts b/src/utils/photoGps.ts new file mode 100644 index 0000000..50f2ef1 --- /dev/null +++ b/src/utils/photoGps.ts @@ -0,0 +1,106 @@ +import { Platform } from 'react-native'; +import * as MediaLibrary from 'expo-media-library'; +import { getAssetInfoAsync } from 'expo-media-library/legacy'; +import { GPSInfo } from '../types'; +import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps'; +import { getCurrentGPS, reverseGeocode } from './locationUtils'; + +export interface PickedPhotoAsset { + uri?: string; + assetId?: string | null; + exif?: ExifLike | null; +} + +export interface PhotoGpsResult { + gps: GPSInfo | null; + permissionDenied?: boolean; // true when media-library read permission was refused +} + +const localityFor = async (latitude: number, longitude: number): Promise => { + try { + return (await reverseGeocode(latitude, longitude)) || undefined; + } catch (e) { + console.warn('Reverse geocode failed for photo GPS:', e); + return undefined; + } +}; + +// Android media-library ids are numeric ("12345"); ImagePicker may hand us +// "content://media/external/images/media/12345" — reduce to the trailing number. +// iOS ids are opaque (e.g. "PH://...") and must be passed verbatim. +function toMediaLibraryId(assetId: string): string { + if (Platform.OS !== 'android') return assetId; + const m = assetId.match(/(\d+)\s*$/); + return m ? m[1] : assetId; +} + +async function resolveFromMediaLibrary( + assetId: string, + useGeotag: boolean +): Promise<{ gps: GPSInfo | null; permissionDenied: boolean }> { + let perm; + try { + perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); + } catch (e) { + console.warn('MediaLibrary permission request failed:', e); + return { gps: null, permissionDenied: true }; + } + if (!perm.granted) { + return { gps: null, permissionDenied: true }; + } + try { + const info = await getAssetInfoAsync(toMediaLibraryId(assetId)); + if (info.location && Number.isFinite(info.location.latitude) && Number.isFinite(info.location.longitude)) { + const { latitude, longitude } = info.location; + const locality = useGeotag ? await localityFor(latitude, longitude) : undefined; + return { + gps: { + latitude, + longitude, + locality, + country: undefined, + timestamp: info.creationTime && info.creationTime > 0 ? info.creationTime : Date.now(), + source: 'mediaLibrary', + }, + permissionDenied: false, + }; + } + } catch (e) { + console.warn('MediaLibrary getAssetInfoAsync failed:', e); + } + return { gps: null, permissionDenied: false }; +} + +// Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION +// on Android 10+) → device GPS fallback. +export async function resolvePhotoGPS( + asset: PickedPhotoAsset, + opts: { useGeotag: boolean; allowDeviceFallback?: boolean } +): Promise { + const { useGeotag, allowDeviceFallback = true } = opts; + + // Tier 1 — EXIF embedded in the file (needs no extra permission) + const exifCoord = parseExifGPS(asset.exif); + if (exifCoord) { + const timestamp = parseExifTimestamp(asset.exif) ?? Date.now(); + const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined; + return { + gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' }, + }; + } + + // Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION) + if (asset.assetId) { + const { gps, permissionDenied } = await resolveFromMediaLibrary(asset.assetId, useGeotag); + if (gps) return { gps }; + if (permissionDenied) return { gps: null, permissionDenied: true }; + } + + // Tier 3 — device fallback (photo has no location; stamp the device's current spot) + if (allowDeviceFallback) { + const deviceGps = await getCurrentGPS(); + if (deviceGps) return { gps: { ...deviceGps, source: 'device' } }; + } + + return { gps: null }; +}