diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdfb53..f4489e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 + +- Persist install id, tracked flag, attribution id and offer across launches (AsyncStorage optional peer). +- Send `installId` on `/ingestInstall` so the server can dedupe; `trackInstall` posts only once per install. +- Add opt-in `debug` configure flag with redacted API key logging. + ## 0.2.0 - Remove `discountBps` from `TapAppLinkOffer`. Present `billingOfferId` on the paywall. diff --git a/README.md b/README.md index 3b0ea3a..a561dea 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,29 @@ Works in Expo **dev clients and production builds**. Expo Go cannot load the And ## Install +Install the SDK and AsyncStorage together. AsyncStorage is required so install state survives cold launches; without it each launch can post a new install. + +**Expo** + +```bash +npx expo install @tapapplink/react-native @react-native-async-storage/async-storage +``` + +**Bare React Native** + ```bash -yarn add @tapapplink/react-native +npm install @tapapplink/react-native @react-native-async-storage/async-storage +# or: yarn add @tapapplink/react-native @react-native-async-storage/async-storage ``` Bare React Native iOS apps should run `cd ios && pod install` after installing. Rebuild the native binary after adding the package. +### Why AsyncStorage (not the Android native module) + +The Play Install Referrer bridge is Android-only. Storing install state only there would leave iOS (and Expo iOS builds) without persistence. AsyncStorage is the usual cross-platform key/value store for Expo and bare React Native, so one path covers both platforms. + ## Usage ```ts @@ -22,6 +37,7 @@ import { TapAppLink } from "@tapapplink/react-native"; TapAppLink.configure({ publicKey: "etk_live_…", environment: __DEV__ ? "sandbox" : "production", + debug: __DEV__, }); await TapAppLink.trackInstall(); @@ -30,6 +46,10 @@ await TapAppLink.setAppUserId(await Purchases.getAppUserID()); const offer = TapAppLink.getOffer(); ``` +`trackInstall` posts `/ingestInstall` once per install (keyed by a persisted `installId`). Later launches restore the stored attribution id and offer without posting again. `setAppUserId` sends that stored `attributionId` after hydrate. + +Set `debug: true` to log each request, response and stored state. The API key is redacted in those logs. + Purchases are attributed with billing **webhooks**, not a client `trackPurchase` call. ## Publishing diff --git a/__tests__/tapAppLink.test.ts b/__tests__/tapAppLink.test.ts index 297c065..407f92b 100644 --- a/__tests__/tapAppLink.test.ts +++ b/__tests__/tapAppLink.test.ts @@ -1,14 +1,31 @@ +const asyncStore = new Map(); + jest.mock("react-native", () => ({ NativeModules: {}, Platform: { OS: "ios", isPad: false }, })); +jest.mock("@react-native-async-storage/async-storage", () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => asyncStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + asyncStore.set(key, value); + }), + removeItem: jest.fn(async (key: string) => { + asyncStore.delete(key); + }), + }, +})); + import { TapAppLink } from "../src/tapAppLink"; +import { STORAGE_KEY } from "../src/storage"; describe("TapAppLink", () => { beforeEach(async () => { + asyncStore.clear(); await TapAppLink.resetForTesting(); - jest.restoreAllMocks(); + jest.clearAllMocks(); }); it("requires configure before trackInstall", async () => { @@ -17,8 +34,9 @@ describe("TapAppLink", () => { ); }); - it("tracks install once and caches offer", async () => { + it("tracks install once, sends installId, and caches offer", async () => { const fetchMock = jest.fn().mockResolvedValue({ + status: 200, json: async () => ({ attributionId: "attr_123", offer: { @@ -31,7 +49,7 @@ describe("TapAppLink", () => { global.fetch = fetchMock as unknown as typeof fetch; TapAppLink.configure({ - publicKey: "etk_test", + publicKey: "etk_test_key", environment: "sandbox", ingestUrl: "https://example.test", }); @@ -40,39 +58,146 @@ describe("TapAppLink", () => { const second = await TapAppLink.trackInstall(); expect(first).toMatchObject({ attributionId: "attr_123" }); - expect(second).toEqual({ matched: false, skipped: true }); + expect(second).toMatchObject({ + matched: false, + skipped: true, + attributionId: "attr_123", + offer: { + creatorName: "Ada", + promoCode: "ADA10", + billingOfferId: "offer_1", + }, + }); expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as { installId: string }; + expect(typeof body.installId).toBe("string"); + expect(body.installId.length).toBeGreaterThan(0); + expect(TapAppLink.getInstallId()).toBe(body.installId); expect(TapAppLink.getOffer()).toEqual({ creatorName: "Ada", promoCode: "ADA10", billingOfferId: "offer_1", }); expect(TapAppLink.getAttributionId()).toBe("attr_123"); + expect(asyncStore.get(STORAGE_KEY)).toContain("attr_123"); }); - it("posts identify with app user id", async () => { + it("does not post ingestInstall again after a cold start with persisted state", async () => { const fetchMock = jest.fn().mockResolvedValue({ - json: async () => ({ ok: true }), + status: 200, + json: async () => ({ + attributionId: "attr_cold", + offer: { + creatorName: "Bea", + promoCode: null, + billingOfferId: "offer_2", + }, + }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); + await TapAppLink.trackInstall(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const installId = TapAppLink.getInstallId(); + + // Simulate process death: clear in-memory SDK state but keep AsyncStorage. + const persisted = asyncStore.get(STORAGE_KEY); + expect(persisted).toBeTruthy(); + await TapAppLink.resetForTesting(); + asyncStore.set(STORAGE_KEY, persisted!); + + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); + const resumed = await TapAppLink.trackInstall(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(resumed).toMatchObject({ + skipped: true, + installId, + attributionId: "attr_cold", + offer: { + creatorName: "Bea", + promoCode: null, + billingOfferId: "offer_2", + }, }); + expect(TapAppLink.getAttributionId()).toBe("attr_cold"); + expect(TapAppLink.getOffer()?.creatorName).toBe("Bea"); + }); + + it("sends stored attributionId on identify after hydrate", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ attributionId: "attr_id_9" }), + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ ok: true }), + }); global.fetch = fetchMock as unknown as typeof fetch; TapAppLink.configure({ - publicKey: "etk_test", + publicKey: "etk_test_key", environment: "sandbox", ingestUrl: "https://example.test", }); + await TapAppLink.trackInstall(); + + const persisted = asyncStore.get(STORAGE_KEY)!; + await TapAppLink.resetForTesting(); + asyncStore.set(STORAGE_KEY, persisted); + TapAppLink.configure({ + publicKey: "etk_test_key", + environment: "sandbox", + ingestUrl: "https://example.test", + }); await TapAppLink.setAppUserId("user_42"); expect(TapAppLink.getAppUserId()).toBe("user_42"); - expect(fetchMock).toHaveBeenCalledWith( - "https://example.test/ingestIdentify", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer etk_test", - }), - }), + expect(fetchMock).toHaveBeenCalledTimes(2); + const identifyBody = JSON.parse( + (fetchMock.mock.calls[1][1] as { body: string }).body, + ) as { appUserId: string; attributionId: string }; + expect(identifyBody).toEqual({ + appUserId: "user_42", + attributionId: "attr_id_9", + }); + }); + + it("redacts the API key in debug logs", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const fetchMock = jest.fn().mockResolvedValue({ + status: 200, + json: async () => ({ attributionId: "attr_dbg" }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + TapAppLink.configure({ + publicKey: "etk_live_supersecret", + environment: "sandbox", + ingestUrl: "https://example.test", + debug: true, + }); + await TapAppLink.trackInstall(); + + const logged = logSpy.mock.calls.map((call) => JSON.stringify(call)); + expect(logged.some((line) => line.includes("etk_live_supersecret"))).toBe( + false, ); + expect(logged.some((line) => line.includes("etk_...cret"))).toBe(true); + logSpy.mockRestore(); }); }); diff --git a/package-lock.json b/package-lock.json index ae82303..2f29bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@types/jest": "^29.5.14", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.46.0", @@ -21,8 +22,14 @@ "typescript": "^5.9.3" }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.17.0", "react": "*", "react-native": ">=0.72.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -2921,6 +2928,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -5737,6 +5757,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -6921,6 +6951,19 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index b25bbd7..60c1b74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tapapplink/react-native", - "version": "0.2.0", + "version": "0.3.0", "description": "Tap App Link attribution SDK for React Native and Expo", "license": "MIT", "homepage": "https://tapapplink.com", @@ -38,15 +38,21 @@ "test": "jest", "prepublishOnly": "npm run build" }, - "publishConfig": { "access": "public" }, "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.17.0", "react": "*", "react-native": ">=0.72.0" }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + }, "devDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", "@types/jest": "^29.5.14", "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.46.0", diff --git a/src/index.ts b/src/index.ts index f1a36b1..6958e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export { type TapAppLinkEnvironment, type TapAppLinkOffer, } from "./tapAppLink"; +export type { PersistedInstallState } from "./storage"; export { TapAppLink as default } from "./tapAppLink"; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..bec7ea5 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,120 @@ +export type PersistedInstallState = { + installId: string; + tracked: boolean; + attributionId?: string; + offer?: { + creatorName: string; + promoCode: string | null; + billingOfferId: string | null; + } | null; +}; + +export type KeyValueStorage = { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; +}; + +export const STORAGE_KEY = "@tapapplink/react-native/installState"; + +let memoryFallback: string | null = null; +let warnedMissingStorage = false; + +const memoryStorage = (): KeyValueStorage => ({ + getItem: async () => memoryFallback, + setItem: async (_key, value) => { + memoryFallback = value; + }, + removeItem: async () => { + memoryFallback = null; + }, +}); + +/** + * Prefer AsyncStorage so install state survives cold starts on both iOS and + * Android (Expo and bare). The Android native module alone cannot cover iOS. + */ +export const resolveStorage = (): KeyValueStorage => { + try { + // Optional peer; resolve at runtime so the SDK still loads without it. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("@react-native-async-storage/async-storage") as { + default?: KeyValueStorage; + } & KeyValueStorage; + const storage = mod.default ?? mod; + if ( + storage && + typeof storage.getItem === "function" && + typeof storage.setItem === "function" + ) { + return storage; + } + } catch { + // fall through + } + + if (!warnedMissingStorage) { + warnedMissingStorage = true; + console.warn( + "[TapAppLink] @react-native-async-storage/async-storage is not installed. Install state cannot persist, so each cold launch will post a new /ingestInstall until you add AsyncStorage.", + ); + } + return memoryStorage(); +}; + +export const loadInstallState = async ( + storage: KeyValueStorage, +): Promise => { + try { + const raw = await storage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as PersistedInstallState; + if (!parsed || typeof parsed.installId !== "string") return null; + return { + installId: parsed.installId, + tracked: Boolean(parsed.tracked), + attributionId: + typeof parsed.attributionId === "string" + ? parsed.attributionId + : undefined, + offer: parsed.offer ?? null, + }; + } catch { + return null; + } +}; + +export const saveInstallState = async ( + storage: KeyValueStorage, + state: PersistedInstallState, +): Promise => { + await storage.setItem(STORAGE_KEY, JSON.stringify(state)); +}; + +export const clearInstallState = async ( + storage: KeyValueStorage, +): Promise => { + memoryFallback = null; + await storage.removeItem(STORAGE_KEY); +}; + +export const resetStorageHelpersForTesting = () => { + memoryFallback = null; + warnedMissingStorage = false; +}; + +export const createInstallId = (): string => { + const cryptoObj = + typeof globalThis !== "undefined" + ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto + : undefined; + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + // RFC 4122 version 4 fallback when randomUUID is unavailable. + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const rand = (Math.random() * 16) | 0; + const value = char === "x" ? rand : (rand & 0x3) | 0x8; + return value.toString(16); + }); +}; diff --git a/src/tapAppLink.ts b/src/tapAppLink.ts index fd81a7c..945df78 100644 --- a/src/tapAppLink.ts +++ b/src/tapAppLink.ts @@ -1,4 +1,14 @@ import { NativeModules, Platform } from "react-native"; +import { + clearInstallState, + createInstallId, + loadInstallState, + resetStorageHelpersForTesting, + resolveStorage, + saveInstallState, + type KeyValueStorage, + type PersistedInstallState, +} from "./storage"; export type TapAppLinkEnvironment = "production" | "sandbox"; @@ -12,6 +22,8 @@ export type TapAppLinkConfig = { publicKey: string; environment: TapAppLinkEnvironment; ingestUrl?: string; + /** When true, logs requests, responses and stored state with the API key redacted. */ + debug?: boolean; }; type NativeSignals = { @@ -58,11 +70,69 @@ const collectSignals = async (): Promise => { }; let config: TapAppLinkConfig | null = null; +let storage: KeyValueStorage = resolveStorage(); +let hydratePromise: Promise | null = null; +let installId: string | undefined; let tracked = false; let lastAttributionId: string | undefined; let lastAppUserId: string | undefined; let lastOffer: TapAppLinkOffer | null = null; +const redactKey = (value: string): string => { + if (value.length <= 8) return "***"; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +}; + +const debugEnabled = (): boolean => Boolean(config?.debug); + +const debugLog = (message: string, details?: unknown) => { + if (!debugEnabled()) return; + if (details === undefined) { + console.log(`[TapAppLink] ${message}`); + return; + } + console.log(`[TapAppLink] ${message}`, details); +}; + +const applyPersisted = (state: PersistedInstallState) => { + installId = state.installId; + tracked = state.tracked; + lastAttributionId = state.attributionId; + lastOffer = state.offer ?? null; +}; + +const persist = async () => { + if (!installId) return; + const state: PersistedInstallState = { + installId, + tracked, + attributionId: lastAttributionId, + offer: lastOffer, + }; + await saveInstallState(storage, state); + debugLog("stored state", { + installId: state.installId, + tracked: state.tracked, + attributionId: state.attributionId, + offer: state.offer, + }); +}; + +const hydrate = async () => { + const loaded = await loadInstallState(storage); + if (loaded) { + applyPersisted(loaded); + debugLog("hydrated state", loaded); + } +}; + +const ensureHydrated = async () => { + if (!hydratePromise) { + hydratePromise = hydrate(); + } + await hydratePromise; +}; + const ingestBase = (cfg: TapAppLinkConfig): string => { if (cfg.ingestUrl) return cfg.ingestUrl.replace(/\/$/, ""); return "https://us-central1-tapapplink.cloudfunctions.net"; @@ -72,15 +142,28 @@ const post = async (path: string, body: unknown) => { if (!config) { throw new Error("TapAppLink.configure() must be called first"); } - const response = await fetch(`${ingestBase(config)}${path}`, { + const url = `${ingestBase(config)}${path}`; + const headers = { + Authorization: `Bearer ${config.publicKey}`, + "Content-Type": "application/json", + }; + debugLog("request", { + url, method: "POST", headers: { - Authorization: `Bearer ${config.publicKey}`, - "Content-Type": "application/json", + ...headers, + Authorization: `Bearer ${redactKey(config.publicKey)}`, }, + body, + }); + const response = await fetch(url, { + method: "POST", + headers, body: JSON.stringify(body), }); - return response.json() as Promise>; + const result = (await response.json()) as Record; + debugLog("response", { url, status: response.status, body: result }); + return result; }; const cacheFromResult = (result: Record) => { @@ -93,23 +176,48 @@ const cacheFromResult = (result: Record) => { } }; +const platformName = (): "IOS" | "ANDROID" | "UNKNOWN" => { + if (Platform.OS === "ios") return "IOS"; + if (Platform.OS === "android") return "ANDROID"; + return "UNKNOWN"; +}; + export const TapAppLink = { configure: (next: TapAppLinkConfig) => { config = next; + storage = resolveStorage(); + hydratePromise = hydrate(); + debugLog("configure", { + environment: next.environment, + ingestUrl: next.ingestUrl, + publicKey: redactKey(next.publicKey), + debug: Boolean(next.debug), + }); }, trackInstall: async () => { + await ensureHydrated(); if (tracked) { - return { matched: false, skipped: true }; + const stored = { + matched: false, + skipped: true as const, + installId, + attributionId: lastAttributionId, + offer: lastOffer, + }; + debugLog("trackInstall skipped; returning stored values", stored); + return stored; } + + if (!installId) { + installId = createInstallId(); + await persist(); + } + const signals = await collectSignals(); const result = await post("/ingestInstall", { - platform: - Platform.OS === "ios" - ? "IOS" - : Platform.OS === "android" - ? "ANDROID" - : "UNKNOWN", + installId, + platform: platformName(), deviceFamily: signals.deviceFamily, locale: signals.locale, networkContext: signals.networkContext, @@ -118,10 +226,12 @@ export const TapAppLink = { }); tracked = true; cacheFromResult(result); + await persist(); return result; }, setAppUserId: async (appUserId: string) => { + await ensureHydrated(); lastAppUserId = appUserId; return post("/ingestIdentify", { appUserId, @@ -130,24 +240,22 @@ export const TapAppLink = { }, applyCode: async (code: string) => { + await ensureHydrated(); const result = await post("/redeemCode", { code, appUserId: lastAppUserId, attributionId: lastAttributionId, - platform: - Platform.OS === "ios" - ? "IOS" - : Platform.OS === "android" - ? "ANDROID" - : "UNKNOWN", + platform: platformName(), }); cacheFromResult(result); + await persist(); return result; }, getOffer: () => lastOffer, getAttributionId: () => lastAttributionId, getAppUserId: () => lastAppUserId, + getInstallId: () => installId, linkRevenueCatUser: async (appUserId: string) => TapAppLink.setAppUserId(appUserId), @@ -158,10 +266,16 @@ export const TapAppLink = { linkQonversionUser: async (userId: string) => TapAppLink.setAppUserId(userId), resetForTesting: async () => { + config = null; tracked = false; + installId = undefined; lastAttributionId = undefined; lastAppUserId = undefined; lastOffer = null; + hydratePromise = null; + await clearInstallState(storage); + resetStorageHelpersForTesting(); + storage = resolveStorage(); }, };