Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +37,7 @@ import { TapAppLink } from "@tapapplink/react-native";
TapAppLink.configure({
publicKey: "etk_live_…",
environment: __DEV__ ? "sandbox" : "production",
debug: __DEV__,
});

await TapAppLink.trackInstall();
Expand All @@ -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
Expand Down
155 changes: 140 additions & 15 deletions __tests__/tapAppLink.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
const asyncStore = new Map<string, string>();

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 () => {
Expand All @@ -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: {
Expand All @@ -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",
});
Expand All @@ -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();
});
});
47 changes: 45 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading