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.
This commit is contained in:
Maurice
2026-11-07 14:57:50 +01:00
parent c1c71a12cf
commit 46f4cab1b7
6 changed files with 120 additions and 7 deletions
+17
View File
@@ -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,
+16
View File
@@ -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();
+15 -7
View File
@@ -21,12 +21,14 @@ interface Watcher {
success: Success;
failure: Failure;
once: boolean;
high: boolean;
}
const watchers = new Map<number, Watcher>();
let nextId = 1;
let running = false;
let highAccuracy = false;
/** The accuracy the running stream was started with. */
let runningHigh = false;
let subscribed: Promise<unknown> | null = null;
function toPosition(p: Extract<NativeLocationPayload, { latitude: number }>): 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;
}
+38
View File
@@ -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();
});
});
+27
View File
@@ -50,6 +50,14 @@ export interface IapClient {
finish(purchase: Purchase): Promise<void>;
/** The subscription this store account already holds, for a reinstall or a new device. */
restore(): Promise<Purchase | null>;
/**
* 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<Purchase | null>;
/** 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.
+7
View File
@@ -110,6 +110,13 @@ export interface TrekShellPlugin {
event: 'windowLayout',
listener: (payload: NativeWindowLayout) => void
): Promise<{ remove: () => Promise<void> }>;
/** Android, feature playBilling: a paid subscription never delivered (cleared while the app was closed). */
storeUnfinished(options: { prefix: string }): Promise<StoreRestoreResult>;
/** Android, feature playBilling: a pending payment cleared while the app runs. */
addListener(
event: 'storeTransaction',
listener: (payload: StoreRestoreResult) => void
): Promise<{ remove: () => Promise<void> }>;
}
/** What Google Play Billing answers a restore with (Android, feature playBilling), beside iOS's `jws`. */