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 };