mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
Reference in New Issue
Block a user