Skip to content

Tracking transparency

@symbiote-native/tracking-transparency wraps expo-tracking-transparency — the iOS App Tracking Transparency prompt, permission get/request, and the advertising-ID getter — so every SymbioteNative adapter can reach it, not just React. Its permission surface (getTrackingPermissionsAsync/requestTrackingPermissionsAsync) shares the same usePermissions() hook/composable/service shape as brightness’s own permission surface, so switching between the two packages needs no relearning; getAdvertisingId/isAvailable are plain stateless free functions, same as device.

OS platform Support
iOS ✅ live
Android ✅ live
Framework adapter Support
React ✅ live
Vue ✅ live
Angular ✅ live
Svelte ✅ live
Terminal window
npm install @symbiote-native/tracking-transparency

expo-tracking-transparency and expo-modules-core come along as regular dependencies, pinned to exact versions — never install either yourself, and never add the expo meta-package to your project (it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).

import { Pressable, Text, View } from '@symbiote-native/react';
import { usePermissions } from '@symbiote-native/tracking-transparency/react';
export default function TrackingScreen() {
const [status, request, get, error] = usePermissions();
return (
<View>
<Text>{error ? `check failed: ${error.message}` : (status?.status ?? 'checking…')}</Text>
<Pressable onPress={() => (error ? get() : request())}>
<Text>{error ? 'Retry check' : 'Request tracking permission'}</Text>
</Pressable>
</View>
);
}

status stays null both while the first fetch is in flight and after it fails, so error is what tells those two apart; get() re-runs the check without prompting the user.

The stateless functions are already framework-agnostic — import them straight from the package root, on any adapter:

import { getAdvertisingId } from '@symbiote-native/tracking-transparency';
const advertisingId = getAdvertisingId(); // null on the iOS Simulator, or before/without consent
Signature Description
getAdvertisingId(): string | null Gets the advertising ID (Android AAID / iOS IDFA). Returns null on the iOS Simulator, when tracking hasn’t been authorized via requestTrackingPermissionsAsync, or when the user declined
getTrackingPermissionsAsync(): Promise<PermissionResponse> Checks whether the user has authorized the app to access tracking-related data. Always resolves granted on Android and web
requestTrackingPermissionsAsync(): Promise<PermissionResponse> Requests the user to authorize or deny access to app-related data usable for tracking, showing the real ATT prompt on iOS. Always resolves granted on Android and web
isAvailable(): boolean Whether the tracking-transparency native module resolved at all
React (/react) Vue (/vue) Angular (/angular) Svelte (/svelte) Signature Returns
usePermissions usePermissions PermissionsService.connect() usePermissions () React: [PermissionResponse | null, request, get, Error | null] tuple. Vue: { status: Ref<PermissionResponse | null>, error: Ref<Error | null>, request, get }. Angular: Signal<PermissionResponse | null> from connect(), plus error: Signal<Error | null> and request()/get() on the service. Svelte: { status, error, request, get }, status and error getters over PermissionResponse | null / Error | null
Field Type Description
status PermissionResponse | null The permission status last read, null until the first fetch resolves
error Error | null Why the automatic fetch left status at null. Cleared by the next successful get()/request()
request () => Promise<PermissionResponse> Asks the user for the permission, then updates status and clears error. Rejects to its caller when the native call fails
get () => Promise<PermissionResponse> Re-reads the current status without prompting. Same update and rejection behavior as request

React hands those back positionally, as [status, request, get, error], so existing two- and three-element destructuring keeps working. Vue wraps status and error in refs; Angular exposes status through connect() and error as a separate readonly signal on the service; Svelte returns both as getters, read as permissions.status / permissions.error.

Read together, the two fields separate the three states:

status error Meaning
null null Not fetched yet
null Error The automatic fetch failed
PermissionResponse null Fetched

Every variant auto-fetches the current permission status once on mount/connect(), and updates again whenever request/get resolves. A failure in that automatic fetch lands in error instead of escaping as an unhandled rejection; get()/request() called by hand still reject to their caller. Angular’s auto-fetch is latched to at most one run per service instance, so a later connect() never re-fetches: call get() to retry.

Re-exported verbatim from expo-modules-core, never the expo meta-package:

Field Type Description
status PermissionStatus The current permission status — 'undetermined', 'denied', or 'granted'
granted boolean Whether the permission is granted — a convenience shortcut over checking status === 'granted'
canAskAgain boolean Whether the user can be asked again for this permission, or the OS has permanently blocked it
expires PermissionExpiration When the permission expires ('never' on every platform this package targets)
  • Android and web always report granted. There is no tracking-consent concept on either platform — getTrackingPermissionsAsync/requestTrackingPermissionsAsync short-circuit to a fixed granted response without ever calling the native module, matching upstream exactly.
  • getAdvertisingId returns null on the iOS Simulator, regardless of any settings — there is no real IDFA to read there. This is expected Apple Simulator behavior, not a bug in this wrapper.

@symbiote-native/tracking-transparency ships zero React/Vue/Angular/Svelte logic in expo-tracking-transparency itself — its functions are hand-ported, verbatim, into this package’s own core/, resolving the native module through expo-modules-core’s requireNativeModule rather than the expo meta-package this project never installs:

packages/tracking-transparency/src/
├── core/ getAdvertisingId, get/requestTrackingPermissionsAsync, isAvailable;
│ native-module.ts resolves ExpoTrackingTransparency through
│ expo-modules-core's requireNativeModule.
├── react/hooks/ @symbiote-native/tracking-transparency/react — usePermissions
├── vue/composables/ @symbiote-native/tracking-transparency/vue — usePermissions (same name)
├── angular/services/ @symbiote-native/tracking-transparency/angular — PermissionsService
└── svelte/runes/ @symbiote-native/tracking-transparency/svelte — usePermissions (same name)

Only the permission surface gets a lifecycle hook — there’s per-instance state to seed and refresh; getAdvertisingId/isAvailable are stateless, re-exported verbatim by every adapter. Upstream’s own createPermissionHook/useTrackingPermissions is not ported — that helper is React-only (built on useState/useEffect), and this repo’s convention is for each adapter to hand-roll its own permission hook instead, exactly like brightness/cellular already do. The native code itself is never vendored or copied — expo-modules-autolinking resolves it straight out of node_modules (see the native setup guide).