import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } 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 MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; import { processAndExportPhotoNative } from './src/utils/nativeExport'; import { PARAM_DEFS } from './src/utils/paramDefs'; // @ts-ignore import './global.css'; const SESSION_KEY = 'saved_session'; const STARTUP_MODE_KEY = 'startup_mode'; // Camera/device settings that survive restarts regardless of startup mode // (the session file only restores the full look under 'last session'). const CAMERA_SETTINGS_KEY = 'camera_settings'; // P5: native Kotlin export path. EXPO_PUBLIC_NATIVE_EXPORT=1 swaps the Skia // render engine for the RecipesCamExport native pipeline (same look contract, // rendering off the JS thread). Default stays legacy until P6 parity/perf gate // decides which engine ships. const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); // Back/front lens the live viewfinder uses; flipping is transient UI state // (not persisted in sessions — startup always opens on the back camera). const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back'); // Imperative handle into the Viewfinder camera controller: flushes the EV to // the hardware AE bias right before each capture (live slider drags never // touch the hardware — that flashed the Xiaomi preview). const viewfinderRef = useRef(null); // Edge-to-edge (app.json/gradle edgeToEdgeEnabled) makes Android ignore the // manifest's adjustResize, so the OS never shrinks the window for the IME and // the bottom deck stays under the keyboard. Track the IME height ourselves and // lift the deck by it (covers the custom-watermark text input). const [keyboardHeight, setKeyboardHeight] = useState(0); // Top edge of the bottom deck (its own onLayout) -> the height left for the // photo. Library mode letterboxes the image above it so no watermark // position hides under an open panel. Null until the first layout pass. const [deckTop, setDeckTop] = useState(null); // Library photo rotation (FRAME tab), 0 or 90deg clockwise; feeds the // preview source and the export alike. const [photoRotation, setPhotoRotation] = useState<0 | 90>(0); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); return () => { show.remove(); hide.remove(); }; }, []); const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom temporary overrides for the sliders const [adjustments, setAdjustments] = useState({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); // Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled + // text + drag position as 0..1 fractions of the photo area. Lives here so the // live Viewfinder overlay, the panel input and every export share one state. const DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 }; const [customWm, setCustomWm] = useState({ ...DEFAULT_CUSTOM_WM }); // WATERMARK tab ROTATE chip: clockwise degrees, applied to the GPS lines and // the custom text in both the preview and the exported file. const [wmRotation, setWmRotation] = useState(0); // GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame / // photo area plus a size multiplier, same space as the custom mark. There is // nothing to accept — the panel being open IS the edit session. const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1 }; const [gpsWm, setGpsWm] = useState({ ...DEFAULT_GPS_WM }); // FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape // (the PNG's own 4000x3117) instead of the default portrait rotation. const [wallLandscape, setWallLandscape] = useState(false); // The mark the EXPORT burns in. The chip above is the live draft (typing + // dragging update it); only ACCEPT in the WATERMARK tab copies the draft in // here — until then the file keeps the previous stamp (or none). const [customWmApplied, setCustomWmApplied] = useState({ ...DEFAULT_CUSTOM_WM, }); // Bumped by a double-tap on the mark: the panel re-focuses the text input. const [wmEditNonce, setWmEditNonce] = useState(0); // Draft edit: toggling the chip OFF also drops any accepted stamp, so a // switched-off watermark can never come back in an export. const handleUpdateCustomWm = (wm: CustomWatermark) => { setCustomWm(wm); if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); }; // Leaving the watermark editor = closing its panel. The mark keeps capturing // image touches ONLY while that panel is open (Viewfinder's wmArmed), so this // is what hands the photo back to tap-to-focus / pinch / double-tap zoom. const closeWatermarkEditor = () => { setOpenTab(null); setOpenParamKey(null); }; // ACCEPT: the draft becomes the mark the export burns in, then the editor // closes so the image stops being a placement surface. const handleAcceptCustomWm = () => { setCustomWmApplied({ ...customWm }); closeWatermarkEditor(); }; // CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark // (none at all when nothing was ever accepted), then close exactly like // ACCEPT — the escape hatch a running session previously lacked. const handleCancelCustomWm = () => { setCustomWm({ ...customWmApplied }); closeWatermarkEditor(); }; // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); const [rawEnabled, setRawEnabled] = useState(false); // Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters // the BRIGHTEST area to protect its detail (never blow whites) and pushes the // shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip // (capture headroom; NOT a full-frame dim), a highlight roll (keep bright // detail), deep shadow crush and raised contrast/clarity. All constants are a // starting approximation — tune after device testing. const [meterMode, setMeterMode] = useState('evaluative'); const METER_HL_EV = -0.5; const METER_HL_HIGHLIGHT = -3; const METER_HL_SHADOW = -4; const METER_HL_CONTRAST = 3; const METER_HL_CLARITY = 3; const clampAdj = (v: number) => Math.max(-10, Math.min(10, v)); const meteringActive = mode === 'camera' && meterMode === 'highlight'; const meteringAdj: Partial | undefined = meteringActive ? { highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT), shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW), contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST), clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY), } : undefined; // Same tonal overlay for the camera EXPORT (the EV dip is not inside — the // hardware AE bias at capture already carries it, evFromCamera skips software // EV, so folding the dip here would darken twice). const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments; const rawFailRef = useRef(false); // Progressive disclosure: a tool chip opens its panel; null = photo hero only. const [openTab, setOpenTab] = useState(null); // The parameter whose slider row is open inside the panel — lifted here (not // local to AdjustmentPanel) because an open row arms drag-anywhere-on-the- // image adjustment in the Viewfinder. Cleared when the panel/tab closes. const [openParamKey, setOpenParamKey] = useState(null); const [libraryImageUri, setLibraryImageUri] = useState(null); // Session photo history (camera captures + library exports). The camera // thumbnail/preview button shows the newest entry; the swipe viewer walks the // whole list. Not persisted across restarts — entries are app-cache files. const [photoHistory, setPhotoHistory] = useState([]); // Index into photoHistory of the photo shown in the swipe viewer; null = closed. const [viewerIndex, setViewerIndex] = useState(null); // Gear sheet (sound / ratio / metering mode). Camera captures never auto-open // the viewer anymore — the thumbnail button does. const [settingsVisible, setSettingsVisible] = useState(false); // Shutter click: only OFF/ON (no volume API on Android). Applied at capture. const [shutterSound, setShutterSound] = useState('on'); // Startup behavior: 'default' = PROVIA neutral at 4:3 camera; 'last' = restore // previous session from AsyncStorage. Default is 'default'. const [startupMode, setStartupMode] = useState('default'); const photoSeq = useRef(0); const [isProcessing, setIsProcessing] = useState(false); // Short shutter-burst lock: guards the capturePhotoToFile calls from // overlapping. Released as soon as the frames are written — NOT after the // render, so a new shot can start while the previous photo is still being // processed in the background. const captureBusyRef = useRef(false); // FIFO background render queue: processAndExportPhoto (Skia render + gallery // save, ~1s) runs OFF the shutter path, one at a time, so the next photo can // be taken immediately. FIFO keeps history order == shutter order. Renders // are heavy on the JS thread but yield between awaits — UI keeps responding. const exportQueueRef = useRef>(Promise.resolve()); const enqueueExport = (task: () => Promise) => { exportQueueRef.current = exportQueueRef.current .catch(() => {}) // a failed render must not stall the queue .then(task) .catch((e) => console.error('Background export failed:', e)); }; // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality', }); // Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as // a live output only while rawEnabled — CameraX cannot bind two ImageCapture // use cases, so attaching both at once would break the session. Attaching a // DNG output while RAW is on gives ~one exposure per capture: the JPEG below // and this DNG are separate shots taken a few tens of ms apart. const rawPhotoOutput = usePhotoOutput({ containerFormat: 'dng', qualityPrioritization: 'quality', }); // 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(() => { // 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 }, []); // P0 spike: native decode/encode benchmark (EXPO_PUBLIC_BENCH=1 builds only). useEffect(() => { if (process.env.EXPO_PUBLIC_BENCH === '1') { import('./src/dev/nativeBenchProbe') .then((m) => m.nativeBenchProbe()) .catch(() => {}); } }, []); // P6 parity probe: same source through legacy Skia and native engines // (EXPO_PUBLIC_PARITY=1 builds only). useEffect(() => { if (process.env.EXPO_PUBLIC_PARITY === '1') { import('./src/dev/parityProbe') .then((m) => m.parityProbe()) .catch((e) => console.error('[PARITY] effect failed', e)); } }, []); // Save/restore session on app lifecycle changes useEffect(() => { const subscription = AppState.addEventListener('change', async (nextAppState) => { if (nextAppState === 'background' || nextAppState === 'inactive') { const session = { mode, libraryImageUri, adjustments, selectedFrame, useGeotag, aspectRatio, meterMode, shutterSound, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, customWm, }; try { await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); } catch (e) { console.warn('Failed to save session:', e); } } }); return () => subscription.remove(); }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, aspectRatio, meterMode, shutterSound]); // Guards the camera-settings persist effect below: until initSession has // read the stored values, changes are just the initial defaults — writing // them would overwrite the saved settings (async restore loses the race // against the mount effect). const settingsHydratedRef = useRef(false); const initSession = async () => { const list = await getAllRecipes(); setRecipes(list); // Device/camera settings restore first: they apply under BOTH startup // modes ('default' resets only the look, not these hardware choices). try { const raw = await AsyncStorage.getItem(CAMERA_SETTINGS_KEY); if (raw) { const s = JSON.parse(raw); if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound); if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode); if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled); } } catch (e) { console.warn('Failed to read camera settings:', e); } settingsHydratedRef.current = true; // Startup mode: 'default' opens camera + PROVIA film sim (neutral // adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous // session from AsyncStorage; with no saved session it falls back to the // same clean default state. let startup: StartupMode = 'default'; try { const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY); if (raw === 'last') startup = 'last'; } catch (e) { console.warn('Failed to read startup mode:', e); } setStartupMode(startup); const applyDefaultStartup = () => { setMode('camera'); setLibraryImageUri(null); setAspectRatio('4:3'); const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); if (provia) applyRecipe(filmSimToRecipe(provia)); // Clean camera start: GPS watermark stays OFF until the user opts in. setUseGeotag(false); }; if (startup === 'default') { applyDefaultStartup(); return; } let session: any = null; try { 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'); } if (session.adjustments) setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); // NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the // session snapshot here — the session file is only written on background, // so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above // persists them on every change and is authoritative for these three. if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.customWm && typeof session.customWm === 'object') { // Restore defensively: a session saved by an older build may miss the // field entirely or hold a malformed shape. const raw = session.customWm as Partial; const clamp01 = (v: unknown, fb: number) => typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : fb; setCustomWm({ enabled: raw.enabled === true, text: typeof raw.text === 'string' ? raw.text : '', x: clamp01(raw.x, 0.5), y: clamp01(raw.y, 0.5), color: typeof raw.color === 'string' ? raw.color : undefined, size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined, font: typeof raw.font === 'string' ? raw.font : null, }); } if (session.selectedRecipeId) { // Film sims are never persisted in the recipe list (sims applied on the // fly) — resolve sim ids via FILM_SIMS, real recipes via the list. const sim = FILM_SIMS.find((f) => f.id === session.selectedRecipeId); restoredRecipe = sim ? filmSimToRecipe(sim) : list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } } if (restoredRecipe) { // Recipe identity restored; the look (adjustments/frame/geotag/ratio) // stays exactly as saved in the session above — applyRecipe would reset // them to the recipe's canonical values and drop the user's tweaks. setSelectedRecipe(restoredRecipe); } else { applyDefaultStartup(); } }; const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { setGpsInfo({ ...info, source: 'device' }); } }; const applyRecipe = (recipe: Recipe) => { setSelectedRecipe(recipe); setAdjustments(recipe.adjustments); setSelectedFrame(recipe.frameId || 'none'); // GPS watermark is the user's own setting (FRAME tab), not part of a // preset look: recipes carry a useGeotag field but selecting one must // never flip the watermark on/off. }; // Picking a film sim swaps the look only: everything the user dialled in on // the LIGHT/WB/FX tabs and the frame they picked stay exactly as they are. // A saved recipe still loads its own stored values (that is what saving it // was for). Same trick as the session restore below: set the recipe without // applyRecipe, which would snap the sliders back to the sim's canonical set. const handleSelectRecipe = (recipe: Recipe) => { if (!FILM_SIMS.some((s) => s.id === recipe.id)) { applyRecipe(recipe); return; } setSelectedRecipe({ ...recipe, adjustments, frameId: selectedFrame }); }; // The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe // copy of it: every slider move rewrites its `adjustments` in place // (handleUpdateAdjustments), so the canonical values only survive in the // film-sim table and in the saved list. const pristineRecipe = (recipe: Recipe): Recipe | null => { const sim = FILM_SIMS.find((f) => f.id === recipe.id); if (sim) return filmSimToRecipe(sim); return recipes.find((r) => r.id === recipe.id) || null; }; // Every edit belongs to the mode and the photo it was made on, so it never // crosses into the next session: the sliders go back to the recipe's own // values, a frame picked for that still is dropped, and the custom mark — // placed against that very photo — is switched back off. const resetEditsToRecipe = () => { const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null; if (base) { applyRecipe(base); } else { setAdjustments({ ...DEFAULT_ADJUSTMENTS }); setSelectedFrame('none'); } setCustomWm({ ...DEFAULT_CUSTOM_WM }); setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); setGpsWm({ ...DEFAULT_GPS_WM }); }; const handleUpdateAdjustments = (updates: Partial) => { // Storm guard, generic across every param: same-value writes (slider // micro-jitter / repeated pointer-move events) still build a fresh // adjustments object and re-render the whole tree — skip them. Supersedes // the old EV-only dedupe; EV flush at capture reads its own ref, so a // skipped identical EV needs no re-send. const keys = Object.keys(updates) as (keyof ColorAdjustments)[]; if (keys.every((k) => updates[k] === adjustments[k])) return; // 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) => { setSelectedFrame(frameId); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, frameId, }); } }; const handleToggleGeotag = async (enabled: boolean) => { setUseGeotag(enabled); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, useGeotag: enabled, }); } if (enabled && !gpsInfo) { const locationGranted = await requestLocationPermissions(); if (locationGranted) { loadGPS(); } else { Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.'); setUseGeotag(false); } } }; const handleAspectRatio = (ratio: AspectRatio) => { setAspectRatio(ratio); }; const handleRawEnabled = (enabled: boolean) => { rawFailRef.current = false; // allow one recovery alert per enable setRawEnabled(enabled); if (enabled) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } }; const handleStartupMode = (mode: StartupMode) => { setStartupMode(mode); AsyncStorage.setItem(STARTUP_MODE_KEY, mode).catch(() => {}); }; // Persist device/camera settings on every change — not just on background — // so they survive a kill/relaunch even under 'default' startup, which only // resets the look (mode/ratio/recipe/geotag), not these. useEffect(() => { if (!settingsHydratedRef.current) return; AsyncStorage.setItem( CAMERA_SETTINGS_KEY, JSON.stringify({ shutterSound, meterMode, rawEnabled }) ).catch(() => {}); }, [shutterSound, meterMode, rawEnabled]); // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. const rawEnabledRef = useRef(rawEnabled); rawEnabledRef.current = rawEnabled; // Attaching the DNG output while RAW is on adds a second ImageCapture use // case — CameraX historically refuses two per camera. If the session errors // in that state, drop RAW (removing the output reconfigures back to the // single working JPEG session) instead of leaving a dead camera. const handleCameraSessionError = useCallback((error: Error) => { console.error('Camera session error:', error); if (rawEnabledRef.current && !rawFailRef.current) { rawFailRef.current = true; setRawEnabled(false); Alert.alert( 'RAW Unavailable', 'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.' ); } }, []); // Save the freshly captured DNG into an album literally named "RAW" (create // the album on first use). Failure never blocks the JPEG pipeline. const saveRawToAlbum = async (uri: string): Promise => { try { const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { console.warn('RAW save: media library permission denied'); return false; } const asset = await MediaLibraryLegacy.createAssetAsync(uri); const albumName = 'RAW'; let album = await MediaLibraryLegacy.getAlbumAsync(albumName); if (album) { await MediaLibraryLegacy.addAssetsToAlbumAsync(asset, album); } else { await MediaLibraryLegacy.createAlbumAsync(albumName, asset); } return true; } catch (e) { console.error('RAW album save failed:', e); return false; } }; const handleSaveRecipe = async (name: string) => { if (!selectedRecipe) return; const newRecipe = await saveCustomRecipe({ name, baseFilter: selectedRecipe.baseFilter, adjustments, frameId: selectedFrame, useGeotag, }); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Alert.alert('Recipe Saved', `"${name}" has been created.`); }; // PRESETS → CREATE quick-input: build a brand-new recipe from the modal's // typed fields, persist it, and apply it (the user sees the look immediately). const handleCreateRecipe = async (recipe: Omit) => { const newRecipe = await saveCustomRecipe(recipe); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; const handleDeleteRecipe = async (id: string) => { await deleteCustomRecipe(id); const list = await getAllRecipes(); setRecipes(list); if (selectedRecipe?.id === id && list.length > 0) { applyRecipe(list[0]); } }; 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; } catch (e) { mediaOk = false; console.warn('Pre-pick media+photo-location permission error:', e); } } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 1, exif: true, // Android 13+ photo picker always returns a redacted cache copy with // assetId=null — EXIF GPS stripped, MediaStore id lost → photo GPS is // unreachable and we'd stamp the device's location instead. The legacy // picker (ACTION_GET_CONTENT → DocumentsUI) hands back a // com.android.providers.media.documents uri, so the MediaStore asset id // survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not // redacted → tier-1 EXIF / tier-2 MediaStore both work. legacy: Platform.OS === 'android' && useGeotag, }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; setLibraryImageUri(asset.uri); setPhotoRotation(0); setMode('library'); // Picking from the camera is the other half of that switch (the camera // button is the way back), so the camera's edits must not land on the // photo: the picked still starts at the recipe's own values too. if (mode !== 'library') resetEditsToRecipe(); // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); // 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 && mediaOk, }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; const activeRecipe: Recipe = selectedRecipe || { id: 'temp', name: 'Custom', baseFilter: 'none', adjustments, frameId: selectedFrame, useGeotag, }; // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; // Watermark handed to the export engines (Skia + native): the ACCEPTED mark // (not the live draft), null while disabled or empty, else trimmed text + // position. Trimming here keeps the stamped text identical to what the // preview drew. const wmForExport = (): { text: string; x: number; y: number; color?: string; size?: number; font?: string | null; } | null => customWmApplied.enabled && customWmApplied.text.trim().length > 0 ? { text: customWmApplied.text.trim(), x: customWmApplied.x, y: customWmApplied.y, color: customWmApplied.color, size: customWmApplied.size, font: customWmApplied.font, } : null; const handleCapture = async () => { if (isProcessing) return; // Camera mode (Phase 4, option A): real capture through vision-camera. if (mode === 'camera') { if (!cameraPermissionGranted) { Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); return; } if (!photoOutput) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } captureBusyRef.current = 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); } } // Hardware AE bias for this shot (best-effort, bounded retries inside). // Applied here — right before the capture — instead of live during EV // slider drags, which re-locked AE on the Xiaomi and flashed preview. try { await viewfinderRef.current?.flushExposureBias(); } catch (e) { console.warn('EV flush at capture failed:', e); } const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const sourceUri = toUri(filePath); // RAW DNG sidecar: fire the second exposure immediately after the JPEG // so the two shots stay a few tens of ms apart, then file it into the // "RAW" album without blocking the JPEG export below. if (rawEnabled && rawPhotoOutput) { try { const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const rawUri = toUri(rawFile.filePath); saveRawToAlbum(rawUri).then((ok) => { if (!ok) { Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.'); } }); } catch (rawError) { console.error('RAW capture failed:', rawError); } } // Snapshot the look at shutter time, then render OFF the shutter path: // processAndExportPhoto (~1s Skia render + gallery save) is enqueued on // the FIFO background queue — the shutter returns in tens of ms and the // next shot can be taken immediately; no full-screen RENDERING overlay. // Finished photos land in history (and the gallery) when their turn // completes; the thumbnail button shows each as it arrives. // P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport — // same queue/pushPhoto/Haptics/Alert behavior, different renderer. const recipeName = activeRecipe.name; const render = () => { const recipeArgs = { ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag, }; const options = { // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias // already applied exposure compensation at capture — don't add 2^EV. // aspect: crop to the on-screen composition ratio (full = keep 4:3 native). dpi: 300, sharpen: true, evFromCamera: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, }; return NATIVE_EXPORT ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) : processAndExportPhoto(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); }; enqueueExport(async () => { try { const result = await render(); if (result) { // No auto preview after a capture — the thumbnail button opens // the swipe viewer. Saved to session history either way. pushPhoto({ uri: result.uri, recipeName, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing the captured photo.'); } } catch (error) { console.error('Camera export failed:', error); 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 { captureBusyRef.current = 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); // Plain library frames export the photo at its own aspect (the ratio band is // a camera-composition tool); polaroid/wall keep their framed ratio crop. const libraryPlainFrame = selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'; const libraryAspect = aspectRatio === 'full' || libraryPlainFrame ? undefined : aspectRatio; const result = NATIVE_EXPORT ? await processAndExportPhotoNative( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo, // Library export keeps the on-screen composition: plain frames export // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, watermark: wmForExport(), watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, } ) : await processAndExportPhoto( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo, // Library export keeps the on-screen composition: plain frames export // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, watermark: wmForExport(), watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, // Pinch/drag inside a polaroid card window or wall-frame opening: // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, photoRotation, } ); setIsProcessing(false); if (result) { pushPhoto({ uri: result.uri, recipeName: activeRecipe.name, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); // Library has no thumbnail button (right button switches back to camera), // so the export result auto-opens the viewer on the newest photo. setViewerIndex(photoHistory.length); 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 === mode) return; if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { setGpsInfo(null); } // The deck panels belong to one mode only (FRAME exists in library alone, // and the WATERMARK panel is the watermark's edit session), so every switch // closes them: no frame chip row over the live preview, and no watermark // placement layer left eating the camera's tap-to-focus. setOpenTab(null); setOpenParamKey(null); resetEditsToRecipe(); // Camera mode has no FRAME picker to undo a frame, so even a recipe that // carries one must not follow the user onto the live camera (and into the // capture, which burns the frame in). if (next === 'camera') setSelectedFrame('none'); setMode(next); }; // Append a finished export (camera capture or library export) to the session // photo history. Returned entry id lets callers delete by id. const pushPhoto = (p: Omit): string => { const id = `photo-${Date.now()}-${photoSeq.current++}`; setPhotoHistory((h) => [...h, { id, ...p }]); return id; }; // Thumbnail (camera mode) opens the viewer on the newest photo. const openLatestViewer = () => { if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1); }; // Viewer trash: delete the photo from the app AND its gallery copy (when it // was saved). Deleting a saved photo goes through Android's per-item system // confirmation (scoped storage — every single delete asks again by design); // if the user cancels THAT dialog the photo is kept in both places. const handleDeletePhoto = async (id: string) => { const idx = photoHistory.findIndex((p) => p.id === id); if (idx < 0) return; const entry = photoHistory[idx]; const remove = () => { const next = photoHistory.filter((p) => p.id !== id); setPhotoHistory(next); setViewerIndex((prev) => { if (prev === null) return null; if (next.length === 0) return null; if (prev > idx) return prev - 1; if (prev >= next.length) return next.length - 1; return prev; }); }; const deleteCache = async () => { try { await FileSystem.deleteAsync(entry.uri, { idempotent: true }); } catch (e) { console.warn('Photo cache delete failed:', e); } }; if (!entry.savedToLibrary || !entry.assetId) { // Never reached the gallery — nothing for the OS to confirm. remove(); deleteCache(); return; } try { // deleteAssetsAsync shows the OS delete confirmation when the photo is // in the gallery. false = user cancelled it → keep everything. const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]); if (!ok) { Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.'); return; } remove(); deleteCache(); } catch (e) { console.warn('Gallery delete failed:', e); Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.'); } }; // Viewer SAVE & DONE: photo was exported to cache only — copy it into the // gallery now and remember its assetId so a later trash can remove it. const handleSavePhoto = async (id: string) => { const entry = photoHistory.find((p) => p.id === id); if (!entry || entry.savedToLibrary) return; const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.'); return; } try { const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri); setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p))); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (e) { console.error('Save to gallery failed:', e); Alert.alert('Save Failed', 'Could not save this photo to your gallery.'); } }; // Tool-rail toggle: closing the panel (or switching tabs) also drops the open // parameter row — otherwise a stale slider row (and its image-drag target) // would survive into the next panel. const handleToggleTab = (tab: TabId) => { setOpenTab((prev) => (prev === tab ? null : tab)); setOpenParamKey(null); }; // While a panel parameter row is open, a vertical drag anywhere on the live // camera image adjusts that same parameter (the open slider follows). Open // row wins over the locked-AE EV drag; a plain tap still locks focus. const imageAdjustTarget = (() => { if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null; if (!openParamKey) return null; const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters; const def = list.find((d) => d.key === openParamKey); if (!def) return null; return { key: def.key, min: def.min, max: def.max, step: def.step, value: def.get(adjustments), onChange: (v: number) => handleUpdateAdjustments(def.set(v)), }; })(); return ( {/* Fullscreen hero: photo or live preview fills the whole screen */} handleUpdateAdjustments({ exposureCompensation: ev })} meteringEvOffset={meteringActive ? METER_HL_EV : 0} meteringAdjustments={meteringAdj} imageAdjustTarget={imageAdjustTarget} customWm={customWm} wmRotation={wmRotation} gpsWm={gpsWm} onGpsWmChange={(x, y, size) => setGpsWm({ x, y, size })} onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))} wallLandscape={wallLandscape} // The mark only captures image touches while its panel is open, so a // watermark left ON cannot block tap-to-focus or pinch/zoom. wmEditing={openTab === 'watermark'} onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))} onWmEdit={() => { // Double-tap on the mark re-opens its editor (and with it the // placement layer), then focuses the input. setOpenTab('watermark'); setOpenParamKey(null); setWmEditNonce((n) => n + 1); }} /> {/* Top overlay: mode label + recipe name (left), Save current (right) */} {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} setDeckTop(e.nativeEvent.layout.y)} > {openTab !== null && ( setPhotoRotation((r) => (r === 0 ? 90 : 0))} wallLandscape={wallLandscape} onToggleWallLandscape={() => setWallLandscape((v) => !v)} useGeotag={useGeotag} openParam={openParamKey} onOpenParamChange={setOpenParamKey} currentBaseFilter={selectedRecipe?.baseFilter} onCreateRecipe={handleCreateRecipe} onSelectRecipe={handleSelectRecipe} onUpdateAdjustments={handleUpdateAdjustments} onUpdateFrame={handleUpdateFrame} onToggleGeotag={handleToggleGeotag} onSaveRecipe={handleSaveRecipe} onDeleteRecipe={handleDeleteRecipe} customWm={customWm} onUpdateCustomWm={handleUpdateCustomWm} onAcceptCustomWm={handleAcceptCustomWm} onCancelCustomWm={handleCancelCustomWm} wmEditNonce={wmEditNonce} wmRotation={wmRotation} onRotateWm={() => setWmRotation((r) => (r + 90) % 360)} /> )} setSettingsVisible(true)} onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} /> {/* Gear sheet: shutter sound / ratio+RAW / metering mode */} setSettingsVisible(false)} aspectRatio={aspectRatio} onAspectRatio={handleAspectRatio} rawEnabled={rawEnabled} onRawEnabled={handleRawEnabled} cameraMode={mode === 'camera'} meterMode={meterMode} onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} startupMode={startupMode} onStartupMode={handleStartupMode} /> {/* Session photo viewer (swipe across captured/exported photos) */} 0} photos={photoHistory} initialIndex={viewerIndex ?? 0} onClose={() => setViewerIndex(null)} onDelete={handleDeletePhoto} onSave={handleSavePhoto} /> {/* Global Processing Loader Overlay */} {isProcessing && ( RENDERING PHOTO... )} ); }