@symbiote-native/cellular 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -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 +76 -0
  7. package/build/core/cellular.d.ts +40 -0
  8. package/build/core/cellular.js +116 -0
  9. package/build/core/index.d.ts +2 -0
  10. package/build/core/index.js +2 -0
  11. package/build/core/native-module.d.ts +13 -0
  12. package/build/core/native-module.js +3 -0
  13. package/build/core/types.d.ts +14 -0
  14. package/build/core/types.js +19 -0
  15. package/build/react/hooks/use-permissions/index.d.ts +6 -0
  16. package/build/react/hooks/use-permissions/index.js +28 -0
  17. package/build/react/index.d.ts +2 -0
  18. package/build/react/index.js +4 -0
  19. package/build/vue/composables/use-permissions/index.d.ts +7 -0
  20. package/build/vue/composables/use-permissions/index.js +21 -0
  21. package/build/vue/index.d.ts +2 -0
  22. package/build/vue/index.js +4 -0
  23. package/build-ngc/angular/index.d.ts +2 -0
  24. package/build-ngc/angular/index.js +5 -0
  25. package/build-ngc/angular/index.js.map +1 -0
  26. package/build-ngc/angular/services/permissions.service/index.d.ts +11 -0
  27. package/build-ngc/angular/services/permissions.service/index.js +35 -0
  28. package/build-ngc/angular/services/permissions.service/index.js.map +1 -0
  29. package/build-ngc/core/cellular.d.ts +40 -0
  30. package/build-ngc/core/cellular.js +117 -0
  31. package/build-ngc/core/cellular.js.map +1 -0
  32. package/build-ngc/core/index.d.ts +2 -0
  33. package/build-ngc/core/index.js +3 -0
  34. package/build-ngc/core/index.js.map +1 -0
  35. package/build-ngc/core/native-module.d.ts +13 -0
  36. package/build-ngc/core/native-module.js +4 -0
  37. package/build-ngc/core/native-module.js.map +1 -0
  38. package/build-ngc/core/types.d.ts +14 -0
  39. package/build-ngc/core/types.js +20 -0
  40. package/build-ngc/core/types.js.map +1 -0
  41. package/native-link.json +12 -0
  42. package/package.json +107 -0
  43. package/src/angular/index.ts +4 -0
  44. package/src/angular/services/permissions.service/index.ts +35 -0
  45. package/src/angular/services/permissions.service/permissions.service.test.ts +109 -0
  46. package/src/core/cellular.test.ts +193 -0
  47. package/src/core/cellular.ts +131 -0
  48. package/src/core/index.ts +16 -0
  49. package/src/core/native-module.ts +24 -0
  50. package/src/core/types.ts +23 -0
  51. package/src/react/hooks/use-permissions/index.ts +39 -0
  52. package/src/react/hooks/use-permissions/use-permissions.test.tsx +115 -0
  53. package/src/react/index.ts +4 -0
  54. package/src/vue/composables/use-permissions/index.ts +34 -0
  55. package/src/vue/composables/use-permissions/use-permissions.test.ts +103 -0
  56. package/src/vue/index.ts +4 -0
