@symbiote-native/cellular 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.
- package/build/angular/services/permissions.service/index.d.ts +4 -0
- package/build/angular/services/permissions.service/index.js +20 -3
- package/build/core/cellular.js +2 -2
- package/build/react/hooks/use-permissions/index.d.ts +2 -1
- package/build/react/hooks/use-permissions/index.js +18 -5
- package/build/svelte/index.d.ts +2 -0
- package/build/svelte/index.js +6 -0
- package/build/svelte/runes/use-permissions.svelte.d.ts +7 -0
- package/build/svelte/runes/use-permissions.svelte.js +55 -0
- package/build/vue/composables/use-permissions/index.d.ts +1 -0
- package/build/vue/composables/use-permissions/index.js +13 -3
- package/build-ngc/angular/services/permissions.service/index.d.ts +4 -0
- package/build-ngc/angular/services/permissions.service/index.js +23 -5
- package/build-ngc/angular/services/permissions.service/index.js.map +1 -1
- package/package.json +30 -13
- package/src/angular/services/permissions.service/index.ts +20 -2
- package/src/react/hooks/use-permissions/index.ts +19 -4
- package/src/svelte/index.ts +6 -0
- package/src/svelte/runes/use-permissions.svelte.ts +65 -0
- package/src/svelte/svelte-ambient.d.ts +6 -0
- package/src/vue/composables/use-permissions/index.ts +13 -2
- package/src/angular/services/permissions.service/permissions.service.test.ts +0 -109
- package/src/core/cellular.test.ts +0 -193
- package/src/react/hooks/use-permissions/use-permissions.test.tsx +0 -115
- package/src/vue/composables/use-permissions/use-permissions.test.ts +0 -103
|
@@ -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>;
|
|
@@ -37,8 +37,13 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
|
|
|
37
37
|
//
|
|
38
38
|
// readonly status = inject(PermissionsService).connect();
|
|
39
39
|
// // template: {{ status()?.granted }}
|
|
40
|
+
//
|
|
41
|
+
// The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
|
|
42
|
+
// escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
|
|
43
|
+
// "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
|
|
44
|
+
// only the automatic call is made safe.
|
|
40
45
|
import { Injectable, signal } from '@angular/core';
|
|
41
|
-
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core';
|
|
46
|
+
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core/index.js';
|
|
42
47
|
let PermissionsService = (() => {
|
|
43
48
|
let _classDecorators = [Injectable({ providedIn: 'root' })];
|
|
44
49
|
let _classDescriptor;
|
|
@@ -54,20 +59,32 @@ let PermissionsService = (() => {
|
|
|
54
59
|
__runInitializers(_classThis, _classExtraInitializers);
|
|
55
60
|
}
|
|
56
61
|
status = signal(null);
|
|
62
|
+
fetchError = signal(null);
|
|
63
|
+
isAutoFetchStarted = false;
|
|
64
|
+
/** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
|
|
65
|
+
error = this.fetchError.asReadonly();
|
|
57
66
|
connect() {
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
// Latched rather than guarded on `status() === null`: a failed fetch leaves status null
|
|
68
|
+
// forever, so the old guard turned every later connect() into another native call — unbounded
|
|
69
|
+
// once connect() is reached from a change-detected expression instead of a field initializer.
|
|
70
|
+
if (!this.isAutoFetchStarted) {
|
|
71
|
+
this.isAutoFetchStarted = true;
|
|
72
|
+
this.get().catch((cause) => {
|
|
73
|
+
this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
|
|
74
|
+
});
|
|
60
75
|
}
|
|
61
76
|
return this.status.asReadonly();
|
|
62
77
|
}
|
|
63
78
|
async get() {
|
|
64
79
|
const response = await getPermissionsAsync();
|
|
65
80
|
this.status.set(response);
|
|
81
|
+
this.fetchError.set(null);
|
|
66
82
|
return response;
|
|
67
83
|
}
|
|
68
84
|
async request() {
|
|
69
85
|
const response = await requestPermissionsAsync();
|
|
70
86
|
this.status.set(response);
|
|
87
|
+
this.fetchError.set(null);
|
|
71
88
|
return response;
|
|
72
89
|
}
|
|
73
90
|
};
|
package/build/core/cellular.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// all and resolves a plain GRANTED literal. Upstream's web branch (navigator.connection) is out
|
|
7
7
|
// of scope — this repo has no web target for this package's native-module resolution path.
|
|
8
8
|
import { Platform, PermissionStatus, UnavailabilityError, } from 'expo-modules-core';
|
|
9
|
-
import { expoCellular } from './native-module';
|
|
10
|
-
import { CellularGeneration } from './types';
|
|
9
|
+
import { expoCellular } from './native-module.js';
|
|
10
|
+
import { CellularGeneration } from './types.js';
|
|
11
11
|
const NATIVE_MODULE_NAME = 'expo-cellular';
|
|
12
12
|
const GRANTED_PERMISSION_RESPONSE = {
|
|
13
13
|
status: PermissionStatus.GRANTED,
|
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
// React lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
|
|
2
2
|
// current permission status on mount, then exposes request/get to re-check imperatively.
|
|
3
|
+
//
|
|
4
|
+
// The mount fetch has nobody to reject to, so its failure lands in the 4th tuple slot (`error`)
|
|
5
|
+
// instead of escaping as an unhandled rejection: a null status with a non-null error is how a
|
|
6
|
+
// consumer tells "the fetch failed" from "not fetched yet". get()/request() still reject to their
|
|
7
|
+
// direct caller — only the automatic call is made safe.
|
|
3
8
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core';
|
|
9
|
+
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core/index.js';
|
|
5
10
|
export function usePermissions() {
|
|
6
11
|
const isMounted = useRef(true);
|
|
7
12
|
const [status, setStatus] = useState(null);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
8
14
|
const getPermission = useCallback(async () => {
|
|
9
15
|
const response = await getPermissionsAsync();
|
|
10
|
-
if (isMounted.current)
|
|
16
|
+
if (isMounted.current) {
|
|
11
17
|
setStatus(response);
|
|
18
|
+
setError(null);
|
|
19
|
+
}
|
|
12
20
|
return response;
|
|
13
21
|
}, []);
|
|
14
22
|
const requestPermission = useCallback(async () => {
|
|
15
23
|
const response = await requestPermissionsAsync();
|
|
16
|
-
if (isMounted.current)
|
|
24
|
+
if (isMounted.current) {
|
|
17
25
|
setStatus(response);
|
|
26
|
+
setError(null);
|
|
27
|
+
}
|
|
18
28
|
return response;
|
|
19
29
|
}, []);
|
|
20
30
|
useEffect(() => {
|
|
21
31
|
isMounted.current = true;
|
|
22
|
-
|
|
32
|
+
getPermission().catch((cause) => {
|
|
33
|
+
if (isMounted.current)
|
|
34
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
35
|
+
});
|
|
23
36
|
return () => {
|
|
24
37
|
isMounted.current = false;
|
|
25
38
|
};
|
|
26
39
|
}, [getPermission]);
|
|
27
|
-
return [status, requestPermission, getPermission];
|
|
40
|
+
return [status, requestPermission, getPermission, error];
|
|
28
41
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @symbiote-native/cellular/svelte: the Svelte entry over the framework-agnostic core, plus
|
|
2
|
+
// usePermissions — the only stateful surface this package needs a lifecycle wrapper for. The
|
|
3
|
+
// lifecycle bucket is `runes/` (never `hooks/`/`composables/`, those are React's and Vue's terms)
|
|
4
|
+
// — see adapters/svelte/src/runes for the same convention.
|
|
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,55 @@
|
|
|
1
|
+
// Svelte 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. The
|
|
3
|
+
// Svelte twin of vue/composables/use-permissions and react/hooks/use-permissions.
|
|
4
|
+
//
|
|
5
|
+
// `.svelte.ts` (not `.ts`): runes ($state/$effect) are only usable in a file with this extension
|
|
6
|
+
// outside an actual `.svelte` component. `runes/` is Svelte's own term for the bucket React calls
|
|
7
|
+
// `hooks/` and Vue calls `composables/` — see adapters/svelte/src/runes.
|
|
8
|
+
//
|
|
9
|
+
// Vue's onMounted collapses into a `$effect` with no teardown (there is no ongoing subscription
|
|
10
|
+
// here, just a one-shot get). The effect only WRITES `status` (through `get()`), never reads it,
|
|
11
|
+
// so its dependency set stays empty and it fires exactly once on mount.
|
|
12
|
+
//
|
|
13
|
+
// `status` comes back as a GETTER on the returned object rather than a raw `$state` variable:
|
|
14
|
+
// Svelte 5 reactivity is lexically scoped to the declaring module, so a raw `let x = $state(...)`
|
|
15
|
+
// handed out of a plain function arrives dead at the caller. Reading `.status` is the Svelte
|
|
16
|
+
// equivalent of unwrapping Vue's `Ref` via `.value`.
|
|
17
|
+
//
|
|
18
|
+
// The mount fetch has nobody to reject to, so its failure lands in `error` (a second boxed getter,
|
|
19
|
+
// same reason as `status`) instead of escaping as an unhandled rejection: a null status with a
|
|
20
|
+
// non-null error is how a consumer tells "the fetch failed" from "not fetched yet".
|
|
21
|
+
// get()/request() still reject to their direct caller — only the automatic call is made safe.
|
|
22
|
+
import { getPermissionsAsync, requestPermissionsAsync } from '../../core/index.js';
|
|
23
|
+
export function usePermissions() {
|
|
24
|
+
let status = $state(null);
|
|
25
|
+
let error = $state(null);
|
|
26
|
+
const get = async () => {
|
|
27
|
+
const response = await getPermissionsAsync();
|
|
28
|
+
status = response;
|
|
29
|
+
error = null;
|
|
30
|
+
return response;
|
|
31
|
+
};
|
|
32
|
+
const request = async () => {
|
|
33
|
+
const response = await requestPermissionsAsync();
|
|
34
|
+
status = response;
|
|
35
|
+
error = null;
|
|
36
|
+
return response;
|
|
37
|
+
};
|
|
38
|
+
$effect(() => {
|
|
39
|
+
// The catch runs after the effect has settled, so writing `error` there adds no dependency and
|
|
40
|
+
// the effect still fires exactly once.
|
|
41
|
+
get().catch((cause) => {
|
|
42
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
get status() {
|
|
47
|
+
return status;
|
|
48
|
+
},
|
|
49
|
+
get error() {
|
|
50
|
+
return error;
|
|
51
|
+
},
|
|
52
|
+
request,
|
|
53
|
+
get,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -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
|
};
|
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
// Vue lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
|
|
2
2
|
// current permission status on mount, then exposes request/get to re-check imperatively.
|
|
3
|
+
//
|
|
4
|
+
// The mount fetch has nobody to reject to, so its failure lands in the `error` ref instead of
|
|
5
|
+
// escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
|
|
6
|
+
// "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
|
|
7
|
+
// only the automatic call is made safe.
|
|
3
8
|
import { onMounted, ref } from '@vue/runtime-core';
|
|
4
|
-
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core';
|
|
9
|
+
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core/index.js';
|
|
5
10
|
export function usePermissions() {
|
|
6
11
|
const status = ref(null);
|
|
12
|
+
const error = ref(null);
|
|
7
13
|
const get = async () => {
|
|
8
14
|
const response = await getPermissionsAsync();
|
|
9
15
|
status.value = response;
|
|
16
|
+
error.value = null;
|
|
10
17
|
return response;
|
|
11
18
|
};
|
|
12
19
|
const request = async () => {
|
|
13
20
|
const response = await requestPermissionsAsync();
|
|
14
21
|
status.value = response;
|
|
22
|
+
error.value = null;
|
|
15
23
|
return response;
|
|
16
24
|
};
|
|
17
25
|
onMounted(() => {
|
|
18
|
-
|
|
26
|
+
get().catch((cause) => {
|
|
27
|
+
error.value = cause instanceof Error ? cause : new Error(String(cause));
|
|
28
|
+
});
|
|
19
29
|
});
|
|
20
|
-
return { status, request, get };
|
|
30
|
+
return { status, error, request, get };
|
|
21
31
|
}
|
|
@@ -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>;
|
|
@@ -3,32 +3,50 @@
|
|
|
3
3
|
//
|
|
4
4
|
// readonly status = inject(PermissionsService).connect();
|
|
5
5
|
// // template: {{ status()?.granted }}
|
|
6
|
+
//
|
|
7
|
+
// The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
|
|
8
|
+
// escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
|
|
9
|
+
// "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
|
|
10
|
+
// only the automatic call is made safe.
|
|
6
11
|
import { Injectable, signal } from '@angular/core';
|
|
7
12
|
import { getPermissionsAsync, requestPermissionsAsync, } from '../../../core';
|
|
8
13
|
import * as i0 from "@angular/core";
|
|
9
14
|
export class PermissionsService {
|
|
10
15
|
status = signal(null, /* @ts-ignore */
|
|
11
16
|
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
17
|
+
fetchError = signal(null, /* @ts-ignore */
|
|
18
|
+
...(ngDevMode ? [{ debugName: "fetchError" }] : /* istanbul ignore next */ []));
|
|
19
|
+
isAutoFetchStarted = false;
|
|
20
|
+
/** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
|
|
21
|
+
error = this.fetchError.asReadonly();
|
|
12
22
|
connect() {
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
// Latched rather than guarded on `status() === null`: a failed fetch leaves status null
|
|
24
|
+
// forever, so the old guard turned every later connect() into another native call — unbounded
|
|
25
|
+
// once connect() is reached from a change-detected expression instead of a field initializer.
|
|
26
|
+
if (!this.isAutoFetchStarted) {
|
|
27
|
+
this.isAutoFetchStarted = true;
|
|
28
|
+
this.get().catch((cause) => {
|
|
29
|
+
this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
|
|
30
|
+
});
|
|
15
31
|
}
|
|
16
32
|
return this.status.asReadonly();
|
|
17
33
|
}
|
|
18
34
|
async get() {
|
|
19
35
|
const response = await getPermissionsAsync();
|
|
20
36
|
this.status.set(response);
|
|
37
|
+
this.fetchError.set(null);
|
|
21
38
|
return response;
|
|
22
39
|
}
|
|
23
40
|
async request() {
|
|
24
41
|
const response = await requestPermissionsAsync();
|
|
25
42
|
this.status.set(response);
|
|
43
|
+
this.fetchError.set(null);
|
|
26
44
|
return response;
|
|
27
45
|
}
|
|
28
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.
|
|
29
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.
|
|
46
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
47
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, providedIn: 'root' });
|
|
30
48
|
}
|
|
31
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.
|
|
49
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionsService, decorators: [{
|
|
32
50
|
type: Injectable,
|
|
33
51
|
args: [{ providedIn: 'root' }]
|
|
34
52
|
}] });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/angular/services/permissions.service/index.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,uEAAuE;AACvE,EAAE;AACF,4DAA4D;AAC5D,yCAAyC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,EAAe,MAAM,eAAe,CAAC;AAChE,OAAO,EACL,mBAAmB,EACnB,uBAAuB,GAExB,MAAM,eAAe,CAAC;;AAGvB,MAAM,OAAO,kBAAkB;IACZ,MAAM,GAAG,MAAM,CAA4B,IAAI;+EAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/angular/services/permissions.service/index.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,uEAAuE;AACvE,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,mBAAmB,EACnB,uBAAuB,GAExB,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,mBAAmB,EAAE,CAAC;QAC7C,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,uBAAuB,EAAE,CAAC;QACjD,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/cellular",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "expo-cellular wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters. Cellular generation (2G/3G/4G/5G), carrier/SIM info on Android (ISO country code, carrier name, MCC/MNC), VoIP-support detection, and the permission get/request pair gating carrier info on Android.",
|
|
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
|
-
"
|
|
59
|
-
"
|
|
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": "
|
|
88
|
-
"@angular/compiler-cli": "
|
|
89
|
-
"@angular/core": "
|
|
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/
|
|
96
|
-
"@symbiote-native/
|
|
97
|
-
"@symbiote-native/react": "0.
|
|
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/
|
|
116
|
+
"@symbiote-native/vue": "0.4.0"
|
|
100
117
|
},
|
|
101
118
|
"scripts": {
|
|
102
119
|
"typecheck": "tsc --build",
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
// readonly status = inject(PermissionsService).connect();
|
|
5
5
|
// // template: {{ status()?.granted }}
|
|
6
|
+
//
|
|
7
|
+
// The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
|
|
8
|
+
// escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
|
|
9
|
+
// "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
|
|
10
|
+
// only the automatic call is made safe.
|
|
6
11
|
import { Injectable, signal, type Signal } from '@angular/core';
|
|
7
12
|
import {
|
|
8
13
|
getPermissionsAsync,
|
|
@@ -13,10 +18,21 @@ import {
|
|
|
13
18
|
@Injectable({ providedIn: 'root' })
|
|
14
19
|
export class PermissionsService {
|
|
15
20
|
private readonly status = signal<PermissionResponse | null>(null);
|
|
21
|
+
private readonly fetchError = signal<Error | null>(null);
|
|
22
|
+
private isAutoFetchStarted = false;
|
|
23
|
+
|
|
24
|
+
/** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
|
|
25
|
+
readonly error: Signal<Error | null> = this.fetchError.asReadonly();
|
|
16
26
|
|
|
17
27
|
connect(): Signal<PermissionResponse | null> {
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
// Latched rather than guarded on `status() === null`: a failed fetch leaves status null
|
|
29
|
+
// forever, so the old guard turned every later connect() into another native call — unbounded
|
|
30
|
+
// once connect() is reached from a change-detected expression instead of a field initializer.
|
|
31
|
+
if (!this.isAutoFetchStarted) {
|
|
32
|
+
this.isAutoFetchStarted = true;
|
|
33
|
+
this.get().catch((cause: unknown) => {
|
|
34
|
+
this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
|
|
35
|
+
});
|
|
20
36
|
}
|
|
21
37
|
return this.status.asReadonly();
|
|
22
38
|
}
|
|
@@ -24,12 +40,14 @@ export class PermissionsService {
|
|
|
24
40
|
async get(): Promise<PermissionResponse> {
|
|
25
41
|
const response = await getPermissionsAsync();
|
|
26
42
|
this.status.set(response);
|
|
43
|
+
this.fetchError.set(null);
|
|
27
44
|
return response;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
async request(): Promise<PermissionResponse> {
|
|
31
48
|
const response = await requestPermissionsAsync();
|
|
32
49
|
this.status.set(response);
|
|
50
|
+
this.fetchError.set(null);
|
|
33
51
|
return response;
|
|
34
52
|
}
|
|
35
53
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// React lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
|
|
2
2
|
// current permission status on mount, then exposes request/get to re-check imperatively.
|
|
3
|
+
//
|
|
4
|
+
// The mount fetch has nobody to reject to, so its failure lands in the 4th tuple slot (`error`)
|
|
5
|
+
// instead of escaping as an unhandled rejection: a null status with a non-null error is how a
|
|
6
|
+
// consumer tells "the fetch failed" from "not fetched yet". get()/request() still reject to their
|
|
7
|
+
// direct caller — only the automatic call is made safe.
|
|
3
8
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
9
|
import {
|
|
5
10
|
getPermissionsAsync,
|
|
@@ -11,29 +16,39 @@ export function usePermissions(): [
|
|
|
11
16
|
PermissionResponse | null,
|
|
12
17
|
() => Promise<PermissionResponse>,
|
|
13
18
|
() => Promise<PermissionResponse>,
|
|
19
|
+
Error | null,
|
|
14
20
|
] {
|
|
15
21
|
const isMounted = useRef(true);
|
|
16
22
|
const [status, setStatus] = useState<PermissionResponse | null>(null);
|
|
23
|
+
const [error, setError] = useState<Error | null>(null);
|
|
17
24
|
|
|
18
25
|
const getPermission = useCallback(async () => {
|
|
19
26
|
const response = await getPermissionsAsync();
|
|
20
|
-
if (isMounted.current)
|
|
27
|
+
if (isMounted.current) {
|
|
28
|
+
setStatus(response);
|
|
29
|
+
setError(null);
|
|
30
|
+
}
|
|
21
31
|
return response;
|
|
22
32
|
}, []);
|
|
23
33
|
|
|
24
34
|
const requestPermission = useCallback(async () => {
|
|
25
35
|
const response = await requestPermissionsAsync();
|
|
26
|
-
if (isMounted.current)
|
|
36
|
+
if (isMounted.current) {
|
|
37
|
+
setStatus(response);
|
|
38
|
+
setError(null);
|
|
39
|
+
}
|
|
27
40
|
return response;
|
|
28
41
|
}, []);
|
|
29
42
|
|
|
30
43
|
useEffect(() => {
|
|
31
44
|
isMounted.current = true;
|
|
32
|
-
|
|
45
|
+
getPermission().catch((cause: unknown) => {
|
|
46
|
+
if (isMounted.current) setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
47
|
+
});
|
|
33
48
|
return () => {
|
|
34
49
|
isMounted.current = false;
|
|
35
50
|
};
|
|
36
51
|
}, [getPermission]);
|
|
37
52
|
|
|
38
|
-
return [status, requestPermission, getPermission];
|
|
53
|
+
return [status, requestPermission, getPermission, error];
|
|
39
54
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @symbiote-native/cellular/svelte: the Svelte entry over the framework-agnostic core, plus
|
|
2
|
+
// usePermissions — the only stateful surface this package needs a lifecycle wrapper for. The
|
|
3
|
+
// lifecycle bucket is `runes/` (never `hooks/`/`composables/`, those are React's and Vue's terms)
|
|
4
|
+
// — see adapters/svelte/src/runes for the same convention.
|
|
5
|
+
export { usePermissions } from './runes/use-permissions.svelte';
|
|
6
|
+
export * from '../core';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Svelte 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. The
|
|
3
|
+
// Svelte twin of vue/composables/use-permissions and react/hooks/use-permissions.
|
|
4
|
+
//
|
|
5
|
+
// `.svelte.ts` (not `.ts`): runes ($state/$effect) are only usable in a file with this extension
|
|
6
|
+
// outside an actual `.svelte` component. `runes/` is Svelte's own term for the bucket React calls
|
|
7
|
+
// `hooks/` and Vue calls `composables/` — see adapters/svelte/src/runes.
|
|
8
|
+
//
|
|
9
|
+
// Vue's onMounted collapses into a `$effect` with no teardown (there is no ongoing subscription
|
|
10
|
+
// here, just a one-shot get). The effect only WRITES `status` (through `get()`), never reads it,
|
|
11
|
+
// so its dependency set stays empty and it fires exactly once on mount.
|
|
12
|
+
//
|
|
13
|
+
// `status` comes back as a GETTER on the returned object rather than a raw `$state` variable:
|
|
14
|
+
// Svelte 5 reactivity is lexically scoped to the declaring module, so a raw `let x = $state(...)`
|
|
15
|
+
// handed out of a plain function arrives dead at the caller. Reading `.status` is the Svelte
|
|
16
|
+
// equivalent of unwrapping Vue's `Ref` via `.value`.
|
|
17
|
+
//
|
|
18
|
+
// The mount fetch has nobody to reject to, so its failure lands in `error` (a second boxed getter,
|
|
19
|
+
// same reason as `status`) instead of escaping as an unhandled rejection: a null status with a
|
|
20
|
+
// non-null error is how a consumer tells "the fetch failed" from "not fetched yet".
|
|
21
|
+
// get()/request() still reject to their direct caller — only the automatic call is made safe.
|
|
22
|
+
import { getPermissionsAsync, requestPermissionsAsync, type PermissionResponse } from '../../core';
|
|
23
|
+
|
|
24
|
+
export function usePermissions(): {
|
|
25
|
+
readonly status: PermissionResponse | null;
|
|
26
|
+
readonly error: Error | null;
|
|
27
|
+
request: () => Promise<PermissionResponse>;
|
|
28
|
+
get: () => Promise<PermissionResponse>;
|
|
29
|
+
} {
|
|
30
|
+
let status = $state<PermissionResponse | null>(null);
|
|
31
|
+
let error = $state<Error | null>(null);
|
|
32
|
+
|
|
33
|
+
const get = async (): Promise<PermissionResponse> => {
|
|
34
|
+
const response = await getPermissionsAsync();
|
|
35
|
+
status = response;
|
|
36
|
+
error = null;
|
|
37
|
+
return response;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const request = async (): Promise<PermissionResponse> => {
|
|
41
|
+
const response = await requestPermissionsAsync();
|
|
42
|
+
status = response;
|
|
43
|
+
error = null;
|
|
44
|
+
return response;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
$effect(() => {
|
|
48
|
+
// The catch runs after the effect has settled, so writing `error` there adds no dependency and
|
|
49
|
+
// the effect still fires exactly once.
|
|
50
|
+
get().catch((cause: unknown) => {
|
|
51
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
get status(): PermissionResponse | null {
|
|
57
|
+
return status;
|
|
58
|
+
},
|
|
59
|
+
get error(): Error | null {
|
|
60
|
+
return error;
|
|
61
|
+
},
|
|
62
|
+
request,
|
|
63
|
+
get,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -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" />
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Vue lifecycle wiring over the framework-agnostic core (core/cellular.ts) — fetches the
|
|
2
2
|
// current permission status on mount, then exposes request/get to re-check imperatively.
|
|
3
|
+
//
|
|
4
|
+
// The mount fetch has nobody to reject to, so its failure lands in the `error` ref instead of
|
|
5
|
+
// escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
|
|
6
|
+
// "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
|
|
7
|
+
// only the automatic call is made safe.
|
|
3
8
|
import { onMounted, ref, type Ref } from '@vue/runtime-core';
|
|
4
9
|
import {
|
|
5
10
|
getPermissionsAsync,
|
|
@@ -9,26 +14,32 @@ import {
|
|
|
9
14
|
|
|
10
15
|
export function usePermissions(): {
|
|
11
16
|
status: Ref<PermissionResponse | null>;
|
|
17
|
+
error: Ref<Error | null>;
|
|
12
18
|
request: () => Promise<PermissionResponse>;
|
|
13
19
|
get: () => Promise<PermissionResponse>;
|
|
14
20
|
} {
|
|
15
21
|
const status = ref<PermissionResponse | null>(null);
|
|
22
|
+
const error = ref<Error | null>(null);
|
|
16
23
|
|
|
17
24
|
const get = async () => {
|
|
18
25
|
const response = await getPermissionsAsync();
|
|
19
26
|
status.value = response;
|
|
27
|
+
error.value = null;
|
|
20
28
|
return response;
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
const request = async () => {
|
|
24
32
|
const response = await requestPermissionsAsync();
|
|
25
33
|
status.value = response;
|
|
34
|
+
error.value = null;
|
|
26
35
|
return response;
|
|
27
36
|
};
|
|
28
37
|
|
|
29
38
|
onMounted(() => {
|
|
30
|
-
|
|
39
|
+
get().catch((cause: unknown) => {
|
|
40
|
+
error.value = cause instanceof Error ? cause : new Error(String(cause));
|
|
41
|
+
});
|
|
31
42
|
});
|
|
32
43
|
|
|
33
|
-
return { status, request, get };
|
|
44
|
+
return { status, error, request, get };
|
|
34
45
|
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
// Co-located Angular-driven test (ADR 0025) for PermissionsService. See
|
|
2
|
-
// packages/battery/src/angular/services/battery-state.service/battery-state.service.test.ts for
|
|
3
|
-
// the shared rationale, adapted here to test the get/request permission pair instead of a
|
|
4
|
-
// listener subscription.
|
|
5
|
-
|
|
6
|
-
import '@angular/compiler';
|
|
7
|
-
import { Component, inject, type Signal } from '@angular/core';
|
|
8
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import { mount, unmount } from '@symbiote-native/angular';
|
|
10
|
-
import { installFabric } from '@symbiote-native/test-utils';
|
|
11
|
-
import { PermissionsService } from './index';
|
|
12
|
-
|
|
13
|
-
type IPermissionResponse = {
|
|
14
|
-
status: string;
|
|
15
|
-
expires: 'never';
|
|
16
|
-
granted: boolean;
|
|
17
|
-
canAskAgain: boolean;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const GRANTED: IPermissionResponse = {
|
|
21
|
-
status: 'granted',
|
|
22
|
-
expires: 'never',
|
|
23
|
-
granted: true,
|
|
24
|
-
canAskAgain: true,
|
|
25
|
-
};
|
|
26
|
-
const DENIED: IPermissionResponse = {
|
|
27
|
-
status: 'denied',
|
|
28
|
-
expires: 'never',
|
|
29
|
-
granted: false,
|
|
30
|
-
canAskAgain: true,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const getPermissionsAsyncMock = vi.fn(async () => GRANTED);
|
|
34
|
-
const requestPermissionsAsyncMock = vi.fn(async () => GRANTED);
|
|
35
|
-
|
|
36
|
-
vi.mock('../../../core', () => ({
|
|
37
|
-
getPermissionsAsync: () => getPermissionsAsyncMock(),
|
|
38
|
-
requestPermissionsAsync: () => requestPermissionsAsyncMock(),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
const ROOT_TAG = 973;
|
|
42
|
-
const fabric = installFabric();
|
|
43
|
-
const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
|
|
44
|
-
|
|
45
|
-
let capturedResult: Signal<IPermissionResponse | null> | undefined;
|
|
46
|
-
let capturedService: PermissionsService | undefined;
|
|
47
|
-
|
|
48
|
-
@Component({
|
|
49
|
-
selector: 'symbiote-permissions-host',
|
|
50
|
-
standalone: true,
|
|
51
|
-
template: '',
|
|
52
|
-
})
|
|
53
|
-
class PermissionsHost {
|
|
54
|
-
private readonly service = inject(PermissionsService);
|
|
55
|
-
readonly status = this.service.connect();
|
|
56
|
-
|
|
57
|
-
constructor() {
|
|
58
|
-
capturedResult = this.status;
|
|
59
|
-
capturedService = this.service;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
fabric.reset();
|
|
65
|
-
capturedResult = undefined;
|
|
66
|
-
capturedService = undefined;
|
|
67
|
-
getPermissionsAsyncMock.mockClear();
|
|
68
|
-
requestPermissionsAsyncMock.mockClear();
|
|
69
|
-
getPermissionsAsyncMock.mockResolvedValue(GRANTED);
|
|
70
|
-
requestPermissionsAsyncMock.mockResolvedValue(GRANTED);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
afterEach(() => unmount(ROOT_TAG));
|
|
74
|
-
|
|
75
|
-
describe('PermissionsService.connect', () => {
|
|
76
|
-
it('starts at null before the initial get() resolves', () => {
|
|
77
|
-
mount(ROOT_TAG, PermissionsHost);
|
|
78
|
-
|
|
79
|
-
expect(capturedResult?.()).toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('fetches the permission status once connect() is called', async () => {
|
|
83
|
-
mount(ROOT_TAG, PermissionsHost);
|
|
84
|
-
await tick();
|
|
85
|
-
|
|
86
|
-
expect(capturedResult?.()).toEqual(GRANTED);
|
|
87
|
-
expect(getPermissionsAsyncMock).toHaveBeenCalledTimes(1);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('request() re-fetches and updates the signal', async () => {
|
|
91
|
-
mount(ROOT_TAG, PermissionsHost);
|
|
92
|
-
await tick();
|
|
93
|
-
|
|
94
|
-
requestPermissionsAsyncMock.mockResolvedValueOnce(DENIED);
|
|
95
|
-
await capturedService?.request();
|
|
96
|
-
|
|
97
|
-
expect(capturedResult?.()).toEqual(DENIED);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('get() re-fetches and updates the signal', async () => {
|
|
101
|
-
mount(ROOT_TAG, PermissionsHost);
|
|
102
|
-
await tick();
|
|
103
|
-
|
|
104
|
-
getPermissionsAsyncMock.mockResolvedValueOnce(DENIED);
|
|
105
|
-
await capturedService?.get();
|
|
106
|
-
|
|
107
|
-
expect(capturedResult?.()).toEqual(DENIED);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
@@ -1,193 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
});
|