From 46f4cab1b795bcd1e6bbfe51f9defce34d454d15 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 14:57:50 +0100 Subject: [PATCH] fix(native): location at the accuracy asked for, and a Play payment that clears later is redeemed - The location shim runs at high accuracy only while a watcher wants it and drops back once that watcher is gone, instead of keeping the 2 s GPS rate for the rest of the session. - The purchase client can hand over a Play subscription that was paid but never delivered (a pending payment that cleared later), and the plans screen redeems it at once, as a restore would, before Google Play refunds it after three days. --- client/native-shell/main.ts | 17 ++++++++++++ client/src/native/geolocation.test.ts | 16 +++++++++++ client/src/native/geolocation.ts | 22 +++++++++++----- client/src/native/iap.test.ts | 38 +++++++++++++++++++++++++++ client/src/native/iap.ts | 27 +++++++++++++++++++ client/src/native/trekShell.ts | 7 +++++ 6 files changed, 120 insertions(+), 7 deletions(-) diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index 2defea4a0..b26c6bd74 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -448,6 +448,10 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT * holds (a reinstall, a new phone). Without one: only that buying in the app is * not available, and no pointer elsewhere. */ +/** The restore button of the plans on screen, which redeems a payment that cleared later. */ +let resumeRestore: (() => void) | null = null; +let watchingTransactions = false; + function planFooter(offer: Offer, plans: CloudPlan[]): Node[] { if (offer.opensSoon) return [el('p', { className: 'note' }, t('native.plans.closed'))]; if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))]; @@ -490,6 +494,19 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] { status.textContent = t('native.plans.restoreFailed'); }); + // A payment that was pending (cash, some carrier billing) and has cleared since: + // redeemed right away, as the restore would, before Google Play refunds it. + resumeRestore = () => { + if (!restore.disabled && restore.isConnected) restore.click(); + }; + if (!watchingTransactions) { + watchingTransactions = true; + iap.onTransaction(() => resumeRestore?.()); + } + void iap.unfinished().then((purchase) => { + if (purchase) resumeRestore?.(); + }); + return [ restore, status, diff --git a/client/src/native/geolocation.test.ts b/client/src/native/geolocation.test.ts index f608c82ff..331165e50 100644 --- a/client/src/native/geolocation.test.ts +++ b/client/src/native/geolocation.test.ts @@ -55,6 +55,22 @@ describe('native geolocation', () => { expect(TrekShell.stopLocation).toHaveBeenCalledTimes(1); }); + it('runs at high accuracy only while a watcher asks for it, and drops back after', () => { + installNativeGeolocation(); + const passive = navigator.geolocation.watchPosition(vi.fn()); + expect(TrekShell.startLocation).toHaveBeenLastCalledWith({ highAccuracy: false }); + const tracking = navigator.geolocation.watchPosition(vi.fn(), undefined, { enableHighAccuracy: true }); + expect(TrekShell.startLocation).toHaveBeenLastCalledWith({ highAccuracy: true }); + navigator.geolocation.clearWatch(tracking); + expect(TrekShell.startLocation).toHaveBeenLastCalledWith({ highAccuracy: false }); + expect(TrekShell.startLocation).toHaveBeenCalledTimes(3); + // A later passive watch does not inherit the high accuracy. + navigator.geolocation.watchPosition(vi.fn()); + expect(TrekShell.startLocation).toHaveBeenCalledTimes(3); + navigator.geolocation.clearWatch(passive); + expect(TrekShell.stopLocation).not.toHaveBeenCalled(); + }); + it('settles a one-off request with the first fix, and passes a denial on as the Geolocation error', async () => { installNativeGeolocation(); const once = vi.fn(); diff --git a/client/src/native/geolocation.ts b/client/src/native/geolocation.ts index 243c59451..e3713ba33 100644 --- a/client/src/native/geolocation.ts +++ b/client/src/native/geolocation.ts @@ -21,12 +21,14 @@ interface Watcher { success: Success; failure: Failure; once: boolean; + high: boolean; } const watchers = new Map(); let nextId = 1; let running = false; -let highAccuracy = false; +/** The accuracy the running stream was started with. */ +let runningHigh = false; let subscribed: Promise | null = null; function toPosition(p: Extract): GeolocationPosition { @@ -64,22 +66,28 @@ function dispatch(payload: NativeLocationPayload): void { syncStream(); } -/** Runs the native stream while anyone listens, at the accuracy the most demanding asked for. */ +/** + * Runs the native stream while anyone listens, at the accuracy the most + * demanding of them asked for: high while a watcher wants it (tour tracking), + * back to the battery-friendly one once that watcher is gone. + */ function syncStream(): void { - if (watchers.size > 0 && !running) { + const high = [...watchers.values()].some((w) => w.high); + if (watchers.size > 0 && (!running || high !== runningHigh)) { running = true; + runningHigh = high; subscribed ??= TrekShell.addListener('location', dispatch); - TrekShell.startLocation({ highAccuracy }).catch(reportNativeFailure); + TrekShell.startLocation({ highAccuracy: high }).catch(reportNativeFailure); } else if (watchers.size === 0 && running) { running = false; + runningHigh = false; TrekShell.stopLocation().catch(reportNativeFailure); } } function add(success: Success, failure: Failure, options: PositionOptions | undefined, once: boolean): number { - if (options?.enableHighAccuracy) highAccuracy = true; const id = nextId++; - watchers.set(id, { success, failure, once }); + watchers.set(id, { success, failure, once, high: options?.enableHighAccuracy === true }); syncStream(); return id; } @@ -105,7 +113,7 @@ export function installNativeGeolocation(): boolean { export function _resetNativeGeolocation(): void { watchers.clear(); running = false; - highAccuracy = false; + runningHigh = false; subscribed = null; nextId = 1; } diff --git a/client/src/native/iap.test.ts b/client/src/native/iap.test.ts index ea350afe6..0bf8e2ea5 100644 --- a/client/src/native/iap.test.ts +++ b/client/src/native/iap.test.ts @@ -7,6 +7,8 @@ const shell = vi.hoisted(() => ({ storePurchase: vi.fn(), storeFinish: vi.fn(), storeRestore: vi.fn(), + storeUnfinished: vi.fn(), + addListener: vi.fn(), })); vi.mock('./platform', () => ({ isNativeIos: () => platform.ios, isNativeAndroid: () => platform.android })); @@ -175,4 +177,40 @@ describe('native client on Android (Google Play Billing)', () => { await nativeIapClient.finish(purchase!); expect(shell.storeFinish).toHaveBeenCalledWith({ transactionId: 'token-2' }); }); + + it('hands a payment that cleared later over to the shell, and tells it when one clears', async () => { + shell.storeUnfinished.mockResolvedValue({ + purchaseToken: 'late', + transactionId: 'late', + productId: 'cloud.plus.monthly', + }); + expect(await nativeIapClient.unfinished()).toEqual({ + platform: 'google', + productId: 'cloud.plus.monthly', + receipt: 'late', + transactionId: 'late', + }); + expect(shell.storeUnfinished).toHaveBeenCalledWith({ prefix: 'cloud.' }); + shell.storeUnfinished.mockResolvedValue({}); + expect(await nativeIapClient.unfinished()).toBeNull(); + shell.storeUnfinished.mockRejectedValue({ code: 'UNIMPLEMENTED' }); + expect(await nativeIapClient.unfinished()).toBeNull(); + + shell.addListener.mockResolvedValue({ remove: vi.fn() }); + const listener = vi.fn(); + nativeIapClient.onTransaction(listener); + const [event, handler] = shell.addListener.mock.calls[0] as [string, () => void]; + expect(event).toBe('storeTransaction'); + handler(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('on iOS leaves an interrupted purchase to the restore, as StoreKit does', async () => { + platform.ios = true; + platform.android = false; + expect(await nativeIapClient.unfinished()).toBeNull(); + nativeIapClient.onTransaction(vi.fn()); + expect(shell.storeUnfinished).not.toHaveBeenCalled(); + expect(shell.addListener).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/native/iap.ts b/client/src/native/iap.ts index a3d29e5e6..b1a655cda 100644 --- a/client/src/native/iap.ts +++ b/client/src/native/iap.ts @@ -50,6 +50,14 @@ export interface IapClient { finish(purchase: Purchase): Promise; /** The subscription this store account already holds, for a reinstall or a new device. */ restore(): Promise; + /** + * A paid subscription that was never delivered: a payment that was pending and + * cleared later (Google Play refunds it unless it is delivered within three + * days). The shell redeems it at its start, the way a restore does. + */ + unfinished(): Promise; + /** Calls `listener` when such a payment clears while the app runs. */ + onTransaction(listener: () => void): void; } /** Every store product of the cloud starts with this. */ @@ -91,6 +99,10 @@ export const fakeIapClient: IapClient = { async restore() { return null; }, + async unfinished() { + return null; + }, + onTransaction() {}, }; /** The store this app buys through: the App Store on iOS, Google Play on Android. */ @@ -160,6 +172,21 @@ export const nativeIapClient: IapClient = { // Left unfinished, the store hands the transaction over again; a restore redeems it idempotently. } }, + async unfinished() { + // StoreKit hands an interrupted first purchase over through a restore on its own; only Play needs this. + if (nativeStore()?.platform !== 'google') return null; + try { + const { purchaseToken, productId, transactionId } = await TrekShell.storeUnfinished({ prefix: PRODUCT_PREFIX }); + if (!purchaseToken || !transactionId) return null; + return { platform: 'google', productId: productId ?? '', receipt: purchaseToken, transactionId }; + } catch { + return null; + } + }, + onTransaction(listener) { + if (nativeStore()?.platform !== 'google') return; + void TrekShell.addListener('storeTransaction', () => listener()).catch(() => {}); + }, async restore() { try { // Google Play answers with a purchase token where the App Store answers with a signed transaction. diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 8b8539a47..aebf46b6d 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -110,6 +110,13 @@ export interface TrekShellPlugin { event: 'windowLayout', listener: (payload: NativeWindowLayout) => void ): Promise<{ remove: () => Promise }>; + /** Android, feature playBilling: a paid subscription never delivered (cleared while the app was closed). */ + storeUnfinished(options: { prefix: string }): Promise; + /** Android, feature playBilling: a pending payment cleared while the app runs. */ + addListener( + event: 'storeTransaction', + listener: (payload: StoreRestoreResult) => void + ): Promise<{ remove: () => Promise }>; } /** What Google Play Billing answers a restore with (Android, feature playBilling), beside iOS's `jws`. */