feat(gps): a Settings GPS switch that reads the location; the watermark chip only prints it

The watermark chip used to be the only GPS control, so turning the watermark
off also stopped the photo from carrying coordinates. Split the two: a GPS
master switch in Settings decides whether the location is read at all, and the
chip decides whether it is drawn on the photo.

0x889d is also written as UTF-8 now — a place name with accents ("TAM KỲ, ĐÀ
NẴNG") was going out latin-1 and reading back as mojibake.
This commit is contained in:
2026-09-11 20:08:59 +07:00
parent ea9ff01a22
commit 40c91d2a75
4 changed files with 71 additions and 13 deletions
+33 -7
View File
@@ -92,6 +92,11 @@ export default function App() {
const [selectedFrame, setSelectedFrame] = useState<FrameId>('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<GPSInfo | null>(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}
/>
+22 -1
View File
@@ -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({
</TouchableOpacity>
</View>
{/* Device GPS: the master switch. The watermark chip only prints it. */}
<View>
<SectionLabel>GPS</SectionLabel>
<ChipRow
chips={[
{ key: 'gps-on', label: 'ON', active: gpsEnabled, onPress: () => onGpsEnabled(true) },
{ key: 'gps-off', label: 'OFF', active: !gpsEnabled, onPress: () => onGpsEnabled(false) },
]}
/>
<View className="flex-row items-center mt-1.5 ml-1">
<MapPin size={11} color="#52525b" strokeWidth={1.75} />
<Text className="text-zinc-600 font-mono text-[10px] ml-1.5">
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.
</Text>
</View>
</View>
{/* Startup mode */}
<View>
<SectionLabel>STARTUP MODE</SectionLabel>
+14 -3
View File
@@ -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 {
+2 -2
View File
@@ -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)