@@ -0,0 +1,193 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const FAKE_NATIVE_CELLULAR = {
4
+ getCellularGenerationAsync: vi.fn(async () => 3),
5
+ allowsVoipAsync: vi.fn(async () => true),
6
+ getIsoCountryCodeAsync: vi.fn(async () => 'us'),
7
+ getCarrierNameAsync: vi.fn(async () => 'Fake Carrier'),
8
+ getMobileCountryCodeAsync: vi.fn(async () => '310'),
9
+ getMobileNetworkCodeAsync: vi.fn(async () => '260'),
10
+ getPermissionsAsync: vi.fn(async () => ({
11
+ status: 'granted',
12
+ expires: 'never',
13
+ granted: true,
14
+ canAskAgain: true,
15
+ })),
16
+ requestPermissionsAsync: vi.fn(async () => ({
17
+ status: 'granted',
18
+ expires: 'never',
19
+ granted: true,
20
+ canAskAgain: true,
21
+ })),
22
+ };
23
+
24
+ // The real ExpoCellular native module only exists on device — resolving it via
25
+ // requireNativeModule() at import time would throw in this headless test run, so the
26
+ // module-lookup file is faked in place of expo-modules-core's runtime resolution, same pattern
27
+ // packages/haptics/src/core/haptics.test.ts uses.
28
+ vi.mock('./native-module', () => ({
29
+ expoCellular: FAKE_NATIVE_CELLULAR,
30
+ }));
31
+
32
+ // expo-modules-core's real entry transitively imports 'react-native' for Platform/
33
+ // TurboModuleRegistry, whose Flow-typed source Vitest's Oxc transform can't parse — same fake
34
+ // packages/haptics/src/core/haptics.test.ts uses. PermissionStatus/UnavailabilityError are faked
35
+ // alongside it for the same reason.
36
+ const fakePlatform = { OS: 'android' as 'ios' | 'android' };
37
+
38
+ vi.mock('expo-modules-core', () => ({
39
+ Platform: fakePlatform,
40
+ PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
41
+ UnavailabilityError: class UnavailabilityError extends Error {
42
+ constructor(moduleName: string, propertyName: string) {
43
+ super(`${propertyName} is not available on ${moduleName}`);
44
+ }
45
+ },
46
+ }));
47
+
48
+ const {
49
+ getCellularGenerationAsync,
50
+ allowsVoipAsync,
51
+ getIsoCountryCodeAsync,
52
+ getCarrierNameAsync,
53
+ getMobileCountryCodeAsync,
54
+ getMobileNetworkCodeAsync,
55
+ getPermissionsAsync,
56
+ requestPermissionsAsync,
57
+ } = await import('./cellular');
58
+
59
+ afterEach(() => {
60
+ fakePlatform.OS = 'android';
61
+ vi.clearAllMocks();
62
+ });
63
+
64
+ describe('getCellularGenerationAsync', () => {
65
+ it('delegates to the native module unconditionally (no platform gate)', async () => {
66
+ await expect(getCellularGenerationAsync()).resolves.toBe(3);
67
+ });
68
+
69
+ it('still delegates on iOS', async () => {
70
+ fakePlatform.OS = 'ios';
71
+ await expect(getCellularGenerationAsync()).resolves.toBe(3);
72
+ });
73
+
74
+ it('throws an UnavailabilityError-shaped error when the native method is absent', async () => {
75
+ const { getCellularGenerationAsync: native } = FAKE_NATIVE_CELLULAR;
76
+ // @ts-expect-error -- simulating a platform where the native module has no such method
77
+ FAKE_NATIVE_CELLULAR.getCellularGenerationAsync = undefined;
78
+
79
+ await expect(getCellularGenerationAsync()).rejects.toThrow(
80
+ 'getCellularGenerationAsync is not available on expo-cellular',
81
+ );
82
+
83
+ FAKE_NATIVE_CELLULAR.getCellularGenerationAsync = native;
84
+ });
85
+ });
86
+
87
+ describe('allowsVoipAsync', () => {
88
+ it('resolves null on iOS without calling the native module', async () => {
89
+ fakePlatform.OS = 'ios';
90
+ await expect(allowsVoipAsync()).resolves.toBeNull();
91
+ expect(FAKE_NATIVE_CELLULAR.allowsVoipAsync).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('delegates to the native module on Android', async () => {
95
+ await expect(allowsVoipAsync()).resolves.toBe(true);
96
+ });
97
+
98
+ it('throws an UnavailabilityError-shaped error when the native method is absent on Android', async () => {
99
+ const { allowsVoipAsync: native } = FAKE_NATIVE_CELLULAR;
100
+ // @ts-expect-error -- simulating a platform where the native module has no such method
101
+ FAKE_NATIVE_CELLULAR.allowsVoipAsync = undefined;
102
+
103
+ await expect(allowsVoipAsync()).rejects.toThrow(
104
+ 'allowsVoipAsync is not available on expo-cellular',
105
+ );
106
+
107
+ FAKE_NATIVE_CELLULAR.allowsVoipAsync = native;
108
+ });
109
+ });
110
+
111
+ describe('getIsoCountryCodeAsync', () => {
112
+ it('resolves null on iOS without calling the native module', async () => {
113
+ fakePlatform.OS = 'ios';
114
+ await expect(getIsoCountryCodeAsync()).resolves.toBeNull();
115
+ expect(FAKE_NATIVE_CELLULAR.getIsoCountryCodeAsync).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('delegates to the native module on Android', async () => {
119
+ await expect(getIsoCountryCodeAsync()).resolves.toBe('us');
120
+ });
121
+ });
122
+
123
+ describe('getCarrierNameAsync', () => {
124
+ it('resolves null on iOS without calling the native module', async () => {
125
+ fakePlatform.OS = 'ios';
126
+ await expect(getCarrierNameAsync()).resolves.toBeNull();
127
+ expect(FAKE_NATIVE_CELLULAR.getCarrierNameAsync).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('delegates to the native module on Android', async () => {
131
+ await expect(getCarrierNameAsync()).resolves.toBe('Fake Carrier');
132
+ });
133
+ });
134
+
135
+ describe('getMobileCountryCodeAsync', () => {
136
+ it('resolves null on iOS without calling the native module', async () => {
137
+ fakePlatform.OS = 'ios';
138
+ await expect(getMobileCountryCodeAsync()).resolves.toBeNull();
139
+ expect(FAKE_NATIVE_CELLULAR.getMobileCountryCodeAsync).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('delegates to the native module on Android', async () => {
143
+ await expect(getMobileCountryCodeAsync()).resolves.toBe('310');
144
+ });
145
+ });
146
+
147
+ describe('getMobileNetworkCodeAsync', () => {
148
+ it('resolves null on iOS without calling the native module', async () => {
149
+ fakePlatform.OS = 'ios';
150
+ await expect(getMobileNetworkCodeAsync()).resolves.toBeNull();
151
+ expect(FAKE_NATIVE_CELLULAR.getMobileNetworkCodeAsync).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('delegates to the native module on Android', async () => {
155
+ await expect(getMobileNetworkCodeAsync()).resolves.toBe('260');
156
+ });
157
+ });
158
+
159
+ describe('getPermissionsAsync', () => {
160
+ it('delegates to the native module on Android', async () => {
161
+ await getPermissionsAsync();
162
+ expect(FAKE_NATIVE_CELLULAR.getPermissionsAsync).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('resolves a plain GRANTED literal on iOS without calling the native module', async () => {
166
+ fakePlatform.OS = 'ios';
167
+ await expect(getPermissionsAsync()).resolves.toEqual({
168
+ status: 'granted',
169
+ expires: 'never',
170
+ granted: true,
171
+ canAskAgain: true,
172
+ });
173
+ expect(FAKE_NATIVE_CELLULAR.getPermissionsAsync).not.toHaveBeenCalled();
174
+ });
175
+ });
176
+
177
+ describe('requestPermissionsAsync', () => {
178
+ it('delegates to the native module on Android', async () => {
179
+ await requestPermissionsAsync();
180
+ expect(FAKE_NATIVE_CELLULAR.requestPermissionsAsync).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it('resolves a plain GRANTED literal on iOS without calling the native module', async () => {
184
+ fakePlatform.OS = 'ios';
185
+ await expect(requestPermissionsAsync()).resolves.toEqual({
186
+ status: 'granted',
187
+ expires: 'never',
188
+ granted: true,
189
+ canAskAgain: true,
190
+ });
191
+ expect(FAKE_NATIVE_CELLULAR.requestPermissionsAsync).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -0,0 +1,131 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-cellular/src/Cellular.ts (sdk-57). Carrier/SIM
2
+ // info (ISO country code, carrier name, MCC/MNC) and allowsVoipAsync are Android-only upstream —
3
+ // each short-circuits to `null` on iOS before ever reaching the native module, since iOS exposes
4
+ // none of this. getPermissionsAsync/requestPermissionsAsync mirror that same split: Android
5
+ // delegates to the native module, every other platform needs no permission for cellular info at
6
+ // all and resolves a plain GRANTED literal. Upstream's web branch (navigator.connection) is out
7
+ // of scope — this repo has no web target for this package's native-module resolution path.
8
+ import {
9
+ Platform,
10
+ PermissionStatus,
11
+ UnavailabilityError,
12
+ type PermissionResponse,
13
+ } from 'expo-modules-core';
14
+ import { expoCellular } from './native-module';
15
+ import { CellularGeneration } from './types';
16
+
17
+ const NATIVE_MODULE_NAME = 'expo-cellular';
18
+
19
+ const GRANTED_PERMISSION_RESPONSE: PermissionResponse = {
20
+ status: PermissionStatus.GRANTED,
21
+ expires: 'never',
22
+ granted: true,
23
+ canAskAgain: true,
24
+ };
25
+
26
+ /** Gets the generation of the device's current connection. */
27
+ export async function getCellularGenerationAsync(): Promise<CellularGeneration> {
28
+ if (!expoCellular.getCellularGenerationAsync) {
29
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getCellularGenerationAsync');
30
+ }
31
+ return expoCellular.getCellularGenerationAsync();
32
+ }
33
+
34
+ /**
35
+ * Tells if the carrier of the SIM card allows to make VoIP calls on its network.
36
+ * @deprecated This method is deprecated and will be removed in a future SDK version.
37
+ * @platform android
38
+ */
39
+ export async function allowsVoipAsync(): Promise<boolean | null> {
40
+ if (Platform.OS === 'ios') {
41
+ return null;
42
+ }
43
+ if (!expoCellular.allowsVoipAsync) {
44
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'allowsVoipAsync');
45
+ }
46
+ return expoCellular.allowsVoipAsync();
47
+ }
48
+
49
+ /**
50
+ * Gets the ISO country code of the current registered operator's MCC (Mobile Country Code).
51
+ * @platform android
52
+ */
53
+ export async function getIsoCountryCodeAsync(): Promise<string | null> {
54
+ if (Platform.OS === 'ios') {
55
+ return null;
56
+ }
57
+ if (!expoCellular.getIsoCountryCodeAsync) {
58
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getIsoCountryCodeAsync');
59
+ }
60
+ return expoCellular.getIsoCountryCodeAsync();
61
+ }
62
+
63
+ /**
64
+ * Gets the name of the user's cellular service provider.
65
+ * @platform android
66
+ */
67
+ export async function getCarrierNameAsync(): Promise<string | null> {
68
+ if (Platform.OS === 'ios') {
69
+ return null;
70
+ }
71
+ if (!expoCellular.getCarrierNameAsync) {
72
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getCarrierNameAsync');
73
+ }
74
+ return expoCellular.getCarrierNameAsync();
75
+ }
76
+
77
+ /**
78
+ * Gets the mobile country code (MCC) of the device's current registered operator.
79
+ * @platform android
80
+ */
81
+ export async function getMobileCountryCodeAsync(): Promise<string | null> {
82
+ if (Platform.OS === 'ios') {
83
+ return null;
84
+ }
85
+ if (!expoCellular.getMobileCountryCodeAsync) {
86
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getMobileCountryCodeAsync');
87
+ }
88
+ return expoCellular.getMobileCountryCodeAsync();
89
+ }
90
+
91
+ /**
92
+ * Gets the mobile network code (MNC) of the device's current registered operator.
93
+ * @platform android
94
+ */
95
+ export async function getMobileNetworkCodeAsync(): Promise<string | null> {
96
+ if (Platform.OS === 'ios') {
97
+ return null;
98
+ }
99
+ if (!expoCellular.getMobileNetworkCodeAsync) {
100
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getMobileNetworkCodeAsync');
101
+ }
102
+ return expoCellular.getMobileNetworkCodeAsync();
103
+ }
104
+
105
+ /**
106
+ * Checks user's permissions for accessing cellular information. Android only — iOS/web need no
107
+ * permission for cellular info, so this always resolves granted there.
108
+ */
109
+ export async function getPermissionsAsync(): Promise<PermissionResponse> {
110
+ if (Platform.OS === 'android') {
111
+ if (!expoCellular.getPermissionsAsync) {
112
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getPermissionsAsync');
113
+ }
114
+ return expoCellular.getPermissionsAsync();
115
+ }
116
+ return GRANTED_PERMISSION_RESPONSE;
117
+ }
118
+
119
+ /**
120
+ * Asks the user to grant permissions for accessing cellular information. Android only — iOS/web
121
+ * need no permission for cellular info, so this always resolves granted there.
122
+ */
123
+ export async function requestPermissionsAsync(): Promise<PermissionResponse> {
124
+ if (Platform.OS === 'android') {
125
+ if (!expoCellular.requestPermissionsAsync) {
126
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'requestPermissionsAsync');
127
+ }
128
+ return expoCellular.requestPermissionsAsync();
129
+ }
130
+ return GRANTED_PERMISSION_RESPONSE;
131
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ getCellularGenerationAsync,
3
+ allowsVoipAsync,
4
+ getIsoCountryCodeAsync,
5
+ getCarrierNameAsync,
6
+ getMobileCountryCodeAsync,
7
+ getMobileNetworkCodeAsync,
8
+ getPermissionsAsync,
9
+ requestPermissionsAsync,
10
+ } from './cellular';
11
+ export {
12
+ CellularGeneration,
13
+ PermissionStatus,
14
+ type PermissionResponse,
15
+ type PermissionExpiration,
16
+ } from './types';
@@ -0,0 +1,24 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ import type { PermissionResponse } from 'expo-modules-core';
3
+ import type { CellularGeneration } from './types';
4
+
5
+ const EXPO_CELLULAR_MODULE_NAME = 'ExpoCellular';
6
+
7
+ // Every method is optional — each call site checks for its presence before calling through and
8
+ // throws an UnavailabilityError itself, same convention as
9
+ // packages/haptics/src/core/native-module.ts and packages/local-auth/src/core/native-module.ts.
10
+ // Most of these only exist on the Android native module (CellularModule.kt) — iOS short-circuits
11
+ // on Platform.OS before ever reaching them (see core/cellular.ts), since carrier/SIM info isn't
12
+ // exposed by iOS.
13
+ export type INativeCellularModule = {
14
+ getCellularGenerationAsync?(): Promise<CellularGeneration>;
15
+ allowsVoipAsync?(): Promise<boolean>;
16
+ getIsoCountryCodeAsync?(): Promise<string | null>;
17
+ getCarrierNameAsync?(): Promise<string | null>;
18
+ getMobileCountryCodeAsync?(): Promise<string | null>;
19
+ getMobileNetworkCodeAsync?(): Promise<string | null>;
20
+ getPermissionsAsync?(): Promise<PermissionResponse>;
21
+ requestPermissionsAsync?(): Promise<PermissionResponse>;
22
+ };
23
+
24
+ export const expoCellular = requireNativeModule<INativeCellularModule>(EXPO_CELLULAR_MODULE_NAME);
@@ -0,0 +1,23 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-cellular/src/Cellular.types.ts (sdk-57).
2
+ // PermissionResponse/PermissionStatus/PermissionExpiration come straight from
3
+ // expo-modules-core (which re-exports PermissionsInterface) — never from the `expo`
4
+ // meta-package, unlike upstream's own Cellular.ts.
5
+ export {
6
+ PermissionStatus,
7
+ type PermissionResponse,
8
+ type PermissionExpiration,
9
+ } from 'expo-modules-core';
10
+
11
+ /** Telecommunication technology used to establish a cellular connection. */
12
+ export enum CellularGeneration {
13
+ /** The device's connection generation could not be determined. */
14
+ UNKNOWN = 0,
15
+ /** 2nd generation (GPRS/EDGE-class) connection. */
16
+ CELLULAR_2G = 1,
17
+ /** 3rd generation (UMTS/HSPA-class) connection. */
18
+ CELLULAR_3G = 2,
19
+ /** 4th generation (LTE-class) connection. */
20
+ CELLULAR_4G = 3,
21
+ /** 5th generation (NR-class) connection. */
22
+ CELLULAR_5G = 4,
23
+ }
@@ -0,0 +1,39 @@
1
+ // React lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
2
+ // current permission status on mount, then exposes request/get to re-check imperatively.
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import {
5
+ getPermissionsAsync,
6
+ requestPermissionsAsync,
7
+ type PermissionResponse,
8
+ } from '../../../core';
9
+
10
+ export function usePermissions(): [
11
+ PermissionResponse | null,
12
+ () => Promise<PermissionResponse>,
13
+ () => Promise<PermissionResponse>,
14
+ ] {
15
+ const isMounted = useRef(true);
16
+ const [status, setStatus] = useState<PermissionResponse | null>(null);
17
+
18
+ const getPermission = useCallback(async () => {
19
+ const response = await getPermissionsAsync();
20
+ if (isMounted.current) setStatus(response);
21
+ return response;
22
+ }, []);
23
+
24
+ const requestPermission = useCallback(async () => {
25
+ const response = await requestPermissionsAsync();
26
+ if (isMounted.current) setStatus(response);
27
+ return response;
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ isMounted.current = true;
32
+ void getPermission();
33
+ return () => {
34
+ isMounted.current = false;
35
+ };
36
+ }, [getPermission]);
37
+
38
+ return [status, requestPermission, getPermission];
39
+ }
@@ -0,0 +1,115 @@
1
+ // Co-located React-driven test (ADR 0025) for usePermissions. See
2
+ // packages/battery/src/react/hooks/use-battery-state/use-battery-state.test.tsx for the shared
3
+ // rationale (mocks `core`, not expo-modules-core internals), adapted here to test the
4
+ // get/request permission pair instead of a listener subscription.
5
+
6
+ import { createElement, type ReactElement } from 'react';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { mount, unmount, View } from '@symbiote-native/react';
9
+ import { installFabric } from '@symbiote-native/test-utils';
10
+ import { usePermissions } from './index';
11
+
12
+ type IPermissionResponse = {
13
+ status: string;
14
+ expires: 'never';
15
+ granted: boolean;
16
+ canAskAgain: boolean;
17
+ };
18
+
19
+ const GRANTED: IPermissionResponse = {
20
+ status: 'granted',
21
+ expires: 'never',
22
+ granted: true,
23
+ canAskAgain: true,
24
+ };
25
+ const DENIED: IPermissionResponse = {
26
+ status: 'denied',
27
+ expires: 'never',
28
+ granted: false,
29
+ canAskAgain: true,
30
+ };
31
+
32
+ const { getPermissionsAsync, requestPermissionsAsync } = vi.hoisted(() => ({
33
+ getPermissionsAsync: vi.fn(async () => GRANTED),
34
+ requestPermissionsAsync: vi.fn(async () => GRANTED),
35
+ }));
36
+
37
+ vi.mock('../../../core', () => ({
38
+ getPermissionsAsync,
39
+ requestPermissionsAsync,
40
+ }));
41
+
42
+ const ROOT_TAG = 953;
43
+
44
+ let latestStatus: IPermissionResponse | null = null;
45
+ let requestFn: (() => Promise<IPermissionResponse>) | undefined;
46
+ let getFn: (() => Promise<IPermissionResponse>) | undefined;
47
+
48
+ function Probe(): ReactElement {
49
+ const [status, request, get] = usePermissions();
50
+ latestStatus = status;
51
+ requestFn = request;
52
+ getFn = get;
53
+ return createElement(View);
54
+ }
55
+
56
+ const fabric = installFabric();
57
+
58
+ beforeEach(() => {
59
+ fabric.reset();
60
+ latestStatus = null;
61
+ requestFn = undefined;
62
+ getFn = undefined;
63
+ vi.clearAllMocks();
64
+ getPermissionsAsync.mockResolvedValue(GRANTED);
65
+ requestPermissionsAsync.mockResolvedValue(GRANTED);
66
+ });
67
+
68
+ afterEach(() => unmount(ROOT_TAG));
69
+
70
+ describe('usePermissions', () => {
71
+ it('starts at null before the initial getPermissionsAsync() resolves', () => {
72
+ mount(ROOT_TAG, createElement(Probe));
73
+
74
+ expect(latestStatus).toBeNull();
75
+ });
76
+
77
+ it('fetches the permission status on mount', async () => {
78
+ mount(ROOT_TAG, createElement(Probe));
79
+
80
+ await vi.waitFor(() => expect(latestStatus).toEqual(GRANTED));
81
+ expect(getPermissionsAsync).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it('requestPermission() re-fetches and updates the status', async () => {
85
+ mount(ROOT_TAG, createElement(Probe));
86
+ await vi.waitFor(() => expect(latestStatus).toEqual(GRANTED));
87
+
88
+ requestPermissionsAsync.mockResolvedValueOnce(DENIED);
89
+ await requestFn?.();
90
+
91
+ await vi.waitFor(() => expect(latestStatus).toEqual(DENIED));
92
+ });
93
+
94
+ it('getPermission() re-fetches and updates the status', async () => {
95
+ mount(ROOT_TAG, createElement(Probe));
96
+ await vi.waitFor(() => expect(latestStatus).toEqual(GRANTED));
97
+
98
+ getPermissionsAsync.mockResolvedValueOnce(DENIED);
99
+ await getFn?.();
100
+
101
+ await vi.waitFor(() => expect(latestStatus).toEqual(DENIED));
102
+ });
103
+
104
+ it('does not update state after unmount', async () => {
105
+ mount(ROOT_TAG, createElement(Probe));
106
+ await vi.waitFor(() => expect(latestStatus).toEqual(GRANTED));
107
+
108
+ unmount(ROOT_TAG);
109
+ getPermissionsAsync.mockResolvedValueOnce(DENIED);
110
+ await getFn?.();
111
+
112
+ // state setter is guarded by isMounted, so the captured value from before unmount stands
113
+ expect(latestStatus).toEqual(GRANTED);
114
+ });
115
+ });
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/cellular/react: the React entry over the framework-agnostic core, plus
2
+ // usePermissions — the only stateful surface this package needs a lifecycle wrapper for.
3
+ export { usePermissions } from './hooks/use-permissions';
4
+ export * from '../core';
@@ -0,0 +1,34 @@
1
+ // Vue lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
2
+ // current permission status on mount, then exposes request/get to re-check imperatively.
3
+ import { onMounted, ref, type Ref } from '@vue/runtime-core';
4
+ import {
5
+ getPermissionsAsync,
6
+ requestPermissionsAsync,
7
+ type PermissionResponse,
8
+ } from '../../../core';
9
+
10
+ export function usePermissions(): {
11
+ status: Ref<PermissionResponse | null>;
12
+ request: () => Promise<PermissionResponse>;
13
+ get: () => Promise<PermissionResponse>;
14
+ } {
15
+ const status = ref<PermissionResponse | null>(null);
16
+
17
+ const get = async () => {
18
+ const response = await getPermissionsAsync();
19
+ status.value = response;
20
+ return response;
21
+ };
22
+
23
+ const request = async () => {
24
+ const response = await requestPermissionsAsync();
25
+ status.value = response;
26
+ return response;
27
+ };
28
+
29
+ onMounted(() => {
30
+ void get();
31
+ });
32
+
33
+ return { status, request, get };
34
+ }
@@ -0,0 +1,103 @@
1
+ // Co-located Vue-driven test (ADR 0025) for usePermissions. See
2
+ // packages/battery/src/vue/composables/use-battery-state/use-battery-state.test.ts for the
3
+ // shared rationale, adapted here to test the get/request permission pair instead of a listener
4
+ // subscription.
5
+
6
+ import { defineComponent, h } from '@vue/runtime-core';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { mount, unmount } from '@symbiote-native/vue';
9
+ import { installFabric } from '@symbiote-native/test-utils';
10
+ import { usePermissions } from './index';
11
+
12
+ type IPermissionResponse = {
13
+ status: string;
14
+ expires: 'never';
15
+ granted: boolean;
16
+ canAskAgain: boolean;
17
+ };
18
+
19
+ const GRANTED: IPermissionResponse = {
20
+ status: 'granted',
21
+ expires: 'never',
22
+ granted: true,
23
+ canAskAgain: true,
24
+ };
25
+ const DENIED: IPermissionResponse = {
26
+ status: 'denied',
27
+ expires: 'never',
28
+ granted: false,
29
+ canAskAgain: true,
30
+ };
31
+
32
+ const getPermissionsAsyncMock = vi.fn(async () => GRANTED);
33
+ const requestPermissionsAsyncMock = vi.fn(async () => GRANTED);
34
+
35
+ vi.mock('../../../core', () => ({
36
+ getPermissionsAsync: () => getPermissionsAsyncMock(),
37
+ requestPermissionsAsync: () => requestPermissionsAsyncMock(),
38
+ }));
39
+
40
+ const ROOT_TAG = 9953;
41
+ const fabric = installFabric();
42
+
43
+ beforeEach(() => {
44
+ fabric.reset();
45
+ getPermissionsAsyncMock.mockClear();
46
+ requestPermissionsAsyncMock.mockClear();
47
+ getPermissionsAsyncMock.mockResolvedValue(GRANTED);
48
+ requestPermissionsAsyncMock.mockResolvedValue(GRANTED);
49
+ });
50
+
51
+ afterEach(() => unmount(ROOT_TAG));
52
+
53
+ function mountPermissions(): ReturnType<typeof usePermissions> {
54
+ let result: ReturnType<typeof usePermissions> | undefined;
55
+ mount(
56
+ ROOT_TAG,
57
+ defineComponent({
58
+ setup: () => {
59
+ result = usePermissions();
60
+ return () => h('symbiote-text', {}, 'cellular');
61
+ },
62
+ }),
63
+ );
64
+ if (result === undefined) {
65
+ throw new Error('setup() did not run');
66
+ }
67
+ return result;
68
+ }
69
+
70
+ describe('usePermissions (Vue)', () => {
71
+ it('starts at null before the initial get() resolves', () => {
72
+ const { status } = mountPermissions();
73
+
74
+ expect(status.value).toBeNull();
75
+ });
76
+
77
+ it('fetches the permission status on mount', async () => {
78
+ const { status } = mountPermissions();
79
+
80
+ await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
81
+ expect(getPermissionsAsyncMock).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it('request() re-fetches and updates the ref', async () => {
85
+ const { status, request } = mountPermissions();
86
+ await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
87
+
88
+ requestPermissionsAsyncMock.mockResolvedValueOnce(DENIED);
89
+ await request();
90
+
91
+ expect(status.value).toEqual(DENIED);
92
+ });
93
+
94
+ it('get() re-fetches and updates the ref', async () => {
95
+ const { status, get } = mountPermissions();
96
+ await vi.waitFor(() => expect(status.value).toEqual(GRANTED));
97
+
98
+ getPermissionsAsyncMock.mockResolvedValueOnce(DENIED);
99
+ await get();
100
+
101
+ expect(status.value).toEqual(DENIED);
102
+ });
103
+ });