614ebb170b
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.
125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
import { Platform } from 'react-native';
|
|
import * as MediaLibrary from 'expo-media-library';
|
|
import { getAssetInfoAsync } from 'expo-media-library/legacy';
|
|
import { GPSInfo } from '../types';
|
|
import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps';
|
|
import { reverseGeocode } from './locationUtils';
|
|
|
|
export interface PickedPhotoAsset {
|
|
uri?: string;
|
|
assetId?: string | null;
|
|
exif?: ExifLike | null;
|
|
}
|
|
|
|
export interface PhotoGpsResult {
|
|
gps: GPSInfo | null;
|
|
permissionDenied?: boolean; // true when media-library read permission was refused
|
|
}
|
|
|
|
const localityFor = async (latitude: number, longitude: number): Promise<string | undefined> => {
|
|
try {
|
|
return (await reverseGeocode(latitude, longitude)) || undefined;
|
|
} catch (e) {
|
|
console.warn('Reverse geocode failed for photo GPS:', e);
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
// Android media-library ids are numeric ("12345"); ImagePicker may hand us
|
|
// "content://media/external/images/media/12345" — reduce to the trailing number.
|
|
// iOS ids are opaque (e.g. "PH://...") and must be passed verbatim.
|
|
function toMediaLibraryId(assetId: string): string {
|
|
if (Platform.OS !== 'android') return assetId;
|
|
const m = assetId.match(/(\d+)\s*$/);
|
|
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/<id>" — 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(
|
|
asset: PickedPhotoAsset,
|
|
useGeotag: boolean
|
|
): Promise<{ gps: GPSInfo | null; permissionDenied: boolean }> {
|
|
let perm;
|
|
try {
|
|
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
|
} catch (e) {
|
|
console.error('Media library permission request failed (photo GPS): ' + String(e).slice(0, 120));
|
|
return { gps: null, permissionDenied: true };
|
|
}
|
|
if (!perm.granted) {
|
|
console.error('Media library read permission denied (photo GPS)');
|
|
return { gps: null, permissionDenied: true };
|
|
}
|
|
const id = pickerAssetId(asset);
|
|
if (!id) {
|
|
console.warn('Photo has no media-library asset id — GPS lookup skipped');
|
|
return { gps: null, permissionDenied: false };
|
|
}
|
|
try {
|
|
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;
|
|
return {
|
|
gps: {
|
|
latitude,
|
|
longitude,
|
|
locality,
|
|
country: undefined,
|
|
timestamp: info.creationTime && info.creationTime > 0 ? info.creationTime : Date.now(),
|
|
source: 'mediaLibrary',
|
|
},
|
|
permissionDenied: false,
|
|
};
|
|
}
|
|
console.warn('Media library photo has no location (no GPS, or ACCESS_MEDIA_LOCATION denied silently)');
|
|
} catch (e) {
|
|
console.error('Media library asset lookup failed (photo GPS): ' + String(e).slice(0, 160));
|
|
}
|
|
return { gps: null, permissionDenied: false };
|
|
}
|
|
|
|
// Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION
|
|
// 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(
|
|
asset: PickedPhotoAsset,
|
|
opts: { useGeotag: boolean }
|
|
): Promise<PhotoGpsResult> {
|
|
const { useGeotag } = opts;
|
|
|
|
// Tier 1 — EXIF embedded in the file (needs no extra permission)
|
|
const exifCoord = parseExifGPS(asset.exif);
|
|
if (exifCoord) {
|
|
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
|
|
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
|
|
return {
|
|
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
|
|
};
|
|
}
|
|
console.warn('No EXIF GPS in picker metadata');
|
|
|
|
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
|
|
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.warn('Photo GPS tier-2 skipped — no asset id/uri');
|
|
}
|
|
|
|
console.warn('Photo has no readable location — stamping no GPS mark');
|
|
return { gps: null };
|
|
}
|