@symbiote-native/tracking-transparency 0.0.1 → 0.2.0

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 (30) hide show
  1. package/README.md +6 -1
  2. package/build/angular/index.js +2 -2
  3. package/build/angular/services/permissions.service/index.d.ts +4 -0
  4. package/build/angular/services/permissions.service/index.js +20 -3
  5. package/build/core/index.js +1 -1
  6. package/build/core/tracking-transparency.js +1 -1
  7. package/build/react/hooks/use-permissions/index.d.ts +2 -1
  8. package/build/react/hooks/use-permissions/index.js +18 -5
  9. package/build/react/index.js +2 -2
  10. package/build/svelte/index.d.ts +2 -0
  11. package/build/svelte/index.js +6 -0
  12. package/build/svelte/runes/use-permissions.svelte.d.ts +7 -0
  13. package/build/svelte/runes/use-permissions.svelte.js +49 -0
  14. package/build/vue/composables/use-permissions/index.d.ts +1 -0
  15. package/build/vue/composables/use-permissions/index.js +13 -3
  16. package/build/vue/index.js +2 -2
  17. package/build-ngc/angular/services/permissions.service/index.d.ts +4 -0
  18. package/build-ngc/angular/services/permissions.service/index.js +23 -5
  19. package/build-ngc/angular/services/permissions.service/index.js.map +1 -1
  20. package/package.json +30 -13
  21. package/src/angular/services/permissions.service/index.ts +20 -2
  22. package/src/react/hooks/use-permissions/index.ts +19 -4
  23. package/src/svelte/index.ts +7 -0
  24. package/src/svelte/runes/use-permissions.svelte.ts +63 -0
  25. package/src/svelte/svelte-ambient.d.ts +6 -0
  26. package/src/vue/composables/use-permissions/index.ts +13 -2
  27. package/src/angular/services/permissions.service/permissions.service.test.ts +0 -107
  28. package/src/core/tracking-transparency.test.ts +0 -137
  29. package/src/react/hooks/use-permissions/use-permissions.test.tsx +0 -100
  30. package/src/vue/composables/use-permissions/use-permissions.test.ts +0 -99
package/README.md CHANGED
@@ -142,6 +142,12 @@ export class TrackingScreen {
142
142
  }
143
143
  ```
144
144
 
145
+ All three examples mirror the real canary demo screens —
146
+ `examples/expo-react/screens/TrackingTransparencyScreen.tsx`,
147
+ `examples/expo-vue-sfc/screens/TrackingTransparencyScreen.vue`,
148
+ `examples/expo-vue-tsx/screens/TrackingTransparencyScreen.tsx`,
149
+ `examples/expo-angular/src/screens/TrackingTransparencyScreen.ts`.
150
+
145
151
  ## API
146
152
 
147
153
  ```ts
@@ -205,6 +211,5 @@ surface, never a view. Tests inject a fake native-module object in place of the
205
211
 
206
212
  ## Known gaps
207
213
 
208
- - Not yet wired into any `examples/expo-*` canary.
209
214
  - Tests exercise the JS layer only (fake native module, `vitest`) — no on-device/simulator
210
215
  automated smoke test yet, only manual verification.
@@ -1,4 +1,4 @@
1
1
  // PermissionsService is the Angular-only lifecycle half; getAdvertisingId/isAvailable are
2
2
  // stateless free functions, shared with React/Vue.
3
- export { PermissionsService } from './services/permissions.service';
4
- export * from '../core';
3
+ export { PermissionsService } from './services/permissions.service/index.js';
4
+ export * from '../core/index.js';
@@ -2,6 +2,10 @@ import { type Signal } from '@angular/core';
2
2
  import { type PermissionResponse } from '../../../core';
