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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user