diff --git a/examples/expo-bare/app.json b/examples/expo-bare/app.json index 9149982..743934b 100644 --- a/examples/expo-bare/app.json +++ b/examples/expo-bare/app.json @@ -11,7 +11,12 @@ "bundleIdentifier": "detourreactnative.expobare", "supportsTablet": true, "icon": "./assets/detour-logo.png", - "associatedDomains": ["applinks:.godetour.link"] + "associatedDomains": [ + "applinks:.godetour.link" + ], + "infoPlist": { + "NSUserTrackingUsageDescription": "This identifier will be used to deliver personalized ads and measure their effectiveness." + } }, "android": { "package": "detourreactnative.expobare", @@ -31,7 +36,10 @@ "pathPrefix": "/" } ], - "category": ["BROWSABLE", "DEFAULT"] + "category": [ + "BROWSABLE", + "DEFAULT" + ] } ] }, @@ -47,7 +55,8 @@ "imageWidth": 120 } } - ] + ], + "expo-tracking-transparency" ], "experiments": { "reactCompiler": true diff --git a/examples/expo-bare/package.json b/examples/expo-bare/package.json index 084c3c5..1ccce5a 100644 --- a/examples/expo-bare/package.json +++ b/examples/expo-bare/package.json @@ -20,6 +20,8 @@ "expo-dev-client": "~55.0.22", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-splash-screen": "~55.0.15", + "expo-tracking-transparency": "~55.0.11", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.4", diff --git a/examples/expo-bare/src/Screen.tsx b/examples/expo-bare/src/Screen.tsx index db6dbba..67f2ddf 100644 --- a/examples/expo-bare/src/Screen.tsx +++ b/examples/expo-bare/src/Screen.tsx @@ -1,8 +1,12 @@ -import { Image, ScrollView, Text, View } from "react-native"; +import { Image, Pressable, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useDetourContext } from "@swmansion/react-native-detour"; +import { + DetourAnalytics, + DetourEventNames, + useDetourContext, +} from "@swmansion/react-native-detour"; import { colors, styles } from "./styles"; @@ -76,6 +80,24 @@ export const Screen = () => { {link?.params && {JSON.stringify(link.params, null, 2)}} + + + + Test Actions + + Fire these on demand, then inspect the request body in the RN DevTools Network tab to + confirm idfv/aaid/idfa/install_id/customer_user_id are attached. + + + DetourAnalytics.setUserId("test-user-123")}> + Set test customer_user_id + + + DetourAnalytics.logEvent(DetourEventNames.Purchase, { test: true })} + > + Log test event (purchase) + diff --git a/packages/react-native-detour/FIELDS.md b/packages/react-native-detour/FIELDS.md new file mode 100644 index 0000000..a53db28 --- /dev/null +++ b/packages/react-native-detour/FIELDS.md @@ -0,0 +1,97 @@ +# Collected fields + +This document lists every field the SDK collects and sends, why it exists, and how the pieces fit +together. It's meant as a reference for anyone reviewing or extending the SDK's data collection — +not user-facing documentation (see the main [README](./README.md) for that). + +## The process, in short + +The SDK talks to four backend endpoints, each covering a different moment in the app's lifecycle: + +1. **`/api/link/match-link`** — called once per install, from `getDeferredLink.ts`. Tries to match + the current device to a click that happened before install (deferred deep linking). Sends a + `DeterministicFingerprint` (exact click ID from the Android install referrer) or, if that isn't + available, a `ProbabilisticFingerprint` (device/locale/timezone/clipboard signals used for a + best-effort match). +2. **`/api/link/universal-link-click`** — called from `sendUniversalLinkClick.ts` whenever the app + is opened via a Universal/App Link at runtime (not a deferred install). +3. **`/api/analytics/event`** and **`/api/analytics/retention`** — called from `events.ts` / + `retention.ts` whenever the host calls `DetourAnalytics.logEvent` / `logRetention`, or when the + SDK's own `useAppOpenRetention`/`useSessionTracking` hooks fire. +4. **`/api/analytics/conversion`** — called from `conversion.ts` whenever the host calls + `DetourAnalytics.logConversion()`. Own endpoint rather than riding `/api/analytics/event`, since + revenue reporting is a distinct signal from generic event logging (see + `analyticsEmitter.ts#AnalyticsEmitterPayload`'s `"conversion"` kind) — not an event with revenue + bolted on. + +The match-link, event, retention, and conversion endpoints all share the same **identity signals** +(`device_id`/`install_id`, `idfv`, `aaid`, `idfa`, `customer_user_id`) — that's what lets the +backend stitch together "the click that led to this install" and "the events/conversions this +install later produced" into one continuous record, instead of seeing them as unrelated facts. See +`shared/` for where each signal is collected, and `analytics/utils/buildAnalyticsContext.ts` / +`links/utils/fingerprint.ts#collectIdentityFields` for where they get assembled into a request. + +## Identity fields (shared across match-link, event, retention, and conversion) + +| Field | Source | Why | +|---|---|---| +| `device_id` (events/retention) / `install_id` (fingerprint) | `shared/devicePersistence.ts` — random UUID, generated once, persisted to storage | Anchors every event from one installation together before (or absent) a logged-in user. Survives app restarts, not reinstalls. | +| `idfv` | `shared/deviceIdentifiers.ts` (`Application.getIosIdForVendorAsync`) | iOS per-vendor ID. Available without ATT consent — the most reliable iOS identity signal pre-login. | +| `aaid` | `shared/deviceIdentifiers.ts` (`expo-tracking-transparency`'s `getAdvertisingId`, Android) | Android advertising ID. Feeds the backend's deterministic `device_uuid` for cross-MMP migration matching. | +| `idfa` | Same native call, iOS | Ad-attribution signal only — doesn't feed `device_uuid` (IDFV does). Empty unless ATT is granted. | +| `customer_user_id` | `shared/userIdentity.ts`, set via `DetourAnalytics.setUserId()` | The one identifier that survives reinstalls and is shared across a user's devices — the root key for merging anonymous and identified activity. | + +## Event / retention payload (`analytics/types/index.ts#AnalyticsContext`) + +**Wire shape:** `event_name`, `data` (events only), `timestamp`, `platform`, `device_id` stay +top-level — that's exactly what the backend's current `/api/analytics/event` and +`/api/analytics/retention` endpoints already read and store today. Everything below is new and not +yet persisted server-side (see "Not yet stored server-side" below) — it's grouped under one +`metadata` object (`buildAnalyticsContext.ts#toMetadataFields`) instead of more top-level keys, so +the existing endpoint stays backward-compatible (it already ignores unknown body fields) and adding +backend support later means parsing one object, not hunting down individually-added fields. + +| Field (inside `metadata`) | Source | Why | +|---|---|---| +| `idfv` / `aaid` / `idfa` / `customer_user_id` | See identity fields above | Same identity signals as the fingerprint payload. | +| `app_version` / `build_number` | `shared/appInfo.ts` (`expo-application`) | Groups events by release; `build_number` distinguishes builds within the same marketing version. | +| `os_version` | `shared/deviceInfo.ts` | Basic diagnostic/segmentation context. | +| `locale` | `expo-localization`, collected in `buildAnalyticsContext.ts` | Segmentation without relying on IP-based guesses. | +| `att_status` | `shared/deviceIdentifiers.ts` | Explicit ATT state (`granted`/`denied`/`undetermined`/`unavailable`) — otherwise indistinguishable from "not asked yet" if inferred only from a missing `idfa`. | +| `consent` | `shared/consent.ts` (`{ ad, analytics, tracking, source, updatedAt }`) | Audit trail of what the user had consented to when the event was logged. Auto-derived from ATT (iOS) / AAID opt-out (Android) unless the host calls `setConsent()` directly, which always wins (see `applyAutoConsent`'s manual-source guard). | +| `session_id` | `analytics/hooks/useSessionTracking.tsx` | Groups a contiguous stretch of activity (funnels, time-in-session). Rotates after 30 min in background. | + +## Conversion payload (`analytics/api/conversion.ts`) + +**Wire shape:** `event_name`, `revenue`, `currency`, `product_id`, `quantity`, `transaction_id`, +`timestamp`, `platform`, `device_id` top-level, plus the same identity `metadata` object as +events/retention. `event_name` is a required argument to `logConversion()` (no default) — the +reviewer flagged that defaulting silently to `DetourEventNames.Purchase` on a caller typo would +misattribute revenue to the wrong event. + +| Field | Source | Why | +|---|---|---| +| `revenue` / `currency` / `product_id` / `quantity` / `transaction_id` | Host-supplied via `DetourAnalytics.logConversion()` | Revenue as first-class top-level fields (not buried in `data` or nested under `metadata`) so the backend can aggregate ROAS without per-host parsing conventions. | + +## Fingerprint payload (`links/utils/fingerprint.ts`) + +Deterministic (Android install referrer present): identity fields + `clickId` + `utm`. + +Probabilistic (fallback): identity fields + `platform`, `model`, `manufacturer`, `systemVersion`, +`screenWidth`/`screenHeight`/`scale`, `locale`, `timezone`, `userAgent`, `timestamp`, `pastedLink` +(iOS clipboard, only when `shouldUseClipboard` is on), `utm`. + +`utm` comes from parsing the Android install referrer (`links/utils/urlHelpers.ts#parseUtmParams`) +— campaign attribution for installs that arrive with UTM-tagged links. + +## Universal Link click payload (`links/api/sendUniversalLinkClick.ts`) + +`url`, `timestamp`, `platform`, `params` (query params extracted from the clicked URL), and +`metadata: { os_version, app_version, device_model }` — enough context to rate-limit and debug +click volume without duplicating the full identity/analytics payload for a runtime link open. + +## What's collected but never used to gate anything + +Nothing gates on `consent` today — it's recorded as a field on every request, not used to skip +collection. See the open discussion in the PR about whether `ad`/`tracking: false` should stop +AAID/IDFA collection specifically (deferred, pending team discussion). diff --git a/packages/react-native-detour/package.json b/packages/react-native-detour/package.json index 71b346a..2d517ac 100644 --- a/packages/react-native-detour/package.json +++ b/packages/react-native-detour/package.json @@ -78,6 +78,7 @@ "expo-constants": "~55.0.11", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-tracking-transparency": "~55.0.11", "jest": "^29.7.0", "react": "19.2.0", "react-native": "0.83.4", @@ -92,6 +93,7 @@ "expo-constants": ">=17.1.7", "expo-device": ">=7.0.0", "expo-localization": ">=15.0.0", + "expo-tracking-transparency": ">=5.0.0", "react": ">=18", "react-native": ">=0.72", "react-native-device-info": ">=10.0.0" @@ -103,6 +105,9 @@ "expo-device": { "optional": true }, + "expo-tracking-transparency": { + "optional": true + }, "react-native-device-info": { "optional": true } diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 97fe5a3..e91897b 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -2,15 +2,14 @@ import { type PropsWithChildren, createContext, useContext, useEffect } from "re import { Platform } from "react-native"; -import { sendEvent } from "./analytics/api/events"; -import { sendRetentionEvent } from "./analytics/api/retention"; import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; -import type { DetourEvent, DetourEventNames } from "./analytics/types"; +import { useSessionTracking } from "./analytics/hooks/useSessionTracking"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; -import { prepareDeviceIdForApi } from "./analytics/utils/devicePersistence"; +import { dispatchAnalyticsEvent } from "./analytics/utils/dispatchAnalyticsEvent"; import { useDetour } from "./links/hooks/useDetour"; import type { Config, DetourContextType } from "./links/types"; -import { resolveStorage } from "./links/utils/storage"; +import { requestTrackingPermission } from "./shared/deviceIdentifiers"; +import { resolveStorage } from "./shared/storage"; type Props = PropsWithChildren & { config: Config }; @@ -32,18 +31,24 @@ const DetourProviderNative = ({ config, children }: Props) => { shouldUseClipboard = true, storage: userStorage, linkProcessingMode = "all", + shouldRequestTrackingPermission = false, } = config; const storage = resolveStorage(userStorage); + useEffect(() => { + if (!shouldRequestTrackingPermission) return; + requestTrackingPermission(); + }, [shouldRequestTrackingPermission]); + useEffect(() => { activeProviderCount++; - const unsubscribe = analyticsEmitter.subscribe(async ({ eventName, data, isRetention }) => { + const unsubscribe = analyticsEmitter.subscribe((payload) => { if (activeProviderCount > 1) { if (__DEV__) { console.error( - `🔗[Detour:ANALYTICS_ERROR] Event "${eventName}" dropped. ` + + `🔗[Detour:ANALYTICS_ERROR] Event "${payload.eventName}" dropped. ` + `Multiple DetourProviders (${activeProviderCount}) detected. ` + "Analytics logging is disabled until only one provider remains.", ); @@ -51,29 +56,7 @@ const DetourProviderNative = ({ config, children }: Props) => { return; } - try { - const deviceId = await prepareDeviceIdForApi(storage); - - if (isRetention) { - sendRetentionEvent({ apiKey, appID, eventName, deviceId }); - } else { - const event: DetourEvent = { - eventName: eventName as DetourEventNames, - data, - }; - sendEvent({ - apiKey, - appID, - event, - deviceId, - }); - } - } catch (error) { - console.error( - "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", - error, - ); - } + dispatchAnalyticsEvent(payload, { apiKey, appID, storage }); }); return () => { @@ -90,6 +73,7 @@ const DetourProviderNative = ({ config, children }: Props) => { linkProcessingMode, }); useAppOpenRetention(); + useSessionTracking(); return {children}; }; diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 22104ff..cf53e62 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,15 +1,32 @@ +import { setConsent } from "../shared/consent"; +import { setAdvertisingId, setTrackingAuthorizationStatus } from "../shared/deviceIdentifiers"; +import { setUserId } from "../shared/userIdentity"; import { DetourEventNames } from "./types"; +import type { Conversion } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => { - analyticsEmitter.emit({ eventName, data }); + analyticsEmitter.emit({ kind: "event", eventName, data }); }; export const logRetention = (retentionEventName: string) => { - analyticsEmitter.emit({ eventName: retentionEventName, isRetention: true }); + analyticsEmitter.emit({ kind: "retention", eventName: retentionEventName }); +}; + +export type ConversionParams = Conversion & { + eventName: string; +}; + +export const logConversion = ({ eventName, ...conversion }: ConversionParams) => { + analyticsEmitter.emit({ kind: "conversion", eventName, conversion }); }; export const DetourAnalytics = { logEvent, logRetention, + logConversion, + setUserId, + setConsent, + setAdvertisingId, + setTrackingAuthorizationStatus, }; diff --git a/packages/react-native-detour/src/analytics/api/conversion.ts b/packages/react-native-detour/src/analytics/api/conversion.ts new file mode 100644 index 0000000..ce923bb --- /dev/null +++ b/packages/react-native-detour/src/analytics/api/conversion.ts @@ -0,0 +1,50 @@ +import { Platform } from "react-native"; + +import { SDK_HEADER_VALUE } from "../../version"; +import type { AnalyticsContext, Conversion } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; + +const CONVERSION_API_URL = "https://godetour.dev/api/analytics/conversion"; + +export const sendConversion = async ({ + apiKey, + appID, + eventName, + conversion, + ...ctx +}: { + apiKey: string; + appID: string; + eventName: string; + conversion: Conversion; +} & AnalyticsContext) => { + try { + const response = await fetch(CONVERSION_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-App-ID": appID, + "X-SDK": SDK_HEADER_VALUE, + }, + body: JSON.stringify({ + event_name: eventName, + revenue: conversion.revenue, + currency: conversion.currency, + product_id: conversion.productId, + quantity: conversion.quantity, + transaction_id: conversion.transactionId, + timestamp: new Date().toISOString(), + platform: Platform.OS, + device_id: ctx.deviceId, + metadata: toMetadataFields(ctx), + }), + }); + + if (!response.ok) { + console.warn(`🔗[Detour:ANALYTICS_ERROR] Failed to log conversion: ${response.status}`); + } + } catch (error) { + console.error("🔗[Detour:ANALYTICS_ERROR] Network error logging conversion:", error); + } +}; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 854df78..3ef3d80 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -1,21 +1,21 @@ import { Platform } from "react-native"; import { SDK_HEADER_VALUE } from "../../version"; -import type { DetourEvent } from "../types"; +import type { AnalyticsContext, DetourEvent } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; const EVENT_API_URL = "https://godetour.dev/api/analytics/event"; export const sendEvent = async ({ apiKey, appID, - deviceId, event, + ...ctx }: { apiKey: string; appID: string; event: DetourEvent; - deviceId: string; -}) => { +} & AnalyticsContext) => { try { const response = await fetch(EVENT_API_URL, { method: "POST", @@ -30,7 +30,8 @@ export const sendEvent = async ({ data: event.data, timestamp: new Date().toISOString(), platform: Platform.OS, - device_id: deviceId, + device_id: ctx.deviceId, + metadata: toMetadataFields(ctx), }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 927b81c..d3a3781 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -1,20 +1,17 @@ import { Platform } from "react-native"; import { SDK_HEADER_VALUE } from "../../version"; +import type { AnalyticsContext } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; const RETENTION_API_URL = "https://godetour.dev/api/analytics/retention"; export const sendRetentionEvent = async ({ apiKey, appID, - deviceId, eventName, -}: { - apiKey: string; - appID: string; - eventName: string; - deviceId: string; -}) => { + ...ctx +}: { apiKey: string; appID: string; eventName: string } & AnalyticsContext) => { try { const response = await fetch(RETENTION_API_URL, { method: "POST", @@ -28,7 +25,8 @@ export const sendRetentionEvent = async ({ event_name: eventName, timestamp: new Date().toISOString(), platform: Platform.OS, - device_id: deviceId, + device_id: ctx.deviceId, + metadata: toMetadataFields(ctx), }), }); diff --git a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx new file mode 100644 index 0000000..73a0331 --- /dev/null +++ b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; + +import { AppState, type AppStateStatus } from "react-native"; + +import { generateUUID } from "../../shared/uuid"; + +// In-memory only — a cold start is inherently a new session. +let currentSessionId: string = generateUUID(); +let backgroundedAt: number | null = null; + +// 30 min background before a re-open counts as a fresh session (same +// threshold as Google Analytics/Adjust). +const SESSION_TIMEOUT_MS = 30 * 60 * 1000; + +const handleAppStateChange = (nextState: AppStateStatus) => { + if (nextState === "background" || nextState === "inactive") { + backgroundedAt = Date.now(); + return; + } + + if (nextState === "active" && backgroundedAt !== null) { + if (Date.now() - backgroundedAt > SESSION_TIMEOUT_MS) { + currentSessionId = generateUUID(); + } + backgroundedAt = null; + } +}; + +export const useSessionTracking = () => { + useEffect(() => { + const subscription = AppState.addEventListener("change", handleAppStateChange); + return () => { + subscription.remove(); + }; + }, []); +}; + +export const getSessionId = (): string => currentSessionId; diff --git a/packages/react-native-detour/src/analytics/types/index.ts b/packages/react-native-detour/src/analytics/types/index.ts index 4eb9295..89c869f 100644 --- a/packages/react-native-detour/src/analytics/types/index.ts +++ b/packages/react-native-detour/src/analytics/types/index.ts @@ -1,3 +1,6 @@ +import type { Consent } from "../../shared/consent"; +import type { AttStatus } from "../../shared/deviceIdentifiers"; + export enum DetourEventNames { // general Login = "login", @@ -25,3 +28,26 @@ export type DetourEvent = { eventName: DetourEventNames; data?: any; }; + +export type Conversion = { + revenue: number; + currency: string; + productId?: string; + quantity?: number; + transactionId?: string; +}; + +export type AnalyticsContext = { + deviceId: string; + idfv?: string; + aaid?: string; + idfa?: string; + customerUserId?: string; + appVersion?: string; + buildNumber?: string; + consent?: Consent; + osVersion?: string; + locale?: { languageTag: string }[]; + attStatus?: AttStatus; + sessionId?: string; +}; diff --git a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts index 1a4bb0e..9cf4a1a 100644 --- a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts +++ b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts @@ -1,14 +1,11 @@ -import type { DetourEventNames } from "../types"; +import type { Conversion, DetourEventNames } from "../types"; -type AnalyticsListener = ({ - eventName, - data, - isRetention, -}: { - eventName: string | DetourEventNames; - data?: any; - isRetention?: boolean; -}) => void; +export type AnalyticsEmitterPayload = + | { kind: "event"; eventName: string | DetourEventNames; data?: any } + | { kind: "retention"; eventName: string } + | { kind: "conversion"; eventName: string; conversion: Conversion }; + +type AnalyticsListener = (payload: AnalyticsEmitterPayload) => void; let listeners: AnalyticsListener[] = []; @@ -20,21 +17,13 @@ export const analyticsEmitter = { }; }, - emit: ({ - eventName, - data, - isRetention, - }: { - eventName: string | DetourEventNames; - data?: any; - isRetention?: boolean; - }) => { + emit: (payload: AnalyticsEmitterPayload) => { if (listeners.length === 0) { console.warn( "🔗[Detour:ANALYTICS_WARNING] DetourAnalytics method called but DetourProvider is not mounted. Event dropped.", ); return; } - listeners.forEach((listener) => listener({ eventName, data, isRetention })); + listeners.forEach((listener) => listener(payload)); }, }; diff --git a/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts new file mode 100644 index 0000000..5309885 --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts @@ -0,0 +1,57 @@ +import * as Localization from "expo-localization"; + +import { getAppVersion, getBuildNumber } from "../../shared/appInfo"; +import { getConsent } from "../../shared/consent"; +import { collectDeviceIdentitySignals } from "../../shared/deviceIdentifiers"; +import { getSafeOsVersion } from "../../shared/deviceInfo"; +import { prepareDeviceIdForApi } from "../../shared/devicePersistence"; +import type { DetourStorage } from "../../shared/storage"; +import { getUserId } from "../../shared/userIdentity"; +import { getSessionId } from "../hooks/useSessionTracking"; +import type { AnalyticsContext } from "../types"; + +export async function buildAnalyticsContext(storage: DetourStorage): Promise { + const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + + const customerUserId = getUserId(); + const appVersion = getAppVersion(); + const buildNumber = getBuildNumber(); + const consent = getConsent(); + const osVersion = getSafeOsVersion(); + const locale = Localization.getLocales().map((l) => ({ languageTag: l.languageTag })); + const sessionId = getSessionId(); + + return { + deviceId, + idfv, + aaid, + idfa, + customerUserId, + appVersion, + buildNumber, + consent, + osVersion, + locale, + attStatus, + sessionId, + }; +} + +export default buildAnalyticsContext; + +export const toMetadataFields = (ctx: AnalyticsContext) => ({ + idfv: ctx.idfv, + aaid: ctx.aaid, + idfa: ctx.idfa, + customer_user_id: ctx.customerUserId, + app_version: ctx.appVersion, + build_number: ctx.buildNumber, + consent: ctx.consent, + os_version: ctx.osVersion, + locale: ctx.locale, + att_status: ctx.attStatus, + session_id: ctx.sessionId, +}); diff --git a/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts new file mode 100644 index 0000000..dd3e58b --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts @@ -0,0 +1,54 @@ +import type { DetourStorage } from "../../shared/storage"; +import { sendConversion } from "../api/conversion"; +import { sendEvent } from "../api/events"; +import { sendRetentionEvent } from "../api/retention"; +import type { DetourEvent, DetourEventNames } from "../types"; +import type { AnalyticsEmitterPayload } from "./analyticsEmitter"; +import buildAnalyticsContext from "./buildAnalyticsContext"; + +export type DispatchAnalyticsEventConfig = { + apiKey: string; + appID: string; + storage: DetourStorage; +}; + +export const dispatchAnalyticsEvent = async ( + payload: AnalyticsEmitterPayload, + { apiKey, appID, storage }: DispatchAnalyticsEventConfig, +): Promise => { + try { + const analyticsContext = await buildAnalyticsContext(storage); + + if (payload.kind === "retention") { + await sendRetentionEvent({ + apiKey, + appID, + eventName: payload.eventName, + ...analyticsContext, + }); + return; + } + + if (payload.kind === "conversion") { + await sendConversion({ + apiKey, + appID, + eventName: payload.eventName, + conversion: payload.conversion, + ...analyticsContext, + }); + return; + } + + const event: DetourEvent = { + eventName: payload.eventName as DetourEventNames, + data: payload.data, + }; + await sendEvent({ apiKey, appID, event, ...analyticsContext }); + } catch (error) { + console.error( + "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", + error, + ); + } +}; diff --git a/packages/react-native-detour/src/links/api/getDeferredLink.ts b/packages/react-native-detour/src/links/api/getDeferredLink.ts index 88a832f..34a890b 100644 --- a/packages/react-native-detour/src/links/api/getDeferredLink.ts +++ b/packages/react-native-detour/src/links/api/getDeferredLink.ts @@ -1,13 +1,15 @@ import * as Application from "expo-application"; import { SDK_HEADER_VALUE } from "../../version"; -import type { RequiredConfig } from "../types"; +import type { DetourStorage, RequiredConfig } from "../types"; import { type DeterministicFingerprint, type ProbabilisticFingerprint, + collectIdentityFields, getDeterministicFingerprint, getProbabilisticFingerprint, } from "../utils/fingerprint"; +import { parseUtmParams } from "../utils/urlHelpers"; const API_URL = "https://godetour.dev/api/link/match-link"; @@ -38,7 +40,10 @@ export const getDeferredLink = async ({ apiKey: API_KEY, appID, shouldUseClipboard, -}: Pick) => { + storage, +}: Pick & { + storage: DetourStorage; +}) => { let referrer: string | null = null; try { referrer = await Application.getInstallReferrerAsync(); @@ -49,16 +54,28 @@ export const getDeferredLink = async ({ const decodedReferrer = decodeURIComponent(referrer ?? ""); const matchClickId = decodedReferrer.match(/(?:^|&)click_id=([^&]+)/); const referrerClickId = matchClickId ? matchClickId[1] : null; + const utm = parseUtmParams(decodedReferrer); + const identityFields = await collectIdentityFields(storage); let response; if (referrerClickId?.length) { + const deterministicFingerprint: DeterministicFingerprint = { + ...identityFields, + ...getDeterministicFingerprint(referrerClickId), + utm, + }; + response = await sendFingerprint({ API_KEY, appID, - requestBody: getDeterministicFingerprint(referrerClickId), + requestBody: deterministicFingerprint, }); } else { - const probabilisticFingerprint = await getProbabilisticFingerprint(shouldUseClipboard); + const probabilisticFingerprint: ProbabilisticFingerprint = { + ...identityFields, + ...(await getProbabilisticFingerprint(shouldUseClipboard)), + utm, + }; response = await sendFingerprint({ API_KEY, diff --git a/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts b/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts index a4a227a..be6b936 100644 --- a/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts +++ b/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts @@ -1,10 +1,9 @@ import { Platform } from "react-native"; -import Constants from "expo-constants"; - +import { getAppVersion } from "../../shared/appInfo"; +import { getSyncDeviceInfo } from "../../shared/deviceInfo"; import { SDK_HEADER_VALUE } from "../../version"; import type { RequiredConfig } from "../types"; -import { getSyncDeviceInfo } from "../utils/deviceInfo"; const API_URL = "https://godetour.dev/api/link/universal-link-click"; @@ -51,7 +50,7 @@ const buildMetadata = (): Record => { const raw: Record = { os_version: osVersion, - app_version: Constants.nativeAppVersion, + app_version: getAppVersion(), device_model: model, }; diff --git a/packages/react-native-detour/src/links/hooks/useDetour.ts b/packages/react-native-detour/src/links/hooks/useDetour.ts index 0c773bf..822519c 100644 --- a/packages/react-native-detour/src/links/hooks/useDetour.ts +++ b/packages/react-native-detour/src/links/hooks/useDetour.ts @@ -225,6 +225,7 @@ export const useDetour = ({ apiKey, appID, shouldUseClipboard, + storage, }); if (apiLink) { diff --git a/packages/react-native-detour/src/links/types/index.ts b/packages/react-native-detour/src/links/types/index.ts index f254ad0..fb54979 100644 --- a/packages/react-native-detour/src/links/types/index.ts +++ b/packages/react-native-detour/src/links/types/index.ts @@ -1,3 +1,5 @@ +import type { DetourStorage } from "../../shared/storage"; + export type Config = { appID: string; apiKey: string; @@ -11,6 +13,15 @@ export type Config = { * (recommended when Expo Router native-intent handler already resolves runtime/initial links) */ linkProcessingMode?: LinkProcessingMode; + /** + * If `true`, Detour triggers the native App Tracking Transparency prompt on + * iOS (via `expo-tracking-transparency`) shortly after the provider mounts, + * so it can read the IDFA. No-op on Android/web. Default: `false` — the end + * user's consent belongs to the host app, so this stays opt-in and the host + * app remains free to request permission itself at a better-timed moment + * (e.g. after an explanatory screen). + */ + shouldRequestTrackingPermission?: boolean; }; export type LinkProcessingMode = "all" | "web-only" | "deferred-only"; @@ -40,11 +51,7 @@ export type DetourContextType = { clearLink: () => void; }; -export interface DetourStorage { - getItem(key: string): Promise | string | null; - setItem(key: string, value: string): Promise | void; - removeItem?(key: string): Promise | void; -} +export type { DetourStorage }; export type DetourUrlEvent = { url: string; diff --git a/packages/react-native-detour/src/links/utils/appEntrance.ts b/packages/react-native-detour/src/links/utils/appEntrance.ts index 8b5c7ac..fa80a61 100644 --- a/packages/react-native-detour/src/links/utils/appEntrance.ts +++ b/packages/react-native-detour/src/links/utils/appEntrance.ts @@ -1,5 +1,5 @@ +import { StorageKeys } from "../../shared/storage"; import type { DetourStorage } from "../types"; -import { StorageKeys } from "./storage"; export const markFirstEntrance = async (storage: DetourStorage) => { await storage.setItem(StorageKeys.FIRST_ENTRANCE_FLAG_KEY, "true"); diff --git a/packages/react-native-detour/src/links/utils/fingerprint.ts b/packages/react-native-detour/src/links/utils/fingerprint.ts index afa5e06..3e4234a 100644 --- a/packages/react-native-detour/src/links/utils/fingerprint.ts +++ b/packages/react-native-detour/src/links/utils/fingerprint.ts @@ -4,9 +4,24 @@ import * as Clipboard from "expo-clipboard"; import Constants from "expo-constants"; import * as Localization from "expo-localization"; -import { getDeviceInfo } from "./deviceInfo"; +import { collectDeviceIdentitySignals } from "../../shared/deviceIdentifiers"; +import { getDeviceInfo } from "../../shared/deviceInfo"; +import { prepareDeviceIdForApi } from "../../shared/devicePersistence"; +import { getUserId } from "../../shared/userIdentity"; +import type { DetourStorage } from "../types"; -export type ProbabilisticFingerprint = { +// Identity signals shared by both fingerprint variants — this is what lets +// match-link recognize a device via the same identity graph keys used by +// events, instead of only ever seeing a fresh "install". +export type DeviceIdentityFields = { + install_id: string; + idfv?: string; + aaid?: string; + idfa?: string; + customer_user_id?: string; +}; + +export type ProbabilisticFingerprint = DeviceIdentityFields & { platform: string; model: string; manufacturer: string; @@ -19,22 +34,39 @@ export type ProbabilisticFingerprint = { userAgent: string; timestamp: number; pastedLink?: string; + utm?: Record; }; // used when install referrer on android is available -export type DeterministicFingerprint = { +export type DeterministicFingerprint = DeviceIdentityFields & { clickId: string; + utm?: Record; }; -export const getDeterministicFingerprint = (clickId: string): DeterministicFingerprint => { +export const collectIdentityFields = async ( + storage: DetourStorage, +): Promise => { + const [installId, { idfv, aaid, idfa }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + return { - clickId, + install_id: installId, + idfv, + aaid, + idfa, + customer_user_id: getUserId(), }; }; +export const getDeterministicFingerprint = (clickId: string): { clickId: string } => ({ + clickId, +}); + export const getProbabilisticFingerprint = async ( shouldUseClipboard: boolean, -): Promise => { +): Promise> => { const { width, height } = Dimensions.get("screen"); const locales = Localization.getLocales(); const localeLanguageTags = locales.map((locale) => ({ diff --git a/packages/react-native-detour/src/links/utils/urlHelpers.ts b/packages/react-native-detour/src/links/utils/urlHelpers.ts index d8293ce..de01a99 100644 --- a/packages/react-native-detour/src/links/utils/urlHelpers.ts +++ b/packages/react-native-detour/src/links/utils/urlHelpers.ts @@ -35,3 +35,16 @@ export function getRouteFromDeepLink(urlObj: URL): string { const route = urlObj.host + urlObj.pathname + (urlObj.search ?? ""); return route.startsWith("/") ? route : `/${route}`; } + +export function parseUtmParams(decodedReferrer: string): Record | undefined { + const params = new URLSearchParams(decodedReferrer); + const utm: Record = {}; + + for (const [key, value] of params) { + if (key.startsWith("utm_") && value) { + utm[key] = value; + } + } + + return Object.keys(utm).length > 0 ? utm : undefined; +} diff --git a/packages/react-native-detour/src/shared/appInfo.ts b/packages/react-native-detour/src/shared/appInfo.ts new file mode 100644 index 0000000..cea9444 --- /dev/null +++ b/packages/react-native-detour/src/shared/appInfo.ts @@ -0,0 +1,6 @@ +import * as Application from "expo-application"; + +export const getAppVersion = (): string | undefined => + Application.nativeApplicationVersion ?? undefined; + +export const getBuildNumber = (): string | undefined => Application.nativeBuildVersion ?? undefined; diff --git a/packages/react-native-detour/src/shared/consent.ts b/packages/react-native-detour/src/shared/consent.ts new file mode 100644 index 0000000..009bdd9 --- /dev/null +++ b/packages/react-native-detour/src/shared/consent.ts @@ -0,0 +1,23 @@ +// In-memory only, reset on every cold start. `ad`/`tracking` self-heal via +// ATT/AAID re-derivation (the OS remembers); manual overrides don't — the +// host must call setConsent() again each cold start for those. +export type Consent = { + ad?: boolean; + analytics?: boolean; + tracking?: boolean; + source?: "att" | "aaid-optout" | "manual"; + updatedAt?: number; +}; + +let consent: Consent | undefined; + +export const setConsent = (update: Consent): void => { + consent = { ...consent, ...update, source: update.source ?? "manual", updatedAt: Date.now() }; +}; + +export const applyAutoConsent = (update: Omit): void => { + if (consent?.source === "manual") return; + consent = { ...consent, ...update, updatedAt: Date.now() }; +}; + +export const getConsent = (): Consent | undefined => consent; diff --git a/packages/react-native-detour/src/shared/deviceIdentifiers.ts b/packages/react-native-detour/src/shared/deviceIdentifiers.ts new file mode 100644 index 0000000..51428a9 --- /dev/null +++ b/packages/react-native-detour/src/shared/deviceIdentifiers.ts @@ -0,0 +1,193 @@ +import { Platform } from "react-native"; + +import * as Application from "expo-application"; + +import { applyAutoConsent } from "./consent"; + +// Opt-out sentinel — collides every opted-out device into the same fake AAID. +const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +let manualAdvertisingId: string | undefined; + +export type AttStatus = "granted" | "denied" | "undetermined" | "unavailable"; +const VALID_ATT_STATUSES: AttStatus[] = ["granted", "denied", "undetermined", "unavailable"]; + +let manualAttStatus: AttStatus | undefined; + +type TrackingTransparencyModule = { + getAdvertisingId?: () => Promise; + requestTrackingPermissionsAsync?: () => Promise<{ status: string }>; + getTrackingPermissionsAsync?: () => Promise<{ status: string }>; +}; + +const trackingTransparency = (() => { + try { + return require("expo-tracking-transparency") as TrackingTransparencyModule; + } catch { + return null; + } +})(); + +export const getIdfv = async (): Promise => { + if (Platform.OS !== "ios") return undefined; + try { + const idfv = await Application.getIosIdForVendorAsync(); + return idfv ?? undefined; + } catch { + return undefined; + } +}; + +const fetchRawAdvertisingId = async (): Promise => { + if (!trackingTransparency?.getAdvertisingId) return null; + try { + return await trackingTransparency.getAdvertisingId(); + } catch { + return null; + } +}; + +const getRawAdvertisingId = async (): Promise => { + const id = await fetchRawAdvertisingId(); + if (!id || id === AD_ID_OPT_OUT) return undefined; + return id; +}; + +const applyAaidAutoConsent = (rawId: string | null): void => { + if (!rawId) return; + applyAutoConsent({ ad: rawId !== AD_ID_OPT_OUT, source: "aaid-optout" }); +}; + +export const getAaid = async (): Promise => { + if (Platform.OS !== "android") return undefined; + if (manualAdvertisingId) return manualAdvertisingId; + const rawId = await fetchRawAdvertisingId(); + applyAaidAutoConsent(rawId); + if (!rawId || rawId === AD_ID_OPT_OUT) return undefined; + return rawId; +}; + +export const getIdfa = async (): Promise => { + if (Platform.OS !== "ios") return undefined; + if (manualAdvertisingId) return manualAdvertisingId; + return getRawAdvertisingId(); +}; + +// Apple ties tracking + ad personalization to one ATT prompt, so a determined +// status implies both consent flags. Skips "undetermined" — we don't know yet. +const applyAttAutoConsent = (status: AttStatus): void => { + if (status !== "granted" && status !== "denied") return; + applyAutoConsent({ tracking: status === "granted", ad: status === "granted", source: "att" }); +}; + +const getRawAttStatus = async (): Promise => { + if (Platform.OS !== "ios" || !trackingTransparency?.getTrackingPermissionsAsync) { + return "unavailable"; + } + try { + const { status } = await trackingTransparency.getTrackingPermissionsAsync(); + return status as AttStatus; + } catch { + return "unavailable"; + } +}; + +// Reads ATT state without showing the system prompt, so att_status is known +// even when shouldRequestTrackingPermission is off. +export const getAttStatus = async (): Promise => { + if (manualAttStatus) return manualAttStatus; + const status = await getRawAttStatus(); + applyAttAutoConsent(status); + return status; +}; + +export type DeviceIdentitySignals = { + idfv?: string; + aaid?: string; + idfa?: string; + attStatus: AttStatus; +}; + +let cachedSignals: DeviceIdentitySignals | null = null; +let pendingSignalsPromise: Promise | null = null; +let signalsGeneration = 0; + +export const collectDeviceIdentitySignals = async (): Promise => { + if (cachedSignals) { + return cachedSignals; + } + + if (pendingSignalsPromise) { + return pendingSignalsPromise; + } + + const generation = ++signalsGeneration; + + pendingSignalsPromise = (async () => { + const [idfv, aaid, idfa, attStatus] = await Promise.all([ + getIdfv(), + getAaid(), + getIdfa(), + getAttStatus(), + ]); + const signals = { idfv, aaid, idfa, attStatus }; + // Skip the write if a reset happened mid-flight — otherwise this stale + // result would silently clobber a host override set in the meantime. + if (generation === signalsGeneration) { + cachedSignals = signals; + } + return signals; + })(); + + try { + return await pendingSignalsPromise; + } finally { + pendingSignalsPromise = null; + } +}; + +const resetDeviceIdentitySignalsCache = (): void => { + cachedSignals = null; + pendingSignalsPromise = null; + signalsGeneration++; +}; + +// Lets the host inject an AAID/IDFA it already collected another way, +// skipping our own native call for the rest of the session. +export const setAdvertisingId = (id: string): void => { + if (!UUID_PATTERN.test(id)) { + console.warn( + `🔗[Detour:INVALID_ARGUMENT] setAdvertisingId("${id}") ignored — expected a UUID-formatted AAID/IDFA.`, + ); + return; + } + manualAdvertisingId = id; + resetDeviceIdentitySignalsCache(); +}; +export const requestTrackingPermission = async (): Promise => { + if (Platform.OS !== "ios" || !trackingTransparency?.requestTrackingPermissionsAsync) return; + try { + const { status } = await trackingTransparency.requestTrackingPermissionsAsync(); + applyAttAutoConsent(status as AttStatus); + } catch { + // Best-effort — a failed/denied request just leaves idfa undefined downstream. + } finally { + resetDeviceIdentitySignalsCache(); + } +}; + +// Override for hosts whose native ATT module isn't expo-tracking-transparency. +export const setTrackingAuthorizationStatus = (status: AttStatus): void => { + if (!VALID_ATT_STATUSES.includes(status)) { + console.warn( + `🔗[Detour:INVALID_ARGUMENT] setTrackingAuthorizationStatus("${status}") ignored — ` + + `expected one of: ${VALID_ATT_STATUSES.join(", ")}.`, + ); + return; + } + manualAttStatus = status; + applyAttAutoConsent(status); + resetDeviceIdentitySignalsCache(); +}; diff --git a/packages/react-native-detour/src/links/utils/deviceInfo.ts b/packages/react-native-detour/src/shared/deviceInfo.ts similarity index 93% rename from packages/react-native-detour/src/links/utils/deviceInfo.ts rename to packages/react-native-detour/src/shared/deviceInfo.ts index c6021ed..98efe40 100644 --- a/packages/react-native-detour/src/links/utils/deviceInfo.ts +++ b/packages/react-native-detour/src/shared/deviceInfo.ts @@ -117,6 +117,15 @@ export const getSyncDeviceInfo = (): SyncDeviceInfo => { }; }; +export const getSafeOsVersion = (): string | undefined => { + try { + const { osVersion } = getSyncDeviceInfo(); + return osVersion === UNKNOWN ? undefined : osVersion; + } catch { + return undefined; + } +}; + export const getDeviceInfo = async (): Promise => { assertDeviceInfoLibraryAvailable(); diff --git a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts b/packages/react-native-detour/src/shared/devicePersistence.ts similarity index 72% rename from packages/react-native-detour/src/analytics/utils/devicePersistence.ts rename to packages/react-native-detour/src/shared/devicePersistence.ts index a140781..a5ee7c0 100644 --- a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts +++ b/packages/react-native-detour/src/shared/devicePersistence.ts @@ -1,14 +1,6 @@ -/* eslint-disable no-bitwise */ -import type { DetourStorage } from "../../links/types"; -import { StorageKeys } from "../../links/utils/storage"; - -const generateUUID = () => { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -}; +import type { DetourStorage } from "./storage"; +import { StorageKeys } from "./storage"; +import { generateUUID } from "./uuid"; const saveDeviceId = async (storage: DetourStorage, id: string) => { await storage.setItem(StorageKeys.DEVICE_ID_KEY, id); diff --git a/packages/react-native-detour/src/links/utils/storage.ts b/packages/react-native-detour/src/shared/storage.ts similarity index 81% rename from packages/react-native-detour/src/links/utils/storage.ts rename to packages/react-native-detour/src/shared/storage.ts index 5b28614..d0db8c4 100644 --- a/packages/react-native-detour/src/links/utils/storage.ts +++ b/packages/react-native-detour/src/shared/storage.ts @@ -1,4 +1,8 @@ -import type { DetourStorage } from "../types"; +export interface DetourStorage { + getItem(key: string): Promise | string | null; + setItem(key: string, value: string): Promise | void; + removeItem?(key: string): Promise | void; +} const STORAGE_KEY_PREFIX = "Detour_"; const FIRST_ENTRANCE_FLAG = `${STORAGE_KEY_PREFIX}firstEntranceFlag`; diff --git a/packages/react-native-detour/src/shared/userIdentity.ts b/packages/react-native-detour/src/shared/userIdentity.ts new file mode 100644 index 0000000..8539aba --- /dev/null +++ b/packages/react-native-detour/src/shared/userIdentity.ts @@ -0,0 +1,7 @@ +let customerUserId: string | undefined; + +export const setUserId = (id: string | null): void => { + customerUserId = id ?? undefined; +}; + +export const getUserId = (): string | undefined => customerUserId; diff --git a/packages/react-native-detour/src/shared/uuid.ts b/packages/react-native-detour/src/shared/uuid.ts new file mode 100644 index 0000000..51c0cd0 --- /dev/null +++ b/packages/react-native-detour/src/shared/uuid.ts @@ -0,0 +1,6 @@ +export const generateUUID = (): string => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const random = Math.floor(Math.random() * 16); + if (char === "x") return random.toString(16); + return (8 + (random % 4)).toString(16); + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62376e8..110dbc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,12 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-splash-screen: + specifier: ~55.0.15 + version: 55.0.15(expo@55.0.11)(typescript@5.9.3) + expo-tracking-transparency: + specifier: ~55.0.11 + version: 55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) react: specifier: 19.2.0 version: 19.2.0 @@ -521,6 +527,9 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-tracking-transparency: + specifier: ~55.0.11 + version: 55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@25.5.1) @@ -3765,6 +3774,12 @@ packages: react-native-web: optional: true + expo-tracking-transparency@55.0.16: + resolution: {integrity: sha512-yCNl9GZXMdp7Ayqh4dX8UlnDDTDny4xmVKtzZJEJeqtyYzQlJfWbzqHDj+gL/VXEfB5DkHmBBuNuJGFck0cfBw==} + peerDependencies: + expo: '*' + react-native: '*' + expo-updates-interface@55.1.5: resolution: {integrity: sha512-YOk9vhplWi0djoeqxMlEQgcDFeOGhnj4dWU0v1QvF5RqpqwLGdx780E0k3zL85xw6LXljVN78d6g8z51qIZu5g==} peerDependencies: @@ -10952,6 +10967,11 @@ snapshots: transitivePeerDependencies: - supports-color + expo-tracking-transparency@55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + expo-updates-interface@55.1.5(expo@55.0.11): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)