3
3
  export declare class PermissionsService {
4
4
  private readonly status;
5
+ private readonly fetchError;
6
+ private isAutoFetchStarted;
7
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
8
+ readonly error: Signal<Error | null>;
5
9
  connect(): Signal<PermissionResponse | null>;
6
10
  get(): Promise<PermissionResponse>;
7
11
  request(): Promise<PermissionResponse>;
@@ -39,8 +39,13 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
39
39
  //
40
40
  // readonly status = inject(PermissionsService).connect();
41
41
  // // template: {{ status()?.granted }}
42
+ //
43
+ // The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
44
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
45
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
46
+ // only the automatic call is made safe.
42
47
  import { Injectable, signal } from '@angular/core';
43
- import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core';
48
+ import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core/index.js';
44
49
  let PermissionsService = (() => {
45
50
  let _classDecorators = [Injectable({ providedIn: 'root' })];
46
51
  let _classDescriptor;
@@ -56,20 +61,32 @@ let PermissionsService = (() => {
56
61
  __runInitializers(_classThis, _classExtraInitializers);
57
62
  }
58
63
  status = signal(null);
64
+ fetchError = signal(null);
65
+ isAutoFetchStarted = false;
66
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
67
+ error = this.fetchError.asReadonly();
59
68
  connect() {
60
- if (this.status() === null) {
61
- void this.get();
69
+ // Latched rather than guarded on `status() === null`: a failed fetch leaves status null
70
+ // forever, so the old guard turned every later connect() into another native call — unbounded
71
+ // once connect() is reached from a change-detected expression instead of a field initializer.
72
+ if (!this.isAutoFetchStarted) {
73
+ this.isAutoFetchStarted = true;
74
+ this.get().catch((cause) => {
75
+ this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
76
+ });
62
77
  }
63
78
  return this.status.asReadonly();
64
79
  }
65
80
  async get() {
66
81
  const response = await getTrackingPermissionsAsync();
67
82
  this.status.set(response);
83
+ this.fetchError.set(null);
68
84
  return response;
69
85
  }
70
86
  async request() {
71
87
  const response = await requestTrackingPermissionsAsync();
72
88
  this.status.set(response);
89
+ this.fetchError.set(null);
73
90
  return response;
74
91
  }
75
92
  };
@@ -1,2 +1,2 @@
1
- export { getAdvertisingId, requestTrackingPermissionsAsync, getTrackingPermissionsAsync, isAvailable, } from './tracking-transparency';
1
+ export { getAdvertisingId, requestTrackingPermissionsAsync, getTrackingPermissionsAsync, isAvailable, } from './tracking-transparency.js';
2
2
  export { PermissionStatus, } from 'expo-modules-core';
@@ -6,7 +6,7 @@
6
6
  // convention is for each adapter to hand-roll its own permission hook instead (react/hooks,
7
7
  // vue/composables, angular/services), same as brightness/cellular already do.
8
8
  import { Platform, PermissionStatus, UnavailabilityError } from 'expo-modules-core';
9
- import { expoTrackingTransparency } from './native-module';
9
+ import { expoTrackingTransparency } from './native-module.js';
10
10
  const NATIVE_MODULE_NAME = 'TrackingTransparency';
11
11
  /**
12
12
  * Gets the advertising ID, a UUID string intended only for advertising. Returns `null` on the
@@ -2,5 +2,6 @@ import { type PermissionResponse } from '../../../core';
2
2
  export declare function usePermissions(): [
3
3
  PermissionResponse | null,
4
4
  () => Promise<PermissionResponse>,
5
- () => Promise<PermissionResponse>
5
+ () => Promise<PermissionResponse>,
6
+ Error | null
6
7
  ];
@@ -2,29 +2,42 @@
2
2
  // auto-fetches the current permission status on mount, then exposes get/request as imperative
3
3
  // callbacks. Mirrors the shape @symbiote-native/cellular's own usePermissions ships in parallel,
4
4
  // so both stay byte-for-byte consistent.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the 4th tuple slot (`error`)
7
+ // instead of escaping as an unhandled rejection: a null status with a non-null error is how a
8
+ // consumer tells "the fetch failed" from "not fetched yet". get()/request() still reject to their
9
+ // direct caller — only the automatic call is made safe.
5
10
  import { useCallback, useEffect, useRef, useState } from 'react';
6
- import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core';
11
+ import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core/index.js';
7
12
  export function usePermissions() {
8
13
  const isMounted = useRef(true);
9
14
  const [status, setStatus] = useState(null);
15
+ const [error, setError] = useState(null);
10
16
  const getPermission = useCallback(async () => {
11
17
  const response = await getTrackingPermissionsAsync();
12
- if (isMounted.current)
18
+ if (isMounted.current) {
13
19
  setStatus(response);
20
+ setError(null);
21
+ }
14
22
  return response;
15
23
  }, []);
16
24
  const requestPermission = useCallback(async () => {
17
25
  const response = await requestTrackingPermissionsAsync();
18
- if (isMounted.current)
26
+ if (isMounted.current) {
19
27
  setStatus(response);
28
+ setError(null);
29
+ }
20
30
  return response;
21
31
  }, []);
22
32
  useEffect(() => {
23
33
  isMounted.current = true;
24
- void getPermission();
34
+ getPermission().catch((cause) => {
35
+ if (isMounted.current)
36
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
37
+ });
25
38
  return () => {
26
39
  isMounted.current = false;
27
40
  };
28
41
  }, [getPermission]);
29
- return [status, requestPermission, getPermission];
42
+ return [status, requestPermission, getPermission, error];
30
43
  }
@@ -2,5 +2,5 @@
2
2
  // core. Only the permission surface gets a lifecycle hook (there is per-instance state to seed
3
3
  // and refresh); getAdvertisingId/isAvailable are stateless, re-exported verbatim — mirrors the
4
4
  // lifecycle-bucket naming convention of adapters/react/src/hooks.
5
- export { usePermissions } from './hooks/use-permissions';
6
- export * from '../core';
5
+ export { usePermissions } from './hooks/use-permissions/index.js';
6
+ export * from '../core/index.js';
@@ -0,0 +1,2 @@
1
+ export { usePermissions } from './runes/use-permissions.svelte';
2
+ export * from '../core';
@@ -0,0 +1,6 @@
1
+ // @symbiote-native/tracking-transparency/svelte: the Svelte entry over the framework-agnostic core.
2
+ // Same reasoning as the React and Vue entries — only the permission surface gets a lifecycle rune,
3
+ // every other export is a plain re-export — mirrors the lifecycle-bucket naming convention of
4
+ // adapters/svelte/src/runes (never `hooks/`/`composables/`, those are React's and Vue's terms).
5
+ export { usePermissions } from './runes/use-permissions.svelte';
6
+ export * from '../core/index.js';
@@ -0,0 +1,7 @@
1
+ import { type PermissionResponse } from '../../core';
2
+ export declare function usePermissions(): {
3
+ readonly status: PermissionResponse | null;
4
+ readonly error: Error | null;
5
+ request: () => Promise<PermissionResponse>;
6
+ get: () => Promise<PermissionResponse>;
7
+ };
@@ -0,0 +1,49 @@
1
+ // Svelte 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. Just a one-shot get with no ongoing subscription, so `$effect` needs no cleanup.
4
+ //
5
+ // `.svelte.ts` (not `.ts`): runes ($state/$effect) only work in files with this extension
6
+ // outside an actual `.svelte` component. `status` is returned as a getter, NOT a bare
7
+ // `$state`: Svelte 5 reactivity is lexically scoped to the declaring module and doesn't
8
+ // survive being returned as a raw value from a plain function — mirrors how Vue's `Ref`
9
+ // needs `.value` to unwrap.
10
+ //
11
+ // The mount fetch has nobody to reject to, so its failure lands in `error` (a second boxed getter,
12
+ // same reason as `status`) instead of escaping as an unhandled rejection: a null status with a
13
+ // non-null error is how a consumer tells "the fetch failed" from "not fetched yet".
14
+ // get()/request() still reject to their direct caller — only the automatic call is made safe.
15
+ import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../core/index.js';
16
+ export function usePermissions() {
17
+ let status = $state(null);
18
+ let error = $state(null);
19
+ const get = async () => {
20
+ const response = await getTrackingPermissionsAsync();
21
+ status = response;
22
+ error = null;
23
+ return response;
24
+ };
25
+ const request = async () => {
26
+ const response = await requestTrackingPermissionsAsync();
27
+ status = response;
28
+ error = null;
29
+ return response;
30
+ };
31
+ $effect(() => {
32
+ // `get` is a plain closure, not reactive state, so the effect has an empty dependency set and
33
+ // runs exactly once on mount — the twin of Vue's onMounted. The catch runs after the effect
34
+ // has settled, so writing `error` there adds no dependency and does not re-arm it.
35
+ get().catch((cause) => {
36
+ error = cause instanceof Error ? cause : new Error(String(cause));
37
+ });
38
+ });
39
+ return {
40
+ get status() {
41
+ return status;
42
+ },
43
+ get error() {
44
+ return error;
45
+ },
46
+ request,
47
+ get,
48
+ };
49
+ }
@@ -2,6 +2,7 @@ import { type Ref } from '@vue/runtime-core';
2
2
  import { type PermissionResponse } from '../../../core';
3
3
  export declare function usePermissions(): {
4
4
  status: Ref<PermissionResponse | null>;
5
+ error: Ref<Error | null>;
5
6
  request: () => Promise<PermissionResponse>;
6
7
  get: () => Promise<PermissionResponse>;
7
8
  };
@@ -2,22 +2,32 @@
2
2
  // auto-fetches the current permission status on mount (mirrors use-battery-level's
3
3
  // onMounted/onUnmounted style, but there is no ongoing subscription to tear down here, just a
4
4
  // one-shot get), then exposes get/request as imperative callbacks.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the `error` ref instead of
7
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
8
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
9
+ // only the automatic call is made safe.
5
10
  import { onMounted, ref } from '@vue/runtime-core';
6
- import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core';
11
+ import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core/index.js';
7
12
  export function usePermissions() {
8
13
  const status = ref(null);
14
+ const error = ref(null);
9
15
  const get = async () => {
10
16
  const response = await getTrackingPermissionsAsync();
11
17
  status.value = response;
18
+ error.value = null;
12
19
  return response;
13
20
  };
14
21
  const request = async () => {
15
22
  const response = await requestTrackingPermissionsAsync();
16
23
  status.value = response;
24
+ error.value = null;
17
25
  return response;
18
26
  };
19
27
  onMounted(() => {
20
- void get();
28
+ get().catch((cause) => {
29
+ error.value = cause instanceof Error ? cause : new Error(String(cause));
30
+ });
21
31
  });
22
- return { status, request, get };
32
+ return { status, error, request, get };
23
33
  }
@@ -2,5 +2,5 @@
2
2
  // Same reasoning as the React entry — only the permission surface gets a lifecycle composable,
3
3
  // every other export is a plain re-export — mirrors the lifecycle-bucket naming convention of
4
4
  // adapters/vue/src/composables (never `hooks/`, that's React's term).
5
- export { usePermissions } from './composables/use-permissions';
6
- export * from '../core';
5
+ export { usePermissions } from './composables/use-permissions/index.js';
6
+ export * from '../core/index.js';
@@ -3,6 +3,10 @@ import { type PermissionResponse } from '../../../core';
3
3
  import * as i0 from "@angular/core";
4
4
  export declare class PermissionsService {
5
5
  private readonly status;
6
+ private readonly fetchError;
7
+ private isAutoFetchStarted;
8
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
9
+ readonly error: Signal<Error | null>;
6
10
  connect(): Signal<PermissionResponse | null>;
7
11
  get(): Promise<PermissionResponse>;
8
12
  request(): Promise<PermissionResponse>;
@@ -5,32 +5,50 @@
5
5
  //
6
6
  // readonly status = inject(PermissionsService).connect();
7
7
  // // template: {{ status()?.granted }}
8
+ //
9
+ // The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
10
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
11
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
12
+ // only the automatic call is made safe.
8
13
  import { Injectable, signal } from '@angular/core';
9
14
  import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, } from '../../../core';
10
15
  import * as i0 from "@angular/core";
11
16
  export class PermissionsService {
12
17
  status = signal(null, /* @ts-ignore */
13
18
  ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
19
+ fetchError = signal(null, /* @ts-ignore */
20
+ ...(ngDevMode ? [{ debugName: "fetchError" }] : /* istanbul ignore next */ []));
21
+ isAutoFetchStarted = false;
22
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
23
+ error = this.fetchError.asReadonly();
14
24
  connect() {
15
- if (this.status() === null) {
16
- void this.get();
25
+ // Latched rather than guarded on `status() === null`: a failed fetch leaves status null
26
+ // forever, so the old guard turned every later connect() into another native call — unbounded
27
+ // once connect() is reached from a change-detected expression instead of a field initializer.
28
+ if (!this.isAutoFetchStarted) {
29
+ this.isAutoFetchStarted = true;
30
+ this.get().catch((cause) => {
31
+ this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
32
+ });
17
33
  }
18
34
  return this.status.asReadonly();
19
35
  }
20
36
  async get() {
21
37
  const response = await getTrackingPermissionsAsync();
22
38
  this.status.set(response);
39
+ this.fetchError.set(null);
23
40
  return response;
24
41
  }
25
42
  async request() {
26
43
  const response = await requestTrackingPermissionsAsync();
27
44
  this.status.set(response);
45
+ this.fetchError.set(null);
28
46
  return response;
29
47
  }
30
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PermissionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
31
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PermissionsService, providedIn: 'root' });
48
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
49
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, providedIn: 'root' });
32
50
  }
