@symbiote-native/tracking-transparency 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/build/angular/index.d.ts +2 -0
  4. package/build/angular/index.js +4 -0
  5. package/build/angular/services/permissions.service/index.d.ts +8 -0
  6. package/build/angular/services/permissions.service/index.js +78 -0
  7. package/build/core/index.d.ts +2 -0
  8. package/build/core/index.js +2 -0
  9. package/build/core/native-module.d.ts +7 -0
  10. package/build/core/native-module.js +3 -0
  11. package/build/core/tracking-transparency.d.ts +19 -0
  12. package/build/core/tracking-transparency.js +58 -0
  13. package/build/react/hooks/use-permissions/index.d.ts +6 -0
  14. package/build/react/hooks/use-permissions/index.js +30 -0
  15. package/build/react/index.d.ts +2 -0
  16. package/build/react/index.js +6 -0
  17. package/build/vue/composables/use-permissions/index.d.ts +7 -0
  18. package/build/vue/composables/use-permissions/index.js +23 -0
  19. package/build/vue/index.d.ts +2 -0
  20. package/build/vue/index.js +6 -0
  21. package/build-ngc/angular/index.d.ts +2 -0
  22. package/build-ngc/angular/index.js +5 -0
  23. package/build-ngc/angular/index.js.map +1 -0
  24. package/build-ngc/angular/services/permissions.service/index.d.ts +11 -0
  25. package/build-ngc/angular/services/permissions.service/index.js +37 -0
  26. package/build-ngc/angular/services/permissions.service/index.js.map +1 -0
  27. package/build-ngc/core/index.d.ts +2 -0
  28. package/build-ngc/core/index.js +3 -0
  29. package/build-ngc/core/index.js.map +1 -0
  30. package/build-ngc/core/native-module.d.ts +7 -0
  31. package/build-ngc/core/native-module.js +4 -0
  32. package/build-ngc/core/native-module.js.map +1 -0
  33. package/build-ngc/core/tracking-transparency.d.ts +19 -0
  34. package/build-ngc/core/tracking-transparency.js +59 -0
  35. package/build-ngc/core/tracking-transparency.js.map +1 -0
  36. package/native-link.json +17 -0
  37. package/package.json +107 -0
  38. package/src/angular/index.ts +4 -0
  39. package/src/angular/services/permissions.service/index.ts +37 -0
  40. package/src/angular/services/permissions.service/permissions.service.test.ts +107 -0
  41. package/src/core/index.ts +12 -0
  42. package/src/core/native-module.ts +21 -0
  43. package/src/core/tracking-transparency.test.ts +137 -0
  44. package/src/core/tracking-transparency.ts +66 -0
  45. package/src/react/hooks/use-permissions/index.ts +41 -0
  46. package/src/react/hooks/use-permissions/use-permissions.test.tsx +100 -0
  47. package/src/react/index.ts +7 -0
  48. package/src/vue/composables/use-permissions/index.ts +36 -0
  49. package/src/vue/composables/use-permissions/use-permissions.test.ts +99 -0
  50. package/src/vue/index.ts +7 -0
