-
Notifications
You must be signed in to change notification settings - Fork 1
P-2207: Web-to-mobile attribution (Android) #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8996b85
f4e858a
e83f54c
4da7111
09719b4
e8353b8
86ac51a
e6c763e
854a26a
f05ce1c
d551764
3847a8a
3bc68ed
3ae25f1
3d00c8c
2904ef9
635e5dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,6 +68,7 @@ | |
| "react": ">=18.0.0", | ||
| "react-native": ">=0.70.0", | ||
| "react-native-device-info": ">=10.0.0", | ||
| "react-native-play-install-referrer": ">=1.1.8", | ||
| "wagmi": ">=2.0.0" | ||
| }, | ||
| "peerDependenciesMeta": { | ||
|
|
@@ -86,6 +87,9 @@ | |
| "react-native-device-info": { | ||
| "optional": true | ||
| }, | ||
| "react-native-play-install-referrer": { | ||
| "optional": true | ||
| }, | ||
|
Comment on lines
+90
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Android consumers following the README installation command, this optional peer is not installed, so the lazy Useful? React with 👍 / 👎. |
||
| "wagmi": { | ||
| "optional": true | ||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,14 +134,14 @@ export class FormoAnalytics implements IFormoAnalytics { | |
| const analytics = new FormoAnalytics(writeKey, options); | ||
|
|
||
| // Capture attribution BEFORE lifecycle tracking so the first | ||
| // Application Installed/Opened events carry utm_*/ref/referrer context. | ||
| // Application Installed/Opened events carry utm_*/ref/referrer. | ||
| // | ||
| // Deep-link initial URL is awaited because Linking.getInitialURL() is a | ||
| // fast native bridge call and we need its result before lifecycle events | ||
| // fire. The url-event subscription is set up synchronously for runtime | ||
| // deep links. Install-referrer capture (Play / AdServices) is fire-and- | ||
| // forget since it involves potentially-slow network I/O on iOS and is | ||
| // best-effort; it'll still populate attribution for subsequent events. | ||
| // Both are awaited so the stored traffic source is populated before | ||
| // lifecycle fires — that's what lets Application Installed report the | ||
| // web-to-mobile referrer (e.g. referrer=example.com). Deep-link initial URL | ||
| // is a fast native bridge call; the Android Play Install Referrer is a fast | ||
| // one-shot native call (and no-ops instantly when the native module or the | ||
| // platform isn't Android), so awaiting it does not meaningfully delay init. | ||
| if (analytics.isAttributionEnabled("deeplinks")) { | ||
| try { | ||
| await analytics.startDeepLinkCapture(); | ||
|
|
@@ -151,12 +151,14 @@ export class FormoAnalytics implements IFormoAnalytics { | |
| } | ||
|
|
||
| if (analytics.isAttributionEnabled("installReferrer")) { | ||
| captureInstallReferrer({ | ||
| customRefParams: analytics.options.referral?.queryParams, | ||
| pathPattern: analytics.options.referral?.pathPattern, | ||
| }).catch((error) => { | ||
| try { | ||
| await captureInstallReferrer({ | ||
| customRefParams: analytics.options.referral?.queryParams, | ||
| pathPattern: analytics.options.referral?.pathPattern, | ||
| }); | ||
|
Comment on lines
+155
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Play Install Referrer native callback never arrives (for example, a stalled Play Store/service connection), Useful? React with 👍 / 👎.
Comment on lines
153
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Play Store service stalls and never invokes its callback, this awaits the full three-second timeout before the SDK is available. Useful? React with 👍 / 👎. |
||
| } catch (error) { | ||
| logger.debug("FormoAnalytics: install referrer capture failed", error); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Initialize lifecycle tracking if enabled | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /** | ||
| * SDK init awaits captureInstallReferrer so the referrer is available when the | ||
| * Application Installed event fires. That makes a hung native call dangerous: | ||
| * if the Play Store service connection stalls and the callback never arrives, | ||
| * an unbounded await would block FormoAnalytics.init() forever, leaving every | ||
| * consumer on the no-op context with tracking silently dead. | ||
| * | ||
| * These tests pin the timeout that prevents that. | ||
| */ | ||
|
|
||
| jest.mock("react-native", () => ({ | ||
| Platform: { OS: "android" }, | ||
| })); | ||
|
|
||
| const mockStorageInstance = { | ||
| get: jest.fn().mockReturnValue(null), | ||
| set: jest.fn(), | ||
| setAsync: jest.fn().mockResolvedValue(undefined), | ||
| remove: jest.fn(), | ||
| isAvailable: jest.fn().mockReturnValue(true), | ||
| }; | ||
|
|
||
| const mockStorageManager = { | ||
| hasPersistentStorage: jest.fn().mockReturnValue(true), | ||
| }; | ||
|
|
||
| jest.mock("../lib/storage", () => ({ | ||
| __esModule: true, | ||
| storage: jest.fn(() => mockStorageInstance), | ||
| getStorageManager: jest.fn(() => mockStorageManager), | ||
| })); | ||
|
|
||
| jest.mock("../lib/logger", () => ({ | ||
| __esModule: true, | ||
| logger: { | ||
| info: jest.fn(), | ||
| warn: jest.fn(), | ||
| error: jest.fn(), | ||
| debug: jest.fn(), | ||
| log: jest.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| // Simulate a stalled Play Store service: the callback is never invoked. | ||
| jest.mock( | ||
| "react-native-play-install-referrer", | ||
| () => ({ | ||
| PlayInstallReferrer: { | ||
| getInstallReferrerInfo: () => { | ||
| /* intentionally never calls back */ | ||
| }, | ||
| }, | ||
| }), | ||
| { virtual: true } | ||
| ); | ||
|
|
||
| import { captureInstallReferrer } from "../lib/installReferrer"; | ||
|
|
||
| describe("captureInstallReferrer — hung native call", () => { | ||
| beforeEach(() => { | ||
| jest.useFakeTimers(); | ||
| mockStorageInstance.get.mockReturnValue(null); | ||
| mockStorageInstance.setAsync.mockClear(); | ||
| mockStorageManager.hasPersistentStorage.mockReturnValue(true); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| it("resolves (does not hang init) when the Play callback never fires", async () => { | ||
| const promise = captureInstallReferrer(); | ||
| // Advance past the bound; if the timeout were missing this would never settle | ||
| // and the test would time out. | ||
| jest.advanceTimersByTime(1501); | ||
| await expect(promise).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not mark attribution resolved on timeout, so it retries next launch", async () => { | ||
| const promise = captureInstallReferrer(); | ||
| jest.advanceTimersByTime(1501); | ||
| await promise; | ||
|
|
||
| // The one-shot flag must NOT be persisted — a timeout is transient. | ||
| expect(mockStorageInstance.setAsync).not.toHaveBeenCalledWith( | ||
| "install_referrer_resolved", | ||
| "true" | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an Android consumer does not install this peer, Metro will fail while bundling:
src/lib/installReferrer/index.tscontains a statically analyzablerequire("react-native-play-install-referrer"), and Metro resolves such dependencies before thetry/catchcan run. Marking the peer optional therefore makes the documented no-module path a build-timeUnable to resolve modulefailure rather than a runtime warning/no-op. Make it required, or replace the static dependency with an integration approach that Metro can safely omit.Useful? React with 👍 / 👎.