33
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PermissionsService, decorators: [{
51
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, decorators: [{
34
52
  type: Injectable,
35
53
  args: [{ providedIn: 'root' }]
36
54
  }] });
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/angular/services/permissions.service/index.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,4FAA4F;AAC5F,yFAAyF;AACzF,+BAA+B;AAC/B,EAAE;AACF,4DAA4D;AAC5D,yCAAyC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,EAAe,MAAM,eAAe,CAAC;AAChE,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAEhC,MAAM,eAAe,CAAC;;AAGvB,MAAM,OAAO,kBAAkB;IACZ,MAAM,GAAG,MAAM,CAA4B,IAAI;+EAAC,CAAC;IAElE,OAAO;QACL,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,EAAE,CAAC;YAC3B,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;IAClC,CAAC;IAED,KAAK,CAAC,GAAG;QACP,MAAM,QAAQ,GAAG,MAAM,2BAA2B,EAAE,CAAC;QACrD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1B,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,QAAQ,GAAG,MAAM,+BAA+B,EAAE,CAAC;QACzD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1B,OAAO,QAAQ,CAAC;IAClB,CAAC;uGApBU,kBAAkB;2GAAlB,kBAAkB,cADL,MAAM;;2FACnB,kBAAkB;kBAD9B,UAAU;mBAAC,EAAE,UAAU,EAAE,MAAM,EAAE"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/angular/services/permissions.service/index.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,4FAA4F;AAC5F,yFAAyF;AACzF,+BAA+B;AAC/B,EAAE;AACF,4DAA4D;AAC5D,yCAAyC;AACzC,EAAE;AACF,gGAAgG;AAChG,kGAAkG;AAClG,mGAAmG;AACnG,wCAAwC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,EAAe,MAAM,eAAe,CAAC;AAChE,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAEhC,MAAM,eAAe,CAAC;;AAGvB,MAAM,OAAO,kBAAkB;IACZ,MAAM,GAAG,MAAM,CAA4B,IAAI;+EAAC,CAAC;IACjD,UAAU,GAAG,MAAM,CAAe,IAAI;mFAAC,CAAC;IACjD,kBAAkB,GAAG,KAAK,CAAC;IAEnC,gGAAgG;IACvF,KAAK,GAAyB,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;IAEpE,OAAO;QACL,wFAAwF;QACxF,8FAA8F;QAC9F,8FAA8F;QAC9F,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC7B,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;YAC/B,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;gBAClC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YACjF,CAAC,CAAC,CAAC;QACL,CAAC;QACD,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;IAClC,CAAC;IAED,KAAK,CAAC,GAAG;QACP,MAAM,QAAQ,GAAG,MAAM,2BAA2B,EAAE,CAAC;QACrD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,QAAQ,GAAG,MAAM,+BAA+B,EAAE,CAAC;QACzD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,QAAQ,CAAC;IAClB,CAAC;uGAjCU,kBAAkB;2GAAlB,kBAAkB,cADL,MAAM;;2FACnB,kBAAkB;kBAD9B,UAAU;mBAAC,EAAE,UAAU,EAAE,MAAM,EAAE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/tracking-transparency",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,10 @@
30
30
  "types": "./build/react/index.d.ts",
