Harden native geolocation to prevent app-crash on "Near Me"

The "Near Me crashes the app / bounces to ZapStore" bug is a native
FATAL EXCEPTION on the Android main thread, which no JS try/catch can
intercept. registerPlugin('Geolocation') always returns a proxy even
when the native class isn't actually registered in the APK (e.g. after
an incomplete cap sync or missing play-services-location), and invoking
a method on that ghost proxy is what detonates the crash.

Fix: verify the plugin is genuinely registered via
Capacitor.isPluginAvailable() and the live window.Capacitor.Plugins
registry BEFORE ever calling it. If it's not truly present, fall back to
the WebView's navigator.geolocation instead of crashing. Also guard the
requestPermissions step independently so a permission-flow throw can't
take down the app. isNativeGeolocation() now reflects the path actually
in use so error copy matches; added isNativePlatform() helper.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 22:16:25 +07:00
parent 7aad972099
commit 71e5c5df1f

View file

@ -20,6 +20,23 @@
* IMPORTANT (native build): @capacitor/geolocation must be installed (it's in
* package.json) and synced with `npx cap sync android` so the native plugin is
* bundled into the APK and registered. On web we just use navigator.geolocation.
*
* CRASH HARDENING (the "Near Me closes the app / bounces to ZapStore" bug)
* -----------------------------------------------------------------------
* A native plugin call that throws on the Android MAIN thread produces a
* `FATAL EXCEPTION: main` that kills the process. No JS try/catch or Promise
* wrapper can intercept that — it happens in Java land, off our promise chain.
* `registerPlugin('Geolocation')` ALWAYS returns a JS proxy, even when the
* native class isn't actually registered in the APK (e.g. after a messy plugin
* upgrade / incomplete `cap sync`, or a missing play-services-location dep).
* Invoking a method on such a "ghost" proxy is exactly what crashes the app.
*
* So before we ever touch the native plugin we verify it is GENUINELY present
* by checking `Capacitor.isPluginAvailable('Geolocation')` and the live
* `window.Capacitor.Plugins.Geolocation` registry. If it's not truly there we
* DO NOT call it — we fall back to the WebView's `navigator.geolocation`
* instead of detonating a native crash. This keeps the app alive no matter what
* state the native project is in.
*/
import { Capacitor, registerPlugin } from '@capacitor/core';
@ -55,21 +72,67 @@ interface CapGeolocationPlugin {
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
}
/**
* Verify the Geolocation plugin is GENUINELY registered on the native side —
* not just that `registerPlugin` handed us a proxy (it always does). Calling a
* method on a proxy whose native class is missing is what crashes the app, so
* we must be certain the native implementation exists before we ever call it.
*/
function isNativeGeoAvailable(): boolean {
try {
if (!Capacitor?.isNativePlatform?.()) return false;
// Capacitor's own registry check — returns false if the native plugin
// class wasn't compiled/registered into this APK.
if (typeof Capacitor.isPluginAvailable === 'function') {
if (!Capacitor.isPluginAvailable('Geolocation')) return false;
}
// Belt-and-braces: confirm the live plugin registry actually holds a
// Geolocation entry with the methods we intend to call.
const w = globalThis as unknown as {
Capacitor?: { Plugins?: Record<string, unknown> };
};
const registered = w.Capacitor?.Plugins?.Geolocation as
| { getCurrentPosition?: unknown }
| undefined;
if (!registered || typeof registered.getCurrentPosition !== 'function') {
return false;
}
return true;
} catch {
return false;
}
}
let geoPlugin: CapGeolocationPlugin | null = null;
function getNativeGeo(): CapGeolocationPlugin | null {
try {
if (!Capacitor?.isNativePlatform?.()) return null;
} catch {
return null;
}
if (!isNativeGeoAvailable()) return null;
if (!geoPlugin) {
geoPlugin = registerPlugin<CapGeolocationPlugin>('Geolocation');
try {
geoPlugin = registerPlugin<CapGeolocationPlugin>('Geolocation');
} catch {
return null;
}
}
return geoPlugin;
}
/** True when running inside the native app (so we can tailor error copy). */
/**
* True when running inside the native app AND the Geolocation plugin is
* genuinely registered — i.e. the native permission path is the one in use.
* The UI uses this to tailor error copy ("open device Settings" vs. "browser
* settings"). When the native plugin is a ghost proxy we fall back to the web
* path, so this returns false and the UI shows the browser-appropriate help.
*/
export function isNativeGeolocation(): boolean {
return isNativeGeoAvailable();
}
/** True when running inside the native Capacitor app, regardless of plugins. */
export function isNativePlatform(): boolean {
try {
return Capacitor?.isNativePlatform?.() ?? false;
} catch {
@ -88,55 +151,96 @@ export async function getCurrentPosition(): Promise<GeoCoords> {
const native = getNativeGeo();
if (native) {
// ── Native path ──
try {
// Ask the OS for permission first — this shows the Android prompt.
let status = await native.checkPermissions().catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await native.requestPermissions({ permissions: ['location', 'coarseLocation'] });
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
throw new GeolocationError('denied', 'Location permission denied.');
}
return getNativePosition(native);
}
// First try a precise fix. High accuracy can fail/time out indoors or
// with a weak GPS signal, which shows up as "unavailable" or "timeout".
try {
const pos = await native.getCurrentPosition({
return getWebPosition();
}
/**
* Native path. The plugin has already been verified as genuinely registered by
* `getNativeGeo`, so calling it is safe. Every bridge call is still funneled
* through `safeCall` so a synchronous throw becomes a catchable rejection
* rather than an uncaught error.
*/
async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoords> {
// Wrap EVERY bridge call in Promise.resolve().then(...) so that even a
// SYNCHRONOUS throw from the native proxy becomes a rejected promise we can
// catch here — a sync throw is what can otherwise crash the whole app.
const safeCall = <T>(fn: () => Promise<T>): Promise<T> =>
Promise.resolve().then(fn);
try {
// Ask the OS for permission first — this shows the Android prompt.
// Guard each step independently: if checkPermissions is missing/throws we
// still try requestPermissions, and if that's missing we proceed to the
// position call (which will surface a clean error rather than crash).
let status = await safeCall(() => native.checkPermissions()).catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await safeCall(() =>
native.requestPermissions({ permissions: ['location', 'coarseLocation'] }),
).catch((permErr) => {
console.warn('[geolocation] requestPermissions failed:', permErr);
return null;
});
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
throw new GeolocationError('denied', 'Location permission denied.');
}
// First try a precise fix. High accuracy can fail/time out indoors or
// with a weak GPS signal, which shows up as "unavailable" or "timeout".
try {
const pos = await safeCall(() =>
native.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 30000,
});
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
} catch (highAccErr) {
// Fall back to a coarse (network/cell/wifi) fix with a longer window.
// This rescues the common "GPS can't lock" case that otherwise looks
// like a hard failure.
console.warn('[geolocation] high-accuracy failed, trying coarse:', highAccErr);
const pos = await native.getCurrentPosition({
}),
);
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
} catch (highAccErr) {
// Fall back to a coarse (network/cell/wifi) fix with a longer window.
// This rescues the common "GPS can't lock" case that otherwise looks
// like a hard failure.
console.warn('[geolocation] high-accuracy failed, trying coarse:', highAccErr);
const pos = await safeCall(() =>
native.getCurrentPosition({
enableHighAccuracy: false,
timeout: 20000,
maximumAge: 120000,
});
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
}
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
if (msg.includes('denied') || msg.includes('permission')) {
throw new GeolocationError('denied', 'Location permission denied.');
}
if (msg.includes('timeout') || msg.includes('timed out')) {
throw new GeolocationError('timeout', 'Location request timed out.');
}
// "location disabled", "unavailable", plugin-not-implemented, etc.
throw new GeolocationError('unavailable', err instanceof Error ? err.message : 'Location unavailable.');
}),
);
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
}
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
if (msg.includes('denied') || msg.includes('permission')) {
throw new GeolocationError('denied', 'Location permission denied.');
}
if (msg.includes('timeout') || msg.includes('timed out')) {
throw new GeolocationError('timeout', 'Location request timed out.');
}
// "location disabled", "unavailable", plugin-not-implemented, etc. — pass
// the real native message through so it shows in the on-screen toast and
// we can see what actually failed without needing adb.
throw new GeolocationError(
'unavailable',
err instanceof Error ? err.message : String(err) || 'Location unavailable.',
);
}
}
// ── Web path ──
/**
* Web path (also used as a safe fallback when the native plugin isn't genuinely
* registered). Uses the WebView/browser `navigator.geolocation`.
*/
function getWebPosition(): Promise<GeoCoords> {
if (!('geolocation' in navigator)) {
throw new GeolocationError('unavailable', 'Geolocation is not supported by this browser.');
return Promise.reject(
new GeolocationError('unavailable', 'Geolocation is not supported by this browser.'),
);
}
return new Promise<GeoCoords>((resolve, reject) => {