diff --git a/App.tsx b/App.tsx index 31a7295..f44ed40 100644 --- a/App.tsx +++ b/App.tsx @@ -92,6 +92,11 @@ export default function App() { const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); + // GPS master switch (SETTINGS -> GPS): may the app read the device's + // location at all? ON resolves a fix at capture and reverse-geocodes it; + // OFF reads nothing. The GPS WATERMARK chip is separate — it only decides + // whether a resolved location is printed on the photo. + const [gpsEnabled, setGpsEnabled] = 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 @@ -283,7 +288,7 @@ export default function App() { // skips GPS loading entirely when coordinates exist — so turning the chip on // showed "STREET VIEW". Resolve the name whenever the mark needs one. useEffect(() => { - if (!useGeotag || !gpsInfo || gpsInfo.locality) return; + if (!gpsEnabled || !gpsInfo || gpsInfo.locality) return; let stale = false; reverseGeocode(gpsInfo.latitude, gpsInfo.longitude).then((name) => { if (!stale && name) setGpsInfo((g) => (g && !g.locality ? { ...g, locality: name } : g)); @@ -291,7 +296,7 @@ export default function App() { return () => { stale = true; }; - }, [useGeotag, gpsInfo]); + }, [gpsEnabled, gpsInfo]); // Save/restore session on app lifecycle changes useEffect(() => { @@ -344,6 +349,7 @@ export default function App() { 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); + if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled); } } catch (e) { console.warn('Failed to read camera settings:', e); @@ -609,6 +615,23 @@ export default function App() { } }; + // SETTINGS -> GPS. ON asks for the permission once and resolves a fix + // now, so the coordinates are already there when the shutter fires. + const handleToggleGps = async (enabled: boolean) => { + if (!enabled) { + setGpsEnabled(false); + // Only the device fix goes: a picked photo keeps its own EXIF location. + setGpsInfo((g) => (g && g.source === 'device' ? null : g)); + return; + } + if (!(await requestLocationPermissions())) { + Alert.alert('Permission Denied', 'Enable location services to use GPS.'); + return; + } + setGpsEnabled(true); + loadGPS(); + }; + const handleToggleGeotag = async (enabled: boolean) => { setUseGeotag(enabled); if (selectedRecipe) { @@ -620,6 +643,7 @@ export default function App() { if (enabled && !gpsInfo) { const locationGranted = await requestLocationPermissions(); if (locationGranted) { + setGpsEnabled(true); loadGPS(); } else { Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.'); @@ -652,9 +676,9 @@ export default function App() { if (!settingsHydratedRef.current) return; AsyncStorage.setItem( CAMERA_SETTINGS_KEY, - JSON.stringify({ shutterSound, meterMode, rawEnabled }) + JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled }) ).catch(() => {}); - }, [shutterSound, meterMode, rawEnabled]); + }, [shutterSound, meterMode, rawEnabled, gpsEnabled]); // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. @@ -819,7 +843,7 @@ export default function App() { // a redacted copy (EXIF GPS stripped), which silently kills the photo's own // coordinates. Request media read + photo-location BEFORE the pick so the // copy keeps its true EXIF GPS. - if (useGeotag && Platform.OS === 'android') { + if (gpsEnabled && Platform.OS === 'android') { try { await MediaLibrary.requestPermissionsAsync(false, ['photo']); } catch (e) { @@ -861,7 +885,7 @@ export default function App() { // Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own // location — that is where the phone is now, not where the photo was // taken, and stamping it just printed the wrong place name on the mark. - const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag }); + const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); @@ -926,7 +950,7 @@ export default function App() { // 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) { + if (gpsEnabled && !captureGps) { const deviceGps = await getCurrentGPS(); if (deviceGps) { captureGps = { ...deviceGps, source: 'device' as const }; @@ -1435,6 +1459,8 @@ export default function App() { onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} + gpsEnabled={gpsEnabled} + onGpsEnabled={handleToggleGps} startupMode={startupMode} onStartupMode={handleStartupMode} /> diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index aa325c6..6aa791a 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { View, Text, Modal, TouchableOpacity } from 'react-native'; import * as Haptics from 'expo-haptics'; -import { X, Volume2, Info, ChevronRight, Settings as SettingsIcon } from 'lucide-react-native'; +import { X, Volume2, Info, ChevronRight, MapPin, Settings as SettingsIcon } from 'lucide-react-native'; import { AspectRatio, MeterMode, ShutterSound, StartupMode } from '../types'; import CreditsModal from './CreditsModal'; @@ -31,6 +31,8 @@ interface SettingsModalProps { onMeterModeChange: (mode: MeterMode) => void; shutterSound: ShutterSound; onShutterSound: (sound: ShutterSound) => void; + gpsEnabled: boolean; + onGpsEnabled: (enabled: boolean) => void; startupMode: StartupMode; onStartupMode: (mode: StartupMode) => void; } @@ -88,6 +90,8 @@ export default function SettingsModal({ onMeterModeChange, shutterSound, onShutterSound, + gpsEnabled, + onGpsEnabled, startupMode, onStartupMode, }: SettingsModalProps) { @@ -121,6 +125,23 @@ export default function SettingsModal({ + {/* Device GPS: the master switch. The watermark chip only prints it. */} + + GPS + onGpsEnabled(true) }, + { key: 'gps-off', label: 'OFF', active: !gpsEnabled, onPress: () => onGpsEnabled(false) }, + ]} + /> + + + + ON: use your location for the photo's coordinates and the GPS watermark. OFF: nothing is read. The watermark chip only decides whether it is printed. + + + + {/* Startup mode */} STARTUP MODE diff --git a/src/utils/exifWrite.ts b/src/utils/exifWrite.ts index 0111320..14818d1 100644 --- a/src/utils/exifWrite.ts +++ b/src/utils/exifWrite.ts @@ -84,10 +84,21 @@ function u32w(b: Uint8Array, p: number, v: number): void { // --- entry builders -------------------------------------------------------- +// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8: +// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the +// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text +// encodes byte-for-byte the same as before. function asciiEntry(tag: number, text: string): Entry { - const data = new Uint8Array(text.length + 1); // NUL-terminated, as EXIF requires - for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff; - return { tag, type: ASCII, count: data.length, data }; + const bytes: number[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) as number; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } + bytes.push(0); + return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) }; } function shortEntry(tag: number, v: number): Entry { diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 87e4a76..26a5a8b 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -717,11 +717,11 @@ export async function processAndExportPhoto( bytes, { dateTime: options?.capturedAt ? new Date(options.capturedAt) : null, - gps: useGeotag && gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, + gps: gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, width: resultImage.width(), height: resultImage.height(), dpi: options?.dpi ?? null, - locationName: useGeotag && gpsInfo ? gpsInfo.locality : null, + locationName: gpsInfo ? gpsInfo.locality : null, device: getDeviceIdentity(), }, await readPhotoBytes(sourceUri)