31
31
  "default": "./build/react/index.js"
32
32
  },
33
+ "./svelte": {
34
+ "types": "./build/svelte/index.d.ts",
35
+ "default": "./build/svelte/index.js"
36
+ },
33
37
  "./angular": {
34
38
  "types": "./build-ngc/angular/index.d.ts",
35
39
  "react-native": "./build-ngc/angular/index.js",
@@ -40,7 +44,10 @@
40
44
  "src",
41
45
  "build",
42
46
  "build-ngc",
43
- "native-link.json"
47
+ "native-link.json",
48
+ "!src/**/*.test.*",
49
+ "!src/**/*.spec.*",
50
+ "!src/**/*.detox.*"
44
51
  ],
45
52
  "publishConfig": {
46
53
  "access": "public"
@@ -50,15 +57,17 @@
50
57
  "expo-modules-core": "57.0.5"
51
58
  },
52
59
  "peerDependencies": {
60
+ "@symbiote-native/angular": ">=0.6.2",
53
61
  "@symbiote-native/engine": ">=0.1.7",
62
+ "@symbiote-native/react": ">=0.2.8",
63
+ "@symbiote-native/svelte": ">=0.1.0",
64
+ "@symbiote-native/vue": ">=0.3.8",
54
65
  "@angular/core": ">=20",
55
66
  "@vue/runtime-core": "^3.5.13",
56
67
  "react": ">=19.0.0",
57
68
  "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"
69
+ "svelte": ">=5.56.0",
70
+ "vue": ">=3.5.0"
62
71
  },
