@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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/build/angular/index.d.ts +2 -0
- package/build/angular/index.js +4 -0
- package/build/angular/services/permissions.service/index.d.ts +8 -0
- package/build/angular/services/permissions.service/index.js +78 -0
- package/build/core/index.d.ts +2 -0
- package/build/core/index.js +2 -0
- package/build/core/native-module.d.ts +7 -0
- package/build/core/native-module.js +3 -0
- package/build/core/tracking-transparency.d.ts +19 -0
- package/build/core/tracking-transparency.js +58 -0
- package/build/react/hooks/use-permissions/index.d.ts +6 -0
- package/build/react/hooks/use-permissions/index.js +30 -0
- package/build/react/index.d.ts +2 -0
- package/build/react/index.js +6 -0
- package/build/vue/composables/use-permissions/index.d.ts +7 -0
- package/build/vue/composables/use-permissions/index.js +23 -0
- package/build/vue/index.d.ts +2 -0
- package/build/vue/index.js +6 -0
- package/build-ngc/angular/index.d.ts +2 -0
- package/build-ngc/angular/index.js +5 -0
- package/build-ngc/angular/index.js.map +1 -0
- package/build-ngc/angular/services/permissions.service/index.d.ts +11 -0
- package/build-ngc/angular/services/permissions.service/index.js +37 -0
- package/build-ngc/angular/services/permissions.service/index.js.map +1 -0
- package/build-ngc/core/index.d.ts +2 -0
- package/build-ngc/core/index.js +3 -0
- package/build-ngc/core/index.js.map +1 -0
- package/build-ngc/core/native-module.d.ts +7 -0
- package/build-ngc/core/native-module.js +4 -0
- package/build-ngc/core/native-module.js.map +1 -0
- package/build-ngc/core/tracking-transparency.d.ts +19 -0
- package/build-ngc/core/tracking-transparency.js +59 -0
- package/build-ngc/core/tracking-transparency.js.map +1 -0
- package/native-link.json +17 -0
- package/package.json +107 -0
- package/src/angular/index.ts +4 -0
- package/src/angular/services/permissions.service/index.ts +37 -0
- package/src/angular/services/permissions.service/permissions.service.test.ts +107 -0
- package/src/core/index.ts +12 -0
- package/src/core/native-module.ts +21 -0
- package/src/core/tracking-transparency.test.ts +137 -0
- package/src/core/tracking-transparency.ts +66 -0
- package/src/react/hooks/use-permissions/index.ts +41 -0
- package/src/react/hooks/use-permissions/use-permissions.test.tsx +100 -0
- package/src/react/index.ts +7 -0
- package/src/vue/composables/use-permissions/index.ts +36 -0
- package/src/vue/composables/use-permissions/use-permissions.test.ts +99 -0
- 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"}
|
package/native-link.json
ADDED
|
@@ -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,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
|
+
}
|