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 |
Installation
Section titled “Installation”npm install @symbiote-native/tracking-transparencyexpo-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).
Permission
Section titled “Permission”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> );}<script setup lang="ts">import { Pressable, Text, View } from '@symbiote-native/vue';import { usePermissions } from '@symbiote-native/tracking-transparency/vue';
const { status, error, request, get } = usePermissions();</script>
<template> <View> <Text>{{ error ? `check failed: ${error.message}` : (status?.status ?? 'checking…') }}</Text> <Pressable @press="error ? get() : request()"> <Text>{{ error ? 'Retry check' : 'Request tracking permission' }}</Text> </Pressable> </View></template>import { Component, inject } from '@angular/core';import { Pressable, Text, View } from '@symbiote-native/angular';import { PermissionsService } from '@symbiote-native/tracking-transparency/angular';
@Component({ standalone: true, imports: [Pressable, Text, View], template: ` <View> <Text>{{ error() ? 'check failed: ' + error()?.message : (status()?.status ?? 'checking…') }}</Text> <Pressable (press)="error() ? permissions.get() : permissions.request()"> <Text>{{ error() ? 'Retry check' : 'Request tracking permission' }}</Text> </Pressable> </View> `,})export class TrackingScreen { readonly permissions = inject(PermissionsService); readonly status = this.permissions.connect(); readonly error = this.permissions.error;}<script lang="ts"> import { Pressable, Text, View } from '@symbiote-native/svelte'; import { usePermissions } from '@symbiote-native/tracking-transparency/svelte';
const permissions = usePermissions();</script>
<View> <Text> {permissions.error ? `check failed: ${permissions.error.message}` : (permissions.status?.status ?? 'checking…')} </Text> <Pressable onPress={() => (permissions.error ? permissions.get() : permissions.request())}> <Text>{permissions.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.
Advertising ID
Section titled “Advertising ID”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 consentFunctions
Section titled “Functions”| 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 |
usePermissions()
Section titled “usePermissions()”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 |
usePermissions() return value
Section titled “usePermissions() return value”| 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.
PermissionResponse
Section titled “PermissionResponse”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/requestTrackingPermissionsAsyncshort-circuit to a fixed granted response without ever calling the native module, matching upstream exactly. getAdvertisingIdreturnsnullon 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.
How the wrapper works
Section titled “How the wrapper works”@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).