63
72
  "peerDependenciesMeta": {
64
73
  "@symbiote-native/angular": {
@@ -67,6 +76,9 @@
67
76
  "@symbiote-native/react": {
68
77
  "optional": true
69
78
  },
79
+ "@symbiote-native/svelte": {
80
+ "optional": true
81
+ },
70
82
  "@symbiote-native/vue": {
71
83
  "optional": true
72
84
  },
@@ -79,24 +91,29 @@
79
91
  "react": {
80
92
  "optional": true
81
93
  },
94
+ "svelte": {
95
+ "optional": true
96
+ },
82
97
  "vue": {
83
98
  "optional": true
84
99
  }
85
100
  },
86
101
  "devDependencies": {
87
- "@angular/compiler": "^22",
88
- "@angular/compiler-cli": "^22",
89
- "@angular/core": "^22",
102
+ "@angular/compiler": "~22.0.8",
103
+ "@angular/compiler-cli": "~22.0.8",
104
+ "@angular/core": "~22.0.8",
90
105
  "@types/node": "^26.0.0",
91
106
  "@types/react": "^19.2.0",
92
107
  "@vue/runtime-core": "^3.5.13",
93
108
  "react": "19.2.3",
109
+ "svelte": "^5.56.0",
94
110
  "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",
111
+ "@symbiote-native/angular": "0.7.0",
112
+ "@symbiote-native/engine": "0.2.0",
113
+ "@symbiote-native/react": "0.3.0",
114
+ "@symbiote-native/svelte": "0.2.0",
98
115
  "@symbiote-native/test-utils": "0.1.6",
99
- "@symbiote-native/react": "0.2.8"
116
+ "@symbiote-native/vue": "0.4.0"
100
117
  },
101
118
  "scripts": {
102
119
  "typecheck": "tsc --build",
@@ -5,6 +5,11 @@
5
5
  //
6
6
  // readonly status = inject(PermissionsService).connect();
7
7
  // // template: {{ status()?.granted }}
8
+ //
9
+ // The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
10
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
11
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
12
+ // only the automatic call is made safe.
8
13
  import { Injectable, signal, type Signal } from '@angular/core';
9
14
  import {
10
15
  getTrackingPermissionsAsync,
@@ -15,10 +20,21 @@ import {
15
20
  @Injectable({ providedIn: 'root' })
16
21
  export class PermissionsService {
17
22
  private readonly status = signal<PermissionResponse | null>(null);
23
+ private readonly fetchError = signal<Error | null>(null);
24
+ private isAutoFetchStarted = false;
25
+
26
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
27
+ readonly error: Signal<Error | null> = this.fetchError.asReadonly();
18
28
 
19
29
  connect(): Signal<PermissionResponse | null> {
20
- if (this.status() === null) {
21
- void this.get();
30
+ // Latched rather than guarded on `status() === null`: a failed fetch leaves status null
31
+ // forever, so the old guard turned every later connect() into another native call — unbounded
32
+ // once connect() is reached from a change-detected expression instead of a field initializer.
33
+ if (!this.isAutoFetchStarted) {
34
+ this.isAutoFetchStarted = true;
35
+ this.get().catch((cause: unknown) => {
36
+ this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
37
+ });
22
38
  }
23
39
  return this.status.asReadonly();
24
40
  }
@@ -26,12 +42,14 @@ export class PermissionsService {
26
42
  async get(): Promise<PermissionResponse> {
27
43
  const response = await getTrackingPermissionsAsync();
28
44
  this.status.set(response);
45
+ this.fetchError.set(null);
29
46
  return response;
30
47
  }
31
48
 
32
49
  async request(): Promise<PermissionResponse> {
33
50
  const response = await requestTrackingPermissionsAsync();
34
51
  this.status.set(response);
52
+ this.fetchError.set(null);
35
53
  return response;
36
54
  }
37
55
  }
@@ -2,6 +2,11 @@
2
2
  // auto-fetches the current permission status on mount, then exposes get/request as imperative
3
3
  // callbacks. Mirrors the shape @symbiote-native/cellular's own usePermissions ships in parallel,
4
4
  // so both stay byte-for-byte consistent.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the 4th tuple slot (`error`)
7
+ // instead of escaping as an unhandled rejection: a null status with a non-null error is how a
8
+ // consumer tells "the fetch failed" from "not fetched yet". get()/request() still reject to their
9
+ // direct caller — only the automatic call is made safe.
5
10
  import { useCallback, useEffect, useRef, useState } from 'react';
6
11
  import {
7
12
  getTrackingPermissionsAsync,
@@ -13,29 +18,39 @@ export function usePermissions(): [
13
18
  PermissionResponse | null,
14
19
  () => Promise<PermissionResponse>,
15
20
  () => Promise<PermissionResponse>,
21
+ Error | null,
16
22
  ] {
17
23
  const isMounted = useRef(true);
18
24
  const [status, setStatus] = useState<PermissionResponse | null>(null);
25
+ const [error, setError] = useState<Error | null>(null);
19
26
 
20
27
  const getPermission = useCallback(async () => {
21
28
  const response = await getTrackingPermissionsAsync();
22
- if (isMounted.current) setStatus(response);
29
+ if (isMounted.current) {
30
+ setStatus(response);
31
+ setError(null);
32
+ }
23
33
  return response;
24
34
  }, []);
25
35
 
26
36
  const requestPermission = useCallback(async () => {
27
37
  const response = await requestTrackingPermissionsAsync();
28
- if (isMounted.current) setStatus(response);
38
+ if (isMounted.current) {
39
+ setStatus(response);
40
+ setError(null);
41
+ }
29
42
  return response;
30
43
  }, []);
31
44
 
32
45
  useEffect(() => {
33
46
  isMounted.current = true;
34
- void getPermission();
47
+ getPermission().catch((cause: unknown) => {
48
+ if (isMounted.current) setError(cause instanceof Error ? cause : new Error(String(cause)));
49
+ });
35
50
  return () => {
36
51
  isMounted.current = false;
37
52
  };
38
53
  }, [getPermission]);
39
54
 
40
- return [status, requestPermission, getPermission];
55
+ return [status, requestPermission, getPermission, error];
41
56
  }
@@ -0,0 +1,7 @@
1
+ // @symbiote-native/tracking-transparency/svelte: the Svelte entry over the framework-agnostic core.
2
+ // Same reasoning as the React and Vue entries — only the permission surface gets a lifecycle rune,
3
+ // every other export is a plain re-export — mirrors the lifecycle-bucket naming convention of
4
+ // adapters/svelte/src/runes (never `hooks/`/`composables/`, those are React's and Vue's terms).
5
+
6
+ export { usePermissions } from './runes/use-permissions.svelte';
7
+ export * from '../core';
@@ -0,0 +1,63 @@
1
+ // Svelte 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. Just a one-shot get with no ongoing subscription, so `$effect` needs no cleanup.
4
+ //
5
+ // `.svelte.ts` (not `.ts`): runes ($state/$effect) only work in files with this extension
6
+ // outside an actual `.svelte` component. `status` is returned as a getter, NOT a bare
7
+ // `$state`: Svelte 5 reactivity is lexically scoped to the declaring module and doesn't
8
+ // survive being returned as a raw value from a plain function — mirrors how Vue's `Ref`
9
+ // needs `.value` to unwrap.
10
+ //
11
+ // The mount fetch has nobody to reject to, so its failure lands in `error` (a second boxed getter,
12
+ // same reason as `status`) instead of escaping as an unhandled rejection: a null status with a
13
+ // non-null error is how a consumer tells "the fetch failed" from "not fetched yet".
14
+ // get()/request() still reject to their direct caller — only the automatic call is made safe.
15
+ import {
16
+ getTrackingPermissionsAsync,
17
+ requestTrackingPermissionsAsync,
18
+ type PermissionResponse,
19
+ } from '../../core';
20
+
21
+ export function usePermissions(): {
22
+ readonly status: PermissionResponse | null;
23
+ readonly error: Error | null;
24
+ request: () => Promise<PermissionResponse>;
25
+ get: () => Promise<PermissionResponse>;
26
+ } {
27
+ let status = $state<PermissionResponse | null>(null);
28
+ let error = $state<Error | null>(null);
29
+
30
+ const get = async (): Promise<PermissionResponse> => {
31
+ const response = await getTrackingPermissionsAsync();
32
+ status = response;
33
+ error = null;
34
+ return response;
35
+ };
36
+
37
+ const request = async (): Promise<PermissionResponse> => {
38
+ const response = await requestTrackingPermissionsAsync();
39
+ status = response;
40
+ error = null;
41
+ return response;
42
+ };
43
+
44
+ $effect(() => {
45
+ // `get` is a plain closure, not reactive state, so the effect has an empty dependency set and
46
+ // runs exactly once on mount — the twin of Vue's onMounted. The catch runs after the effect
47
+ // has settled, so writing `error` there adds no dependency and does not re-arm it.
48
+ get().catch((cause: unknown) => {
49
+ error = cause instanceof Error ? cause : new Error(String(cause));
50
+ });
51
+ });
52
+
53
+ return {
54
+ get status(): PermissionResponse | null {
55
+ return status;
56
+ },
57
+ get error(): Error | null {
58
+ return error;
59
+ },
60
+ request,
61
+ get,
62
+ };
63
+ }
@@ -0,0 +1,6 @@
1
+ // Pulls in svelte's ambient global declarations ($state/$effect/...) for this package's tsc
2
+ // program. adapters/svelte gets these for free because render.ts does a real value import from
3
+ // 'svelte' (any file in a program importing a module loads its ambient globals for the whole
4
+ // program); this package has no such import, so a triple-slash reference does the same job
5
+ // explicitly rather than relying on an incidental side-effect.
6
+ /// <reference types="svelte" />
@@ -2,6 +2,11 @@
2
2
  // auto-fetches the current permission status on mount (mirrors use-battery-level's
3
3
  // onMounted/onUnmounted style, but there is no ongoing subscription to tear down here, just a
4
4
  // one-shot get), then exposes get/request as imperative callbacks.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the `error` ref instead of
7
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
8
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
9
+ // only the automatic call is made safe.
5
10
  import { onMounted, ref, type Ref } from '@vue/runtime-core';
6
11
  import {
7
12
  getTrackingPermissionsAsync,
@@ -11,26 +16,32 @@ import {
11
16
 
12
17
  export function usePermissions(): {
13
18
  status: Ref<PermissionResponse | null>;
19
+ error: Ref<Error | null>;
14
20
  request: () => Promise<PermissionResponse>;
15
21
  get: () => Promise<PermissionResponse>;
16
22
  } {
17
23
  const status = ref<PermissionResponse | null>(null);
24
+ const error = ref<Error | null>(null);
18
25
 
19
26
  const get = async () => {
20
27
  const response = await getTrackingPermissionsAsync();
21
28
  status.value = response;
29
+ error.value = null;
22
30
  return response;
23
31
  };
24
32
 
25
33
  const request = async () => {
26
34
  const response = await requestTrackingPermissionsAsync();
27
35
  status.value = response;
36
+ error.value = null;
28
37
  return response;
29
38
  };
30
39
 
31
40
  onMounted(() => {
32
- void get();
41
+ get().catch((cause: unknown) => {
42
+ error.value = cause instanceof Error ? cause : new Error(String(cause));
43
+ });
33
44
  });
34
45
 
35
- return { status, request, get };
46
+ return { status, error, request, get };
36
47
  }
@@ -1,107 +0,0 @@
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
- });
@@ -1,137 +0,0 @@
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
- });
@@ -1,100 +0,0 @@
1
- // Co-located React-driven test (ADR 0025) for usePermissions. See battery's
2
- // use-battery-state.test.tsx for the shared rationale (mocks `core`, not expo-modules-core
3
- // internals).
4
-
5
- import { createElement, type ReactElement } from 'react';
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
- import { mount, unmount, View } from '@symbiote-native/react';
8
- import { installFabric } from '@symbiote-native/test-utils';
9
- import { usePermissions } 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/react/hooks/use-battery-state's test
18
- // uses for BatteryState — the mock factory's runtime object stands in for the real enum
19
- // (imported here purely for its type), so `PermissionStatus.GRANTED` still type-checks.
20
- vi.mock('../../../core', () => ({
21
- getTrackingPermissionsAsync,
22
- requestTrackingPermissionsAsync,
23
- PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
24
- }));
25
-
26
- const ROOT_TAG = 954;
27
-
28
- const GRANTED: PermissionResponse = {
29
- status: PermissionStatus.GRANTED,
30
- granted: true,
31
- canAskAgain: true,
32
- expires: 'never',
33
- };
34
- const DENIED: PermissionResponse = {
35
- status: PermissionStatus.DENIED,
36
- granted: false,
37
- canAskAgain: true,
38
- expires: 'never',
39
- };
40
-
41
- const results: (PermissionResponse | null)[] = [];
42
- let latestRequest: (() => Promise<PermissionResponse>) | undefined;
43
- let latestGet: (() => Promise<PermissionResponse>) | undefined;
44
-
45
- function Probe(): ReactElement {
46
- const [status, request, get] = usePermissions();
47
- results.push(status);
48
- latestRequest = request;
49
- latestGet = get;
50
- return createElement(View);
51
- }
52
-
53
- const fabric = installFabric();
54
-
55
- beforeEach(() => {
56
- fabric.reset();
57
- results.length = 0;
58
- latestRequest = undefined;
59
- latestGet = undefined;
60
- vi.clearAllMocks();
61
- getTrackingPermissionsAsync.mockResolvedValue(GRANTED);
62
- requestTrackingPermissionsAsync.mockResolvedValue(GRANTED);
63
- });
64
-
65
- afterEach(() => unmount(ROOT_TAG));
66
-
67
- describe('usePermissions', () => {
68
- it('starts at null before the initial fetch resolves', () => {
69
- mount(ROOT_TAG, createElement(Probe));
70
-
71
- expect(results[results.length - 1]).toBe(null);
72
- });
73
-
74
- it('fetches the permission status once on mount', async () => {
75
- mount(ROOT_TAG, createElement(Probe));
76
-
77
- await vi.waitFor(() => expect(results[results.length - 1]).toEqual(GRANTED));
78
- expect(getTrackingPermissionsAsync).toHaveBeenCalledTimes(1);
79
- });
80
-
81
- it('request() delegates to requestTrackingPermissionsAsync and updates the status', async () => {
82
- mount(ROOT_TAG, createElement(Probe));
83
- await vi.waitFor(() => expect(results[results.length - 1]).toEqual(GRANTED));
84
-
85
- requestTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
86
- await latestRequest?.();
87
-
88
- await vi.waitFor(() => expect(results[results.length - 1]).toEqual(DENIED));
89
- });
90
-
91
- it('get() re-fetches and updates the status', async () => {
92
- mount(ROOT_TAG, createElement(Probe));
93
- await vi.waitFor(() => expect(results[results.length - 1]).toEqual(GRANTED));
94
-
95
- getTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
96
- await latestGet?.();
97
-
98
- await vi.waitFor(() => expect(results[results.length - 1]).toEqual(DENIED));
99
- });
100
- });
@@ -1,99 +0,0 @@
1
- // Co-located Vue-driven test (ADR 0025) for usePermissions. See battery's
2
- // use-battery-state.test.ts for the shared rationale.
3
-
4
- import { defineComponent, h } from '@vue/runtime-core';
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { mount, unmount } from '@symbiote-native/vue';
7
- import { installFabric } from '@symbiote-native/test-utils';
8
- import { usePermissions } from './index';
9
- import { PermissionStatus, type PermissionResponse } from '../../../core';
10
-
11
- const { getTrackingPermissionsAsync, requestTrackingPermissionsAsync } = vi.hoisted(() => ({
12
- getTrackingPermissionsAsync: vi.fn(),
13
- requestTrackingPermissionsAsync: vi.fn(),
14
- }));
15
-
16
- // Same enum-shaped-object mock trick packages/battery/src/vue/composables/use-battery-state's
17
- // test uses for BatteryState.
18
- vi.mock('../../../core', () => ({
19
- getTrackingPermissionsAsync,
20
- requestTrackingPermissionsAsync,
21
- PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
22
- }));
23
-
24
- const GRANTED: PermissionResponse = {
25
- status: PermissionStatus.GRANTED,
26
- granted: true,
27
- canAskAgain: true,
28
- expires: 'never',
29
- };
30
- const DENIED: PermissionResponse = {
31
- status: PermissionStatus.DENIED,
32
- granted: false,
33
- canAskAgain: true,
34
- expires: 'never',
35
- };
36
-
37
- const ROOT_TAG = 9954;
38
- const fabric = installFabric();
39
-
40
- beforeEach(() => {
41
- fabric.reset();
42
- vi.clearAllMocks();
43
- getTrackingPermissionsAsync.mockResolvedValue(GRANTED);
44
- requestTrackingPermissionsAsync.mockResolvedValue(GRANTED);
45
- });
46
-
47
- afterEach(() => unmount(ROOT_TAG));
48
-
49
- function mountPermissions(): ReturnType<typeof usePermissions> {
50
- let result: ReturnType<typeof usePermissions> | undefined;
51
- mount(
52
- ROOT_TAG,
53
- defineComponent({
54
- setup: () => {
55
- result = usePermissions();
56
- return () => h('symbiote-text', {}, 'tracking-transparency');
57
- },
58
- }),
59
- );
60
- if (result === undefined) {
61
- throw new Error('setup() did not run');
62
- }
63
- return result;
64
- }
65
-
66
- describe('usePermissions (Vue)', () => {
67
- it('starts at null before the initial fetch resolves', () => {
68
- const { status } = mountPermissions();
69
-
70
- expect(status.value).toBe(null);
71
- });
72
-
73
- it('fetches the permission status once on mount', async () => {
74
- const { status } = mountPermissions();
75
-
76
- await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
77
- expect(getTrackingPermissionsAsync).toHaveBeenCalledTimes(1);
78
- });
79
-
80
- it('request() delegates to requestTrackingPermissionsAsync and updates the status', async () => {
81
- const { status, request } = mountPermissions();
82
- await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
83
-
84
- requestTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
85
- await request();
86
-
87
- expect(status.value).toEqual(DENIED);
88
- });
89
-
90
- it('get() re-fetches and updates the status', async () => {
91
- const { status, get } = mountPermissions();
92
- await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
93
-
94
- getTrackingPermissionsAsync.mockResolvedValueOnce(DENIED);
95
- await get();
96
-
97
- expect(status.value).toEqual(DENIED);
98
- });
99
- });