From 358f9b0f2f9337a8ce03620b3f262c4d591ba77e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 8 Sep 2026 10:42:08 +0700 Subject: [PATCH] fix: fresh export URI per save (stale thumbnail), lock spot-meter + quick EV slider, honor photo GPS over device fallback --- App.tsx | 29 +++++++++++++-- src/components/Viewfinder.tsx | 69 ++++++++++++++++++++++++++++++++++- src/utils/exportEngine.ts | 32 ++++++++++++---- src/utils/photoGps.ts | 40 ++++++++++++++++---- 4 files changed, 150 insertions(+), 20 deletions(-) diff --git a/App.tsx b/App.tsx index d57397e..7b81abc 100644 --- a/App.tsx +++ b/App.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native'; +import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform } from 'react-native'; import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; +import * as MediaLibrary from 'expo-media-library'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -214,6 +215,24 @@ export default function App() { const handlePickImage = async () => { if (isProcessing) return; + + // On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us + // a redacted copy (EXIF GPS stripped). That silently kills Tier-1 EXIF GPS + // and the photo's own coordinates (often a different place than where the + // user stands now) get replaced by a device-GPS fallback. Request media + // read + photo-location BEFORE the pick so the copy keeps its true EXIF GPS. + let mediaOk = true; + if (useGeotag && Platform.OS === 'android') { + try { + const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); + mediaOk = perm.granted; + console.error(`[gps] pre-pick media+location permission granted=${perm.granted}`); + } catch (e) { + mediaOk = false; + console.error('[gps] pre-pick media permission error: ' + String(e).slice(0, 120)); + } + } + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, @@ -228,10 +247,13 @@ export default function App() { // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); - // Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback + // Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback. + // Device fallback only when media read was granted (else we cannot know + // whether the photo carries its own GPS — stamping the device's current + // spot over a photo from somewhere else would be wrong). const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag, - allowDeviceFallback: useGeotag, + allowDeviceFallback: useGeotag && mediaOk, }); setGpsInfo(gps); if (permissionDenied) { @@ -382,6 +404,7 @@ export default function App() { cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} + onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })} /> diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 30b909d..2c06190 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -36,6 +36,8 @@ interface ViewfinderProps { cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; photoOutput: CameraPhotoOutput; + // Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV. + onExposureChange?: (ev: number) => void; } const noiseEffect = Skia.RuntimeEffect.Make(` @@ -76,6 +78,7 @@ export default function Viewfinder({ cameraPermissionGranted, onRequestCameraPermission, photoOutput, + onExposureChange, }: ViewfinderProps) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. @@ -236,6 +239,31 @@ export default function Viewfinder({ } }; + // Quick exposure slider — appears while AE/AF is locked. Drag up/down maps + // directly to the camera AE bias in EV stops (same path as the recipe EV + // slider: Viewfinder already syncs adjustments.exposureCompensation to the + // controller via setExposureBias, and captures apply the bias on-device). + const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV + const evTrackTop = vh * 0.24; + const evTrackH = vh * 0.42; + const evPxPerStop = evTrackH / (2 * EV_RANGE); + const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v)); + const evDragRef = useRef<{ y0: number; ev0: number } | null>(null); + const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH; + const onEvSliderGrant = (e: any) => { + evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops }; + }; + const onEvSliderMove = (e: any) => { + const g = evDragRef.current; + if (!g || !onExposureChange) return; + const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter + const ev = clampEv(g.ev0 + dy / evPxPerStop); + onExposureChange(Math.round(ev * 10) / 10); + }; + const onEvSliderRelease = () => { + evDragRef.current = null; + }; + // Instrumentation for device logs (user captures only logcat E level, so // console.log/warn never show up — these are error-level on purpose). const lastPreviewLog = useRef(0); @@ -559,8 +587,11 @@ export default function Viewfinder({ className="absolute" style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }} > - - + + + + + )} {focusPoint && ( @@ -573,6 +604,40 @@ export default function Viewfinder({ AE/AF LOCKED — TAP TO UNLOCK )} + {focusPoint && onExposureChange && ( + <> + {/* EV value readout (left of the slider) */} + + + EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)} + + + {/* Vertical track: drag up = brighter, drag down = darker */} + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onEvSliderGrant} + onResponderMove={onEvSliderMove} + onResponderRelease={onEvSliderRelease} + onResponderTerminate={onEvSliderRelease} + > + + + + + +3 + + + -3 + + + )} {renderCameraOverlays()} ); diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 6566d04..6e524dd 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -60,9 +60,13 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage { return sharpened; } -// 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`; +// Unique filename per export. A fixed path made every export share one URI, and +// RN's cache (keyed by URI) then kept showing the previous photo in the +// thumbnail after a new capture overwrote the file. A fresh URI per export busts +// that cache. Previous export file is deleted after the new one is written and +// gallery-saved, so at most one stale temp file can ever exist. +let lastExportPath: string | null = null; +const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; export async function processAndExportPhoto( sourceUri: string, @@ -299,9 +303,12 @@ export async function processAndExportPhoto( bytes = patchJpegDpi(bytes, options.dpi); } - // 10. Write binary bytes to temporary local file (overwrites previous export) + // 10. Write binary bytes to a unique temporary local file. The unique name + // is load-bearing: a fixed URI would be served from RN's cache and + // the thumbnail/preview would keep showing the previous export. + const exportFile = nextExportFile(); try { - new File(EXPORT_FILE).write(bytes); + new File(exportFile).write(bytes); } catch (e) { console.error('Failed to write export file: ' + e); return null; @@ -313,8 +320,17 @@ export async function processAndExportPhoto( const mediaPermission = await MediaLibrary.requestPermissionsAsync(); if (mediaPermission.granted) { try { - await MediaLibrary.saveToLibraryAsync(EXPORT_FILE); - return { uri: EXPORT_FILE, savedToLibrary: true }; + await MediaLibrary.saveToLibraryAsync(exportFile); + // New file is safely in the gallery — retire the previous export temp. + if (lastExportPath && lastExportPath !== exportFile) { + try { + await FileSystem.deleteAsync(lastExportPath, { idempotent: true }); + } catch (e) { + console.warn('Cleanup of previous export temp failed:', e); + } + } + lastExportPath = exportFile; + return { uri: exportFile, savedToLibrary: true }; } catch (e) { console.warn('Save to gallery failed:', e); } @@ -323,7 +339,7 @@ export async function processAndExportPhoto( } console.error(`[export] gallery-save ${Date.now() - t0}ms`); - return { uri: EXPORT_FILE, savedToLibrary: false }; + return { uri: exportFile, 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 index 50f2ef1..666eef9 100644 --- a/src/utils/photoGps.ts +++ b/src/utils/photoGps.ts @@ -34,25 +34,42 @@ function toMediaLibraryId(assetId: string): string { return m ? m[1] : assetId; } +// ImagePicker's asset.assetId can be null on Android 13+ (system photo picker) +// while its uri is "content://media/external/images/media/" — fall back to +// parsing the id out of the uri so MediaStore lookups still work. +function pickerAssetId(asset: PickedPhotoAsset): string | null { + if (asset.assetId) return asset.assetId; + const uri = asset.uri || ''; + const m = uri.match(/\/media\/(\d+)\s*$/); + return m ? m[1] : null; +} + async function resolveFromMediaLibrary( - assetId: string, + asset: PickedPhotoAsset, 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); + console.error('[gps] tier2 MediaLibrary permission request failed: ' + String(e).slice(0, 120)); return { gps: null, permissionDenied: true }; } if (!perm.granted) { + console.error('[gps] tier2 MediaLibrary permission denied'); return { gps: null, permissionDenied: true }; } + const id = pickerAssetId(asset); + if (!id) { + console.error('[gps] tier2 no assetId to query MediaStore'); + return { gps: null, permissionDenied: false }; + } try { - const info = await getAssetInfoAsync(toMediaLibraryId(assetId)); + const info = await getAssetInfoAsync(toMediaLibraryId(id)); 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; + console.error(`[gps] tier2 MediaStore location lat=${latitude.toFixed(5)} lon=${longitude.toFixed(5)}`); return { gps: { latitude, @@ -65,8 +82,9 @@ async function resolveFromMediaLibrary( permissionDenied: false, }; } + console.error('[gps] tier2 MediaStore asset has no location (photo may have no GPS, or ACCESS_MEDIA_LOCATION read failed silently)'); } catch (e) { - console.warn('MediaLibrary getAssetInfoAsync failed:', e); + console.error('[gps] tier2 getAssetInfoAsync failed: ' + String(e).slice(0, 160)); } return { gps: null, permissionDenied: false }; } @@ -84,22 +102,30 @@ export async function resolvePhotoGPS( if (exifCoord) { const timestamp = parseExifTimestamp(asset.exif) ?? Date.now(); const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined; + console.error(`[gps] tier1 EXIF lat=${exifCoord.latitude.toFixed(5)} lon=${exifCoord.longitude.toFixed(5)}`); return { gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' }, }; } + console.error('[gps] tier1 no EXIF GPS in picker metadata'); // Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION) - if (asset.assetId) { - const { gps, permissionDenied } = await resolveFromMediaLibrary(asset.assetId, useGeotag); + const assetId = pickerAssetId(asset); + if (assetId) { + const { gps, permissionDenied } = await resolveFromMediaLibrary(asset, useGeotag); if (gps) return { gps }; if (permissionDenied) return { gps: null, permissionDenied: true }; + } else { + console.error('[gps] tier2 skipped — no asset id/uri'); } // 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' } }; + if (deviceGps) { + console.error(`[gps] tier3 DEVICE GPS fallback lat=${deviceGps.latitude.toFixed(5)} lon=${deviceGps.longitude.toFixed(5)}`); + return { gps: { ...deviceGps, source: 'device' } }; + } } return { gps: null };