fix: fresh export URI per save (stale thumbnail), lock spot-meter + quick EV slider, honor photo GPS over device fallback

This commit is contained in:
2026-09-08 10:42:08 +07:00
parent 8eca31ce58
commit 358f9b0f2f
4 changed files with 150 additions and 20 deletions
+26 -3
View File
@@ -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 })}
/>
</View>
+67 -2
View File
@@ -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 }}
>
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400" />
<View className="absolute left-1/2 top-1/2 -ml-px -mt-px w-[2px] h-[2px] rounded-full bg-amber-400" />
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400 items-center justify-center">
<View className="w-9 h-9 rounded-full bg-amber-400/15 items-center justify-center">
<Lock size={14} color="#f59e0b" strokeWidth={2.5} />
</View>
</View>
</View>
)}
{focusPoint && (
@@ -573,6 +604,40 @@ export default function Viewfinder({
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
</TouchableOpacity>
)}
{focusPoint && onExposureChange && (
<>
{/* EV value readout (left of the slider) */}
<View pointerEvents="none" className="absolute" style={{ right: 108, top: evThumbY - 12 }}>
<Text className="text-amber-400 font-mono text-xs font-bold bg-black/50 px-1.5 py-0.5 rounded">
EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)}
</Text>
</View>
{/* Vertical track: drag up = brighter, drag down = darker */}
<View
className="absolute"
style={{ left: vw - 56, top: evTrackTop, width: 40, height: evTrackH }}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onEvSliderGrant}
onResponderMove={onEvSliderMove}
onResponderRelease={onEvSliderRelease}
onResponderTerminate={onEvSliderRelease}
>
<View pointerEvents="none" className="absolute left-1/2 top-0 bottom-0 w-[2px] -ml-px bg-white/20 rounded-full" />
<View
pointerEvents="none"
className="absolute w-8 h-8 rounded-full border-2 border-amber-400 bg-black/60"
style={{ top: evThumbY - evTrackTop - 16, left: 4 }}
/>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop - 22 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">+3</Text>
</View>
<View pointerEvents="none" className="absolute" style={{ left: vw - 48, top: evTrackTop + evTrackH + 6 }}>
<Text className="text-zinc-400 font-mono text-[10px] text-center w-9">-3</Text>
</View>
</>
)}
{renderCameraOverlays()}
</View>
);
+24 -8
View File
@@ -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 <Image> 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 <Image> 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;
+33 -7
View File
@@ -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/<id>" — 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 };