@@ -0,0 +1,19 @@
1
+ import type { PermissionResponse } from 'expo-modules-core';
2
+ /**
3
+ * Gets the advertising ID, a UUID string intended only for advertising. Returns `null` on the
4
+ * iOS simulator, when tracking hasn't been authorized via `requestTrackingPermissionsAsync`, or
5
+ * when the user declined.
6
+ */
7
+ export declare function getAdvertisingId(): string | null;
8
+ /**
9
+ * Requests the user to authorize or deny access to app-related data that can be used for
10
+ * tracking. On Android and web this always resolves granted.
11
+ */
12
+ export declare function requestTrackingPermissionsAsync(): Promise<PermissionResponse>;
13
+ /**
14
+ * Checks whether the user has authorized the app to access tracking-related data. On Android and
15
+ * web this always resolves granted.
16
+ */
17
+ export declare function getTrackingPermissionsAsync(): Promise<PermissionResponse>;
18
+ /** Whether the tracking-transparency native module resolved at all. */
19
+ export declare function isAvailable(): boolean;
@@ -0,0 +1,59 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-tracking-transparency/src/TrackingTransparency.ts
2
+ // (sdk-57, verified via `git -C .vendors/expo show origin/sdk-57:...` — the vendored working
3
+ // tree is on `main` and imports createPermissionHook/PermissionResponse from the `expo`
4
+ // meta-package instead of expo-modules-core). createPermissionHook/useTrackingPermissions are
5
+ // NOT ported here — that helper is React-only (built on useState/useEffect), and this repo's
6
+ // convention is for each adapter to hand-roll its own permission hook instead (react/hooks,
7
+ // vue/composables, angular/services), same as brightness/cellular already do.
8
+ import { Platform, PermissionStatus, UnavailabilityError } from 'expo-modules-core';
9
+ import { expoTrackingTransparency } from './native-module';
10
+ const NATIVE_MODULE_NAME = 'TrackingTransparency';
11
+ /**
12
+ * Gets the advertising ID, a UUID string intended only for advertising. Returns `null` on the
13
+ * iOS simulator, when tracking hasn't been authorized via `requestTrackingPermissionsAsync`, or
14
+ * when the user declined.
15
+ */
16
+ export function getAdvertisingId() {
17
+ const advertisingId = expoTrackingTransparency.getAdvertisingId();
18
+ if (advertisingId === '00000000-0000-0000-0000-000000000000') {
19
+ return null;
20
+ }
21
+ return advertisingId;
22
+ }
23
+ const androidAndWebPermissionsResponse = {
24
+ granted: true,
25
+ expires: 'never',
26
+ canAskAgain: true,
27
+ status: PermissionStatus.GRANTED,
28
+ };
29
+ /**
30
+ * Requests the user to authorize or deny access to app-related data that can be used for
31
+ * tracking. On Android and web this always resolves granted.
32
+ */
33
+ export async function requestTrackingPermissionsAsync() {
34
+ if (Platform.OS !== 'ios') {
35
+ return Promise.resolve(androidAndWebPermissionsResponse);
36
+ }
37
+ if (!expoTrackingTransparency.requestPermissionsAsync) {
38
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'requestPermissionsAsync');
39
+ }
40
+ return await expoTrackingTransparency.requestPermissionsAsync();
41
+ }
42
+ /**
43
+ * Checks whether the user has authorized the app to access tracking-related data. On Android and
44
+ * web this always resolves granted.
45
+ */
46
+ export async function getTrackingPermissionsAsync() {
47
+ if (Platform.OS !== 'ios') {
48
+ return Promise.resolve(androidAndWebPermissionsResponse);
49
+ }
50
+ if (!expoTrackingTransparency.getPermissionsAsync) {
51
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getPermissionsAsync');
52
+ }
53
+ return await expoTrackingTransparency.getPermissionsAsync();
54
+ }
55
+ /** Whether the tracking-transparency native module resolved at all. */
56
+ export function isAvailable() {
57
+ return Boolean(expoTrackingTransparency);
58
+ }
59
+ //# sourceMappingURL=tracking-transparency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracking-transparency.js","sourceRoot":"","sources":["../../src/core/tracking-transparency.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,6FAA6F;AAC7F,wFAAwF;AACxF,8FAA8F;AAC9F,6FAA6F;AAC7F,4FAA4F;AAC5F,8EAA8E;AAC9E,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAE3D,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AAElD;;;;GAIG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,aAAa,GAAG,wBAAwB,CAAC,gBAAgB,EAAE,CAAC;IAClE,IAAI,aAAa,KAAK,sCAAsC,EAAE,CAAC;QAC7D,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,aAAa,CAAC;AACvB,CAAC;AAED,MAAM,gCAAgC,GAAuB;IAC3D,OAAO,EAAE,IAAI;IACb,OAAO,EAAE,OAAO;IAChB,WAAW,EAAE,IAAI;IACjB,MAAM,EAAE,gBAAgB,CAAC,OAAO;CACjC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,+BAA+B;IACnD,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,OAAO,OAAO,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC;IAC3D,CAAC;IACD,IAAI,CAAC,wBAAwB,CAAC,uBAAuB,EAAE,CAAC;QACtD,MAAM,IAAI,mBAAmB,CAAC,kBAAkB,EAAE,yBAAyB,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,MAAM,wBAAwB,CAAC,uBAAuB,EAAE,CAAC;AAClE,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,2BAA2B;IAC/C,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,OAAO,OAAO,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC;IAC3D,CAAC;IACD,IAAI,CAAC,wBAAwB,CAAC,mBAAmB,EAAE,CAAC;QAClD,MAAM,IAAI,mBAAmB,CAAC,kBAAkB,EAAE,qBAAqB,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,MAAM,wBAAwB,CAAC,mBAAmB,EAAE,CAAC;AAC9D,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,WAAW;IACzB,OAAO,OAAO,CAAC,wBAAwB,CAAC,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,17 @@
1
+ {
2
+ "android": {
3
+ "gradleProjectName": "expo-tracking-transparency",
4
+ "modules": [
5
+ {
6
+ "importPath": "expo.modules.trackingtransparency.TrackingTransparencyModule",
7
+ "className": "TrackingTransparencyModule",
8
+ "nativeName": "ExpoTrackingTransparency"
9
+ }
10
+ ]
11
+ },
12
+ "ios": {
13
+ "infoPlistKeys": {
14
+ "NSUserTrackingUsageDescription": "This app tracks your activity across other apps and websites."
15
+ }
16
+ }
17
+ }
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@symbiote-native/tracking-transparency",
3
+ "version": "0.0.1",
4
+ "description": "expo-tracking-transparency wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters. iOS App Tracking Transparency prompt (permission get/request, always-granted on Android/web) plus the advertising-ID getter.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
9
+ "directory": "packages/tracking-transparency"
10
+ },
11
+ "homepage": "https://github.com/OneEyed1366/symbiote-native/tree/master/packages/tracking-transparency#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/OneEyed1366/symbiote-native/issues"
14
+ },
15
+ "author": "Andrey Prokopenko <psevdoproger@gmail.com>",
16
+ "type": "module",
17
+ "main": "./build/core/index.js",
18
+ "module": "./build/core/index.js",
19
+ "types": "./build/core/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./build/core/index.d.ts",
23
+ "default": "./build/core/index.js"
24
+ },
25
+ "./vue": {
26
+ "types": "./build/vue/index.d.ts",
27
+ "default": "./build/vue/index.js"
28
+ },
29
+ "./react": {
30
+ "types": "./build/react/index.d.ts",
31
+ "default": "./build/react/index.js"
32
+ },
33
+ "./angular": {
34
+ "types": "./build-ngc/angular/index.d.ts",
35
+ "react-native": "./build-ngc/angular/index.js",
36
+ "default": "./src/angular/index.ts"
37
+ }
38
+ },
39
+ "files": [
40
+ "src",
41
+ "build",
42
+ "build-ngc",
43
+ "native-link.json"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "expo-tracking-transparency": "57.0.1",
50
+ "expo-modules-core": "57.0.5"
51
+ },
52
+ "peerDependencies": {
53
+ "@symbiote-native/engine": ">=0.1.7",
54
+ "@angular/core": ">=20",
55
+ "@vue/runtime-core": "^3.5.13",
56
+ "react": ">=19.0.0",
57
+ "react-native": ">=0.86",
58
+ "vue": ">=3.5.0",
59
+ "@symbiote-native/angular": "0.6.1",
60
+ "@symbiote-native/react": "0.2.8",
61
+ "@symbiote-native/vue": "0.3.8"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@symbiote-native/angular": {
65
+ "optional": true
66
+ },
67
+ "@symbiote-native/react": {
68
+ "optional": true
69
+ },
70
+ "@symbiote-native/vue": {
71
+ "optional": true
72
+ },
73
+ "@angular/core": {
74
+ "optional": true
75
+ },
76
+ "@vue/runtime-core": {
77
+ "optional": true
78
+ },
79
+ "react": {
80
+ "optional": true
81
+ },
82
+ "vue": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@angular/compiler": "^22",
88
+ "@angular/compiler-cli": "^22",
89
+ "@angular/core": "^22",
90
+ "@types/node": "^26.0.0",
91
+ "@types/react": "^19.2.0",
92
+ "@vue/runtime-core": "^3.5.13",
93
+ "react": "19.2.3",
94
+ "typescript": "~6.0.0",
95
+ "@symbiote-native/angular": "0.6.1",
96
+ "@symbiote-native/engine": "0.1.7",
97
+ "@symbiote-native/vue": "0.3.8",
98
+ "@symbiote-native/test-utils": "0.1.6",
99
+ "@symbiote-native/react": "0.2.8"
100
+ },
101
+ "scripts": {
102
+ "typecheck": "tsc --build",
103
+ "clean": "rm -rf build-ngc",
104
+ "ng:build": "pnpm run clean && ngc -p tsconfig.angular.json",
105
+ "format": "prettier --write \"src/**/*.{ts,tsx}\""
106
+ }
107
+ }
@@ -0,0 +1,4 @@
1
+ // PermissionsService is the Angular-only lifecycle half; getAdvertisingId/isAvailable are
2
+ // stateless free functions, shared with React/Vue.
3
+ export { PermissionsService } from './services/permissions.service';
4
+ export * from '../core';
@@ -0,0 +1,37 @@
1
+ // Angular twin of React's usePermissions hook and Vue's usePermissions composable.
2
+ // `connect()` auto-fetches once (no ongoing subscription to clean up, so no Injector/effect
3
+ // needed, unlike BatteryStateService) and returns a readonly signal; get()/request() are
4
+ // imperative one-shot methods.
5
+ //
6
+ // readonly status = inject(PermissionsService).connect();
7
+ // // template: {{ status()?.granted }}
8
+ import { Injectable, signal, type Signal } from '@angular/core';
9
+ import {
10
+ getTrackingPermissionsAsync,
11
+ requestTrackingPermissionsAsync,
12
+ type PermissionResponse,
13
+ } from '../../../core';
14
+
15
+ @Injectable({ providedIn: 'root' })
16
+ export class PermissionsService {
17
+ private readonly status = signal<PermissionResponse | null>(null);
18
+
19
+ connect(): Signal<PermissionResponse | null> {
20
+ if (this.status() === null) {
21
+ void this.get();
22
+ }
23
+ return this.status.asReadonly();
24
+ }
25
+
26
+ async get(): Promise<PermissionResponse> {
27
+ const response = await getTrackingPermissionsAsync();
28
+ this.status.set(response);
29
+ return response;
30
+ }
31
+
32
+ async request(): Promise<PermissionResponse> {
33
+ const response = await requestTrackingPermissionsAsync();
34
+ this.status.set(response);
35
+ return response;
36
+ }
37
+ }
@@ -0,0 +1,107 @@
1
+ // Co-located Angular-driven test (ADR 0025) for PermissionsService. See battery's
2
+ // battery-state.service.test.ts for the shared rationale.
3
+
4
+ import '@angular/compiler';
5
+ import { Component, inject, type Signal } from '@angular/core';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { mount, unmount } from '@symbiote-native/angular';
8
+ import { installFabric } from '@symbiote-native/test-utils';
9
+ import { PermissionsService } from './index';
10
+ import { PermissionStatus, type PermissionResponse } from '../../../core';
11
+
12
+ const { getTrackingPermissionsAsync, requestTrackingPermissionsAsync } = vi.hoisted(() => ({
13
+ getTrackingPermissionsAsync: vi.fn(),
14
+ requestTrackingPermissionsAsync: vi.fn(),
15
+ }));
16
+
17
+ // Same enum-shaped-object mock trick packages/battery/src/angular/services/battery-state.service's
18
+ // test uses for BatteryState.
19
+ vi.mock('../../../core', () => ({
20
+ getTrackingPermissionsAsync,
21
+ requestTrackingPermissionsAsync,
22
+ PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
23
+ }));
24
+
25
+ const GRANTED: PermissionResponse = {
26
+ status: PermissionStatus.GRANTED,
27
+ granted: true,
28
+ canAskAgain: true,
29
+ expires: 'never',
30
+ };
31
+ const DENIED: PermissionResponse = {
32
+ status: PermissionStatus.DENIED,
33
+ granted: false,
34
+ canAskAgain: true,
35
+ expires: 'never',
36
+ };
37
+
38
+ const ROOT_TAG = 974;
39
+ const fabric = installFabric();
40
+ const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
41
+
42
+ let capturedStatus: Signal<PermissionResponse | null> | undefined;
43
+ let capturedService: PermissionsService | undefined;
44
+
45
+ @Component({
46
+ selector: 'symbiote-permissions-host',
47
+ standalone: true,
48
+ template: '',
49
+ })
50
+ class PermissionsHost {
51
+ readonly service = inject(PermissionsService);
52
+ readonly status = this.service.connect();
53
+
54
+ constructor() {
55
+ capturedStatus = this.status;
56
+ capturedService = this.service;
57
+ }
58
+ }
59
+
60
+ beforeEach(() => {
61
+ capturedStatus = undefined;
62
+ capturedService = undefined;
63
+ vi.clearAllMocks();
64
+ getTrackingPermissionsAsync.mockResolvedValue(GRANTED);
65
+ requestTrackingPermissionsAsync.mockResolvedValue(GRANTED);
66
+ });
67
+
68
+ afterEach(() => {
69
+ unmount(ROOT_TAG);
70
+ fabric.reset();
71
+ });
72
+
73
+ describe('PermissionsService.connect', () => {
74
+ it('reports null before the initial fetch resolves', () => {
75
+ mount(ROOT_TAG, PermissionsHost);
76
+
77
+ expect(capturedStatus?.()).toBe(null);
78
+ });
79
+
80
+ it('reports the fetched status once getTrackingPermissionsAsync() resolves', async () => {
81
+ mount(ROOT_TAG, PermissionsHost);
82
+ await tick();
83
+
84
+ expect(capturedStatus?.()).toEqual(GRANTED);
85
+ expect(getTrackingPermissionsAsync).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it('request() delegates to requestTrackingPermissionsAsync and updates the signal', async () => {
89
+ mount(ROOT_TAG, PermissionsHost);
90
+ await tick();
91
+
92
+ requestTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
93
+ await capturedService?.request();
94
+
95
+ expect(capturedStatus?.()).toEqual(DENIED);
96
+ });
97
+
98
+ it('get() re-fetches and updates the signal', async () => {
99
+ mount(ROOT_TAG, PermissionsHost);
100
+ await tick();
101
+
102
+ getTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
103
+ await capturedService?.get();
104
+
105
+ expect(capturedStatus?.()).toEqual(DENIED);
106
+ });
107
+ });
@@ -0,0 +1,12 @@
1
+ export {
2
+ getAdvertisingId,
3
+ requestTrackingPermissionsAsync,
4
+ getTrackingPermissionsAsync,
5
+ isAvailable,
6
+ } from './tracking-transparency';
7
+ export {
8
+ PermissionStatus,
9
+ type PermissionResponse,
10
+ type PermissionExpiration,
11
+ type PermissionHookOptions,
12
+ } from 'expo-modules-core';
@@ -0,0 +1,21 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ import type { PermissionResponse } from 'expo-modules-core';
3
+
4
+ const EXPO_TRACKING_TRANSPARENCY_MODULE_NAME = 'ExpoTrackingTransparency';
5
+
6
+ // getAdvertisingId is unconditional (upstream's Kotlin/Swift always implement it); the two
7
+ // permission methods are optional and iOS-only in practice — Android/web never reach the native
8
+ // module for them at all (see tracking-transparency.ts's Platform.OS !== 'ios' short-circuit), so
9
+ // the call site itself checks presence and throws an UnavailabilityError, matching upstream's own
10
+ // per-platform capability check rather than assuming the native module implements the whole
11
+ // surface (same convention as packages/device/src/core/native-module.ts and
12
+ // packages/brightness/src/core/native-module.ts).
13
+ export type INativeTrackingTransparencyModule = {
14
+ getAdvertisingId(): string | null;
15
+ requestPermissionsAsync?(): Promise<PermissionResponse>;
16
+ getPermissionsAsync?(): Promise<PermissionResponse>;
17
+ };
18
+
19
+ export const expoTrackingTransparency = requireNativeModule<INativeTrackingTransparencyModule>(
20
+ EXPO_TRACKING_TRANSPARENCY_MODULE_NAME,
21
+ );
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const FAKE_NATIVE_TRACKING_TRANSPARENCY = {
4
+ getAdvertisingId: vi.fn(() => 'E9228286-4C4E-4789-9D95-15827DCB291B'),
5
+ requestPermissionsAsync: vi.fn(async () => ({ status: 'granted', granted: true })),
6
+ getPermissionsAsync: vi.fn(async () => ({ status: 'granted', granted: true })),
7
+ };
8
+
9
+ // The real ExpoTrackingTransparency native module only exists on device — resolving it via
10
+ // requireNativeModule() at import time would throw in this headless test run, so the
11
+ // module-lookup file is faked in place of expo-modules-core's runtime resolution, same pattern
12
+ // packages/brightness/src/core/brightness.test.ts and packages/device/src/core/device.test.ts use.
13
+ vi.mock('./native-module', () => ({
14
+ expoTrackingTransparency: FAKE_NATIVE_TRACKING_TRANSPARENCY,
15
+ }));
16
+
17
+ // expo-modules-core's real entry transitively imports 'react-native' for Platform/
18
+ // TurboModuleRegistry, whose Flow-typed source Vitest's Oxc transform can't parse — same fake
19
+ // packages/brightness/src/core/brightness.test.ts uses, since Platform.OS gates the
20
+ // Android/web-vs-iOS branch here.
21
+ const fakePlatform = { OS: 'ios' as 'ios' | 'android' };
22
+
23
+ vi.mock('expo-modules-core', () => ({
24
+ Platform: fakePlatform,
25
+ PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
26
+ UnavailabilityError: class UnavailabilityError extends Error {
27
+ constructor(moduleName: string, propertyName: string) {
28
+ super(`${propertyName} is not available on ${moduleName}`);
29
+ }
30
+ },
31
+ }));
32
+
33
+ const {
34
+ getAdvertisingId,
35
+ requestTrackingPermissionsAsync,
36
+ getTrackingPermissionsAsync,
37
+ isAvailable,
38
+ } = await import('./tracking-transparency');
39
+
40
+ afterEach(() => {
41
+ fakePlatform.OS = 'ios';
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ describe('getAdvertisingId', () => {
46
+ it('returns the advertising ID straight from the native module', () => {
47
+ expect(getAdvertisingId()).toBe('E9228286-4C4E-4789-9D95-15827DCB291B');
48
+ });
49
+
50
+ it('maps the all-zero UUID sentinel to null', () => {
51
+ FAKE_NATIVE_TRACKING_TRANSPARENCY.getAdvertisingId.mockReturnValueOnce(
52
+ '00000000-0000-0000-0000-000000000000',
53
+ );
54
+
55
+ expect(getAdvertisingId()).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe('requestTrackingPermissionsAsync', () => {
60
+ it('always resolves granted on Android without calling the native module', async () => {
61
+ fakePlatform.OS = 'android';
62
+
63
+ await expect(requestTrackingPermissionsAsync()).resolves.toEqual({
64
+ granted: true,
65
+ expires: 'never',
66
+ canAskAgain: true,
67
+ status: 'granted',
68
+ });
69
+ expect(FAKE_NATIVE_TRACKING_TRANSPARENCY.requestPermissionsAsync).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('delegates to the native module on iOS', async () => {
73
+ fakePlatform.OS = 'ios';
74
+
75
+ await expect(requestTrackingPermissionsAsync()).resolves.toEqual({
76
+ status: 'granted',
77
+ granted: true,
78
+ });
79
+ expect(FAKE_NATIVE_TRACKING_TRANSPARENCY.requestPermissionsAsync).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('throws an UnavailabilityError-shaped error on iOS when the native method is absent', async () => {
83
+ fakePlatform.OS = 'ios';
84
+ const { requestPermissionsAsync: native } = FAKE_NATIVE_TRACKING_TRANSPARENCY;
85
+ // @ts-expect-error -- simulating a native module build missing this method
86
+ FAKE_NATIVE_TRACKING_TRANSPARENCY.requestPermissionsAsync = undefined;
87
+
88
+ await expect(requestTrackingPermissionsAsync()).rejects.toThrow(
89
+ 'requestPermissionsAsync is not available on TrackingTransparency',
90
+ );
91
+
92
+ FAKE_NATIVE_TRACKING_TRANSPARENCY.requestPermissionsAsync = native;
93
+ });
94
+ });
95
+
96
+ describe('getTrackingPermissionsAsync', () => {
97
+ it('always resolves granted on Android without calling the native module', async () => {
98
+ fakePlatform.OS = 'android';
99
+
100
+ await expect(getTrackingPermissionsAsync()).resolves.toEqual({
101
+ granted: true,
102
+ expires: 'never',
103
+ canAskAgain: true,
104
+ status: 'granted',
105
+ });
106
+ expect(FAKE_NATIVE_TRACKING_TRANSPARENCY.getPermissionsAsync).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it('delegates to the native module on iOS', async () => {
110
+ fakePlatform.OS = 'ios';
111
+
112
+ await expect(getTrackingPermissionsAsync()).resolves.toEqual({
113
+ status: 'granted',
114
+ granted: true,
115
+ });
116
+ expect(FAKE_NATIVE_TRACKING_TRANSPARENCY.getPermissionsAsync).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('throws an UnavailabilityError-shaped error on iOS when the native method is absent', async () => {
120
+ fakePlatform.OS = 'ios';
121
+ const { getPermissionsAsync: native } = FAKE_NATIVE_TRACKING_TRANSPARENCY;
122
+ // @ts-expect-error -- simulating a native module build missing this method
123
+ FAKE_NATIVE_TRACKING_TRANSPARENCY.getPermissionsAsync = undefined;
124
+
125
+ await expect(getTrackingPermissionsAsync()).rejects.toThrow(
126
+ 'getPermissionsAsync is not available on TrackingTransparency',
127
+ );
128
+
129
+ FAKE_NATIVE_TRACKING_TRANSPARENCY.getPermissionsAsync = native;
130
+ });
131
+ });
132
+
133
+ describe('isAvailable', () => {
134
+ it('resolves true when the native module resolved', () => {
135
+ expect(isAvailable()).toBe(true);
136
+ });
137
+ });
@@ -0,0 +1,66 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-tracking-transparency/src/TrackingTransparency.ts
2
+ // (sdk-57, verified via `git -C .vendors/expo show origin/sdk-57:...` — the vendored working
3
+ // tree is on `main` and imports createPermissionHook/PermissionResponse from the `expo`
4
+ // meta-package instead of expo-modules-core). createPermissionHook/useTrackingPermissions are
5
+ // NOT ported here — that helper is React-only (built on useState/useEffect), and this repo's
6
+ // convention is for each adapter to hand-roll its own permission hook instead (react/hooks,
7
+ // vue/composables, angular/services), same as brightness/cellular already do.
8
+ import { Platform, PermissionStatus, UnavailabilityError } from 'expo-modules-core';
9
+ import type { PermissionResponse } from 'expo-modules-core';
10
+
11
+ import { expoTrackingTransparency } from './native-module';
12
+
13
+ const NATIVE_MODULE_NAME = 'TrackingTransparency';
14
+
15
+ /**
16
+ * Gets the advertising ID, a UUID string intended only for advertising. Returns `null` on the
17
+ * iOS simulator, when tracking hasn't been authorized via `requestTrackingPermissionsAsync`, or
18
+ * when the user declined.
19
+ */
20
+ export function getAdvertisingId(): string | null {
21
+ const advertisingId = expoTrackingTransparency.getAdvertisingId();
22
+ if (advertisingId === '00000000-0000-0000-0000-000000000000') {
23
+ return null;
24
+ }
25
+ return advertisingId;
26
+ }
27
+
28
+ const androidAndWebPermissionsResponse: PermissionResponse = {
29
+ granted: true,
30
+ expires: 'never',
31
+ canAskAgain: true,
32
+ status: PermissionStatus.GRANTED,
33
+ };
34
+
35
+ /**
36
+ * Requests the user to authorize or deny access to app-related data that can be used for
37
+ * tracking. On Android and web this always resolves granted.
38
+ */
39
+ export async function requestTrackingPermissionsAsync(): Promise<PermissionResponse> {
40
+ if (Platform.OS !== 'ios') {
41
+ return Promise.resolve(androidAndWebPermissionsResponse);
42
+ }
43
+ if (!expoTrackingTransparency.requestPermissionsAsync) {
44
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'requestPermissionsAsync');
45
+ }
46
+ return await expoTrackingTransparency.requestPermissionsAsync();
47
+ }
48
+
49
+ /**
50
+ * Checks whether the user has authorized the app to access tracking-related data. On Android and
51
+ * web this always resolves granted.
52
+ */
53
+ export async function getTrackingPermissionsAsync(): Promise<PermissionResponse> {
54
+ if (Platform.OS !== 'ios') {
55
+ return Promise.resolve(androidAndWebPermissionsResponse);
56
+ }
57
+ if (!expoTrackingTransparency.getPermissionsAsync) {
58
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getPermissionsAsync');
59
+ }
60
+ return await expoTrackingTransparency.getPermissionsAsync();
61
+ }
62
+
63
+ /** Whether the tracking-transparency native module resolved at all. */
64
+ export function isAvailable(): boolean {
65
+ return Boolean(expoTrackingTransparency);
66
+ }
@@ -0,0 +1,41 @@
1
+ // React lifecycle wiring over the framework-agnostic core (core/tracking-transparency.ts) —
2
+ // auto-fetches the current permission status on mount, then exposes get/request as imperative
3
+ // callbacks. Mirrors the shape @symbiote-native/cellular's own usePermissions ships in parallel,
4
+ // so both stay byte-for-byte consistent.
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
+ import {
7
+ getTrackingPermissionsAsync,
8
+ requestTrackingPermissionsAsync,
9
+ type PermissionResponse,
10
+ } from '../../../core';
11
+
12
+ export function usePermissions(): [
13
+ PermissionResponse | null,
14
+ () => Promise<PermissionResponse>,
15
+ () => Promise<PermissionResponse>,
16
+ ] {
17
+ const isMounted = useRef(true);
18
+ const [status, setStatus] = useState<PermissionResponse | null>(null);
19
+
20
+ const getPermission = useCallback(async () => {
21
+ const response = await getTrackingPermissionsAsync();
22
+ if (isMounted.current) setStatus(response);
23
+ return response;
24
+ }, []);
25
+
26
+ const requestPermission = useCallback(async () => {
27
+ const response = await requestTrackingPermissionsAsync();
28
+ if (isMounted.current) setStatus(response);
29
+ return response;
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ isMounted.current = true;
34
+ void getPermission();
35
+ return () => {
36
+ isMounted.current = false;
37
+ };
38
+ }, [getPermission]);
39
+
40
+ return [status, requestPermission, getPermission];
41
+ }