Files
RecipesCam/src/utils/locationUtils.ts
T
admin 76cf4c6d7e Cut 1.2.3 with a shutter that never waits on GPS
Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a
shot pressed before the camera session binds and replay it, pass the options
argument capturePhotoToFile requires, save through the native path so the
media permission is not needed, and release the Skia surface and bitmap after
every export. Store the JS bundle uncompressed and load the export pipeline on
demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
2026-09-17 10:02:35 +07:00

107 lines
4.1 KiB
TypeScript

import * as Location from 'expo-location';
import { GPSInfo } from '../types';
export async function requestLocationPermissions(): Promise<boolean> {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
return status === 'granted';
} catch (error) {
console.error('Error requesting location permissions:', error);
return false;
}
}
// The OS permission screen takes the window focus for a moment and the camera
// session goes down with it, so a capture that asks for location comes back
// "Camera is closed" — one ask per shot turned a burst into failed frames.
// Check the status (no UI, cheap) and raise the dialog at most once per run.
let locationAsked = false;
async function ensureLocationPermission(): Promise<boolean> {
try {
if ((await Location.getForegroundPermissionsAsync()).granted) return true;
if (locationAsked) return false;
locationAsked = true;
return (await Location.requestForegroundPermissionsAsync()).status === 'granted';
} catch (error) {
console.error('Error requesting location permissions:', error);
return false;
}
}
// Resolve to null when `p` has not settled inside `ms`. The promise itself is
// untouched — the caller only stops waiting on it. Used by the shutter: an
// indoor fix can take 20s+, and the photo must not wait for it.
export function withDeadline<T>(p: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([p, new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))]);
}
export async function getCurrentGPS(): Promise<GPSInfo | null> {
try {
const hasPermission = await ensureLocationPermission();
if (!hasPermission) return null;
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const { latitude, longitude } = location.coords;
let locality = 'UNKNOWN LOCATION';
let country = '';
try {
const geocode = await Location.reverseGeocodeAsync({ latitude, longitude });
if (geocode && geocode.length > 0) {
const address = geocode[0];
// Combine city, region, street or subregion to form a retro looking place string
locality = localityName(address) || 'LOCAL REGION';
country = (address.country || '').toUpperCase();
}
} catch (e) {
console.warn('Reverse geocoding failed, using coordinates only:', e);
}
return {
latitude,
longitude,
locality,
country,
timestamp: location.timestamp,
};
} catch (error) {
console.error('Error fetching GPS info:', error);
return null;
}
}
// The one place-name picker, shared so the camera stamp and a photo's stamp can
// never disagree. A Vietnamese geocoder answers with the commune/ward in
// subregion (post-2025 two-level administration: the commune is the district-
// level name) and the province/city in region, while city and district come
// back null, so the stamp reads "COMMUNE, CITY" instead of a bare city.
export function localityName(address: Location.LocationGeocodedAddress): string | null {
const district = address.subregion || address.district;
const city = address.city || address.region;
// Same name in both slots (one field echoed twice) must not print twice.
const parts = district && district !== city ? [district, city] : [district || city];
const name = parts.filter(Boolean).join(', ');
return name ? name.toUpperCase() : null;
}
export async function reverseGeocode(latitude: number, longitude: number): Promise<string | null> {
// Foreground permission is not optional here: expo-location's Android
// geocoder throws LocationUnauthorizedException without it. A photo picked
// before the camera ever asked for location therefore resolved to no name at
// all and the stamp fell back to "STREET VIEW" — so make sure it is held
// before every call (same once-per-run ask as getCurrentGPS).
await ensureLocationPermission();
try {
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
if (geo && geo.length > 0) return localityName(geo[0]);
} catch (e) {
console.warn('Reverse geocode failed:', e);
}
return null;
}