Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions apps/mobile/src/lib/appsflyer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ async function loadModule() {
return module;
}

/** Drains the microtasks that gate connector calls on the create() promise. */
async function flushConnector() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

describe('initAppsFlyer purchase connector', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -101,9 +108,21 @@ describe('initAppsFlyer purchase connector', () => {
sandbox: false,
storeKitVersion: 'SK2',
});
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1);
});

it('does not observe transactions when create fails', async () => {
mockedPlatform.OS = 'ios';
mockedAppsFlyer.create.mockRejectedValue(new Error('native bridge down'));

const initAppsFlyer = await loadInit();
initAppsFlyer();

await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();
});

it('does not touch the purchase connector on Android', async () => {
mockedPlatform.OS = 'android';
const initAppsFlyer = await loadInit();
Expand Down Expand Up @@ -131,13 +150,15 @@ describe('initAppsFlyer purchase connector', () => {
initAppsFlyer();
initAppsFlyer();

await flushConnector();
expect(mockedAppsFlyer.create).toHaveBeenCalledTimes(1);
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();

successHolder.current?.('ok');

initAppsFlyer();

await flushConnector();
expect(mockedAppsFlyer.create).toHaveBeenCalledTimes(1);
expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -410,19 +431,38 @@ describe('resetAppsFlyerState', () => {
expect(mockedAppsFlyer.stop).toHaveBeenCalledWith(true);
});

it('calls stopObservingTransactions on iOS', async () => {
it('calls stopObservingTransactions on iOS after the connector was created', async () => {
mockedPlatform.OS = 'ios';
const { resetAppsFlyerState } = await loadModule();
const { initAppsFlyer, resetAppsFlyerState } = await loadModule();
initAppsFlyer();
await flushConnector();

resetAppsFlyerState();

await flushConnector();
expect(mockedAppsFlyer.stopObservingTransactions).toHaveBeenCalledTimes(1);
});

// Regression: a signed-out cold start resets before initAppsFlyer ever runs.
// Native rejects with "Connector not configured, did you call `create`
// first?" and the library drops that promise, so it reached Sentry as an
// unhandled rejection.
it('does not call stopObservingTransactions when the connector was never created', async () => {
mockedPlatform.OS = 'ios';
const { resetAppsFlyerState } = await loadModule();
resetAppsFlyerState();

await flushConnector();
expect(mockedAppsFlyer.stopObservingTransactions).not.toHaveBeenCalled();
});

it('does not call stopObservingTransactions on Android', async () => {
mockedPlatform.OS = 'android';
const { resetAppsFlyerState } = await loadModule();
const { initAppsFlyer, resetAppsFlyerState } = await loadModule();
initAppsFlyer();
resetAppsFlyerState();

await flushConnector();
expect(mockedAppsFlyer.stopObservingTransactions).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -490,6 +530,7 @@ describe('resetAppsFlyerState', () => {

// Fire the stale callback — must not re-arm the SDK.
successHolders[0]?.('ok');
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();

// Track an event — must buffer, not log, because initialized was cleared.
Expand Down Expand Up @@ -568,6 +609,7 @@ describe('stale initSdk callback', () => {

// initialized must still be false, so trackEvent queues instead of logging.
module.trackEvent('post-stale-event');
await flushConnector();
expect(mockedAppsFlyer.logEvent).not.toHaveBeenCalled();
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -616,6 +658,7 @@ describe('stale initSdk callback', () => {

// Fire the stale callback.
successHolders[0]?.('ok');
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();

// Re-init — generation now matches the controller.
Expand All @@ -631,6 +674,7 @@ describe('stale initSdk callback', () => {
// Fire the new callback.
expect(successHolders).toHaveLength(2);
successHolders[1]?.('ok');
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1);
});
});
Expand Down Expand Up @@ -714,6 +758,7 @@ describe('callback token revoke invalidation', () => {

// Fire the stale callback.
successHolders[0]?.('ok');
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled();

// Re-init — token now matches because resetAppsFlyerState was already called.
Expand All @@ -729,6 +774,7 @@ describe('callback token revoke invalidation', () => {
// Fire the new callback — token matches, must succeed.
expect(successHolders).toHaveLength(2);
successHolders[1]?.('ok');
await flushConnector();
expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1);
});
});
97 changes: 53 additions & 44 deletions apps/mobile/src/lib/appsflyer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import { APPSFLYER_APP_ID, APPSFLYER_DEV_KEY } from '@/lib/config';
import { allowsOptional, currentGeneration } from '@/lib/telemetry/controller';

let initialized = false;
/** Blocks re-entry into create() within one JS bundle (before initSdk succeeds). */
let purchaseConnectorCreateStarted = false;
/**
* Resolves to whether the native purchase connector is configured. Null until
* `create()` is first called. See `ensurePurchaseConnector`.
Comment thread
iscekic marked this conversation as resolved.
Resolved
*/
let connectorReady: Promise<boolean> | null = null;
/**
* Invalidation token for in-flight initSdk callbacks. Incremented by
* `resetAppsFlyerState()` so a late success after stop/optional revoke
Expand Down Expand Up @@ -74,21 +77,46 @@ function isConnectorAlreadyConfigured(error: unknown): boolean {
}

/**
* Settles create() without floating promises. `Promise.resolve` normalizes a
* non-promise return (bare mocks / unpatched install) so await never throws
* TypeError. Known-benign "already configured" is swallowed; anything else
* goes to Sentry via handleError.
* Reports whether the native purchase connector is configured.
*
* Native PCAppsFlyer keeps a process-lifetime static connector. A JS reload
* resets module state while native state survives, so create() then rejects
* with "Connector already configured" — which still means configured. Any
* other failure goes to Sentry and leaves the connector unusable.
*/
async function settlePurchaseConnectorCreate(
createResult: void | PromiseLike<void>
): Promise<void> {
async function createPurchaseConnector(): Promise<boolean> {
try {
await Promise.resolve(createResult);
await AppsFlyerPurchaseConnector.create({
logSubscriptions: true,
logInApps: false,
sandbox: __DEV__,
storeKitVersion: StoreKitVersion.SK2,
});
return true;
} catch (error: unknown) {
if (isConnectorAlreadyConfigured(error)) {
return;
return true;
}
handleError('AppsFlyer purchase connector failed')(error);
return false;
}
}

/**
* Runs a connector call only once the connector is known to be configured.
* The library discards the promise that start/stopObservingTransactions
* return, so a native "Connector not configured" rejection escapes as an
* unhandled rejection and lands in Sentry. `connectorReady` stays null on
* Android and before the first create(), so both calls are skipped there.
*/
async function whenConnectorReady(action: () => void): Promise<void> {
if (connectorReady === null || !(await connectorReady)) {
return;
}
try {
action();
} catch {
// Native module missing or threw synchronously.
}
}

Expand Down Expand Up @@ -117,22 +145,9 @@ export function initAppsFlyer(): void {
// purchase revenue server-side, so revenue is attributed without touching the
// purchase flow. iOS-only: Kilo Pass IAP ships on iOS only (subscriptions,
// StoreKit 2 via expo-iap). Create it before initSdk and start observing once
// the SDK has started (in the success callback below).
//
// Native PCAppsFlyer keeps a process-lifetime static connector. JS reloads
// reset module state while native state remains, so create() can reject with
// "Connector already configured". Guard sync re-entry within one bundle and
// swallow only that known-benign rejection (any other failure goes to Sentry).
if (Platform.OS === 'ios' && !purchaseConnectorCreateStarted) {
purchaseConnectorCreateStarted = true;
void settlePurchaseConnectorCreate(
AppsFlyerPurchaseConnector.create({
logSubscriptions: true,
logInApps: false,
sandbox: __DEV__,
storeKitVersion: StoreKitVersion.SK2,
})
);
// both the SDK has started and the connector is configured.
if (Platform.OS === 'ios') {
connectorReady ??= createPurchaseConnector();
}

// Send the optional-consent signal before the SDK starts so attribution
Expand Down Expand Up @@ -169,9 +184,13 @@ export function initAppsFlyer(): void {
return;
}
initialized = true;
if (Platform.OS === 'ios') {
void whenConnectorReady(() => {
// Re-check: a reset can land while the create() promise settles.
if (currentGeneration() !== initGeneration || callbackToken !== initToken) {
return;
}
AppsFlyerPurchaseConnector.startObservingTransactions();
}
});
drainPendingEvents();
},
handleError('AppsFlyer init failed')
Expand Down Expand Up @@ -208,8 +227,8 @@ export function trackEvent(name: string, values?: Record<string, string>): void
* clears the pending-event buffer so stale events from a prior account do
* not transmit on a later init.
*
* Does NOT reset `purchaseConnectorCreateStarted`: native `PCAppsFlyer` keeps
* a process-lifetime static connector, so re-entering `create()` rejects with
* Does NOT clear `connectorReady`: native `PCAppsFlyer` keeps a
* process-lifetime static connector, so re-entering `create()` rejects with
* "Connector already configured".
*/
export function resetAppsFlyerState(): void {
Expand All @@ -228,17 +247,7 @@ export function resetAppsFlyerState(): void {
// Native stop may throw — JS invalidation has already run.
}

if (Platform.OS === 'ios') {
try {
if (
typeof (AppsFlyerPurchaseConnector as unknown as Record<string, unknown>)
.stopObservingTransactions === 'function'
) {
AppsFlyerPurchaseConnector.stopObservingTransactions();
}
} catch {
// Native stopObservingTransactions may throw — JS invalidation has
// already run.
}
}
void whenConnectorReady(() => {
AppsFlyerPurchaseConnector.stopObservingTransactions();
});
}
Loading