fix(gps): stop stamping the phone's current location on a picked photo

The library flow ended in a device-GPS fallback: whenever a photo's own
coordinates could not be read, getCurrentGPS() supplied the phone's present
spot, so the mark named wherever the user was standing instead of where the
photo was taken. Drop that tier entirely -- a photo with no readable location
gets no GPS mark. Also keep the legacy Android picker unconditionally so the
picked file keeps its EXIF GPS whatever the chip state, and prefer the city
over subAdminArea (county/district) as the displayed place name.
This commit is contained in:
2026-09-10 17:08:09 +07:00
parent 423bf533f8
commit 614ebb170b
3 changed files with 26 additions and 39 deletions
+14 -21
View File
@@ -626,17 +626,13 @@ export default function App() {
if (isProcessing) return; if (isProcessing) return;
// On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us // 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 // a redacted copy (EXIF GPS stripped), which silently kills the photo's own
// and the photo's own coordinates (often a different place than where the // coordinates. Request media read + photo-location BEFORE the pick so the
// user stands now) get replaced by a device-GPS fallback. Request media // copy keeps its true EXIF GPS.
// read + photo-location BEFORE the pick so the copy keeps its true EXIF GPS.
let mediaOk = true;
if (useGeotag && Platform.OS === 'android') { if (useGeotag && Platform.OS === 'android') {
try { try {
const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); await MediaLibrary.requestPermissionsAsync(false, ['photo']);
mediaOk = perm.granted;
} catch (e) { } catch (e) {
mediaOk = false;
console.warn('Pre-pick media+photo-location permission error:', e); console.warn('Pre-pick media+photo-location permission error:', e);
} }
} }
@@ -647,13 +643,14 @@ export default function App() {
quality: 1, quality: 1,
exif: true, exif: true,
// Android 13+ photo picker always returns a redacted cache copy with // Android 13+ photo picker always returns a redacted cache copy with
// assetId=null — EXIF GPS stripped, MediaStore id lost → photo GPS is // assetId=null — EXIF GPS stripped, MediaStore id lost. The legacy picker
// unreachable and we'd stamp the device's location instead. The legacy // (ACTION_GET_CONTENT → DocumentsUI) hands back a
// picker (ACTION_GET_CONTENT → DocumentsUI) hands back a
// com.android.providers.media.documents uri, so the MediaStore asset id // com.android.providers.media.documents uri, so the MediaStore asset id
// survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not // survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not
// redacted → tier-1 EXIF / tier-2 MediaStore both work. // redacted → tier-1 EXIF / tier-2 MediaStore both work. Always on: the
legacy: Platform.OS === 'android' && useGeotag, // photo keeps its own EXIF GPS even when the chip is off at pick time, so
// switching the chip on later still stamps the photo's real place.
legacy: Platform.OS === 'android',
}); });
if (!result.canceled && result.assets && result.assets.length > 0) { if (!result.canceled && result.assets && result.assets.length > 0) {
@@ -668,14 +665,10 @@ export default function App() {
// Clear old GPS — will be replaced if the photo (or device) has location data // Clear old GPS — will be replaced if the photo (or device) has location data
setGpsInfo(null); setGpsInfo(null);
// Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback. // Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own
// Device fallback only when media read was granted (else we cannot know // location — that is where the phone is now, not where the photo was
// whether the photo carries its own GPS — stamping the device's current // taken, and stamping it just printed the wrong place name on the mark.
// spot over a photo from somewhere else would be wrong). const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag });
const { gps, permissionDenied } = await resolvePhotoGPS(asset, {
useGeotag,
allowDeviceFallback: useGeotag && mediaOk,
});
setGpsInfo(gps); setGpsInfo(gps);
if (permissionDenied) { if (permissionDenied) {
console.warn('Media library read permission denied — photo GPS unavailable.'); console.warn('Media library read permission denied — photo GPS unavailable.');
+5 -6
View File
@@ -49,13 +49,12 @@ export async function getCurrentGPS(): Promise<GPSInfo | null> {
} }
} }
// The one place-name picker. Order is the camera's original one // The one place-name picker: city first, because "the place" on a stamp means
// (subregion = subAdminArea = district/county first, then city, district, // the town people would name, while subregion is Android's subAdminArea — the
// region) kept verbatim so already-working camera stamps do not shift; only // county/district, i.e. the wrong name for the same coordinates. Shared so the
// the photo path used to drop the district term. Shared so the camera stamp // camera stamp and a photo's stamp can never disagree.
// and a photo's stamp can never disagree about the same coordinates.
export function localityName(address: Location.LocationGeocodedAddress): string | null { export function localityName(address: Location.LocationGeocodedAddress): string | null {
const name = address.subregion || address.city || address.district || address.region; const name = address.city || address.subregion || address.district || address.region;
return name ? name.toUpperCase() : null; return name ? name.toUpperCase() : null;
} }
+7 -12
View File
@@ -3,7 +3,7 @@ import * as MediaLibrary from 'expo-media-library';
import { getAssetInfoAsync } from 'expo-media-library/legacy'; import { getAssetInfoAsync } from 'expo-media-library/legacy';
import { GPSInfo } from '../types'; import { GPSInfo } from '../types';
import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps'; import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps';
import { getCurrentGPS, reverseGeocode } from './locationUtils'; import { reverseGeocode } from './locationUtils';
export interface PickedPhotoAsset { export interface PickedPhotoAsset {
uri?: string; uri?: string;
@@ -89,12 +89,14 @@ async function resolveFromMediaLibrary(
} }
// Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION // Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION
// on Android 10+) → device GPS fallback. // on Android 10+). A photo that carries no location gets NO stamp: the device's
// current spot is not where the photo was taken, and substituting it only ever
// shows the wrong place name.
export async function resolvePhotoGPS( export async function resolvePhotoGPS(
asset: PickedPhotoAsset, asset: PickedPhotoAsset,
opts: { useGeotag: boolean; allowDeviceFallback?: boolean } opts: { useGeotag: boolean }
): Promise<PhotoGpsResult> { ): Promise<PhotoGpsResult> {
const { useGeotag, allowDeviceFallback = true } = opts; const { useGeotag } = opts;
// Tier 1 — EXIF embedded in the file (needs no extra permission) // Tier 1 — EXIF embedded in the file (needs no extra permission)
const exifCoord = parseExifGPS(asset.exif); const exifCoord = parseExifGPS(asset.exif);
@@ -117,13 +119,6 @@ export async function resolvePhotoGPS(
console.warn('Photo GPS tier-2 skipped — no asset id/uri'); console.warn('Photo GPS tier-2 skipped — no asset id/uri');
} }
// Tier 3 — device fallback (photo has no location; stamp the device's current spot) console.warn('Photo has no readable location — stamping no GPS mark');
if (allowDeviceFallback) {
const deviceGps = await getCurrentGPS();
if (deviceGps) {
return { gps: { ...deviceGps, source: 'device' } };
}
}
return { gps: null }; return { gps: null };
} }