@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.245
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/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +70 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +97 -0
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -12
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +2 -37
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/CloudClientService.d.ts +1 -1
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/CloudClientService.js +2 -2
- package/build/services/CloudClientService.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +51 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +228 -6
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/PhoneNotificationsSync.d.ts +1 -1
- package/build/services/PhonePhotoCoordinator.d.ts +3 -2
- package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
- package/build/services/PhonePhotoCoordinator.js +19 -14
- package/build/services/PhonePhotoCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +64 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +103 -3
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/services/manifestPermissions.d.ts +13 -0
- package/build/services/manifestPermissions.d.ts.map +1 -0
- package/build/services/manifestPermissions.js +42 -0
- package/build/services/manifestPermissions.js.map +1 -0
- package/build/types/applet.d.ts +13 -1
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +138 -0
- package/src/services/AppRegistry.ts +3 -40
- package/src/services/CloudClientService.ts +2 -2
- package/src/services/LocalMiniappRuntime.ts +268 -7
- package/src/services/PhonePhotoCoordinator.ts +18 -14
- package/src/services/SoftapCallTransport.ts +151 -16
- package/src/services/manifestPermissions.ts +44 -0
- package/src/types/applet.ts +11 -0
|
@@ -37,12 +37,7 @@ export type SoftapPhase = "idle" | "starting" | "live" | "stopping" | "failed"
|
|
|
37
37
|
|
|
38
38
|
/** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
|
|
39
39
|
export class SoftapCallError extends Error {
|
|
40
|
-
constructor(
|
|
41
|
-
readonly step: SoftapStep,
|
|
42
|
-
readonly code: string,
|
|
43
|
-
message: string,
|
|
44
|
-
readonly cause?: unknown,
|
|
45
|
-
) {
|
|
40
|
+
constructor(readonly step: SoftapStep, readonly code: string, message: string, readonly cause?: unknown) {
|
|
46
41
|
super(message)
|
|
47
42
|
this.name = "SoftapCallError"
|
|
48
43
|
}
|
|
@@ -51,6 +46,73 @@ export class SoftapCallError extends Error {
|
|
|
51
46
|
/** Gallery sync already learned this: glasses report enabled before the SSID is in the phone scan. */
|
|
52
47
|
export const HOTSPOT_BROADCAST_WAIT_MS = 3_000
|
|
53
48
|
|
|
49
|
+
/**
|
|
50
|
+
* What the glasses→phone hop is worth at 540p over a hotspot with one client and no contention.
|
|
51
|
+
*
|
|
52
|
+
* Not the ACS ceiling: this link is a metre of air between two devices that are already paired,
|
|
53
|
+
* and the profile's `maxBitrateBps` describes the phone→Teams hop, which crosses the internet.
|
|
54
|
+
* Sharing one number between them makes the easy hop obey the hard hop's limits.
|
|
55
|
+
*/
|
|
56
|
+
export const GLASSES_PHONE_BITRATE_BPS = 2_500_000
|
|
57
|
+
|
|
58
|
+
/** Where the encoder starts. High, because the link is good from the first frame. */
|
|
59
|
+
const SOFTAP_START_BITRATE_BPS = 2_000_000
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The floor. This is the whole point of the policy.
|
|
63
|
+
*
|
|
64
|
+
* WHIP's default has none, so after an ICE interruption WebRTC restarts at its own minimum and
|
|
65
|
+
* climbs by probing — which is the "potato for a while" the wearer reports. The link did not get
|
|
66
|
+
* worse; the estimator merely forgot what it knew. A floor says: this hop is a hotspot, not a
|
|
67
|
+
* congested uplink, and it is never worth less than this.
|
|
68
|
+
*
|
|
69
|
+
* That trade is the opposite of Mentra-Call#27's cloud-WHIP review: a 1 Mbps floor on Auto over
|
|
70
|
+
* LTE turns a weak WAN into freezes. This function is SoftAP-only. Cloud WHIP must not call it.
|
|
71
|
+
*/
|
|
72
|
+
const SOFTAP_MIN_BITRATE_BPS = 1_200_000
|
|
73
|
+
|
|
74
|
+
/** Field names are the glasses' `WhipStreamConfig` parser's, where `bitrate` means the maximum. */
|
|
75
|
+
export interface SoftapVideoPolicy {
|
|
76
|
+
width: number
|
|
77
|
+
height: number
|
|
78
|
+
fps: number
|
|
79
|
+
bitrate: number
|
|
80
|
+
initialBitrateBps: number
|
|
81
|
+
minBitrateBps: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The bitrate the glasses are told to hold on the hop to the phone.
|
|
86
|
+
*
|
|
87
|
+
* The hotspot has its own bitrate budget, independent of the phone-to-Teams cap. Above 540p,
|
|
88
|
+
* allow a higher requested ceiling while retaining the hotspot budget for low WAN caps.
|
|
89
|
+
*/
|
|
90
|
+
export function softapVideoPolicy(video: {
|
|
91
|
+
width: number
|
|
92
|
+
height: number
|
|
93
|
+
fps: number
|
|
94
|
+
maxBitrateBps: number
|
|
95
|
+
}): SoftapVideoPolicy {
|
|
96
|
+
const isSmall = video.width * video.height <= 960 * 540
|
|
97
|
+
if (!Number.isFinite(video.maxBitrateBps) || video.maxBitrateBps <= 0) {
|
|
98
|
+
throw new Error(`invalid SoftAP video bitrate: ${video.maxBitrateBps}`)
|
|
99
|
+
}
|
|
100
|
+
const max = isSmall ? GLASSES_PHONE_BITRATE_BPS : Math.max(GLASSES_PHONE_BITRATE_BPS, video.maxBitrateBps)
|
|
101
|
+
const initialBitrateBps = Math.min(SOFTAP_START_BITRATE_BPS, max)
|
|
102
|
+
const minBitrateBps = Math.min(SOFTAP_MIN_BITRATE_BPS, initialBitrateBps)
|
|
103
|
+
if (!(minBitrateBps <= initialBitrateBps && initialBitrateBps <= max)) {
|
|
104
|
+
throw new Error(`incoherent SoftAP video policy: min=${minBitrateBps} start=${initialBitrateBps} max=${max}`)
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
width: video.width,
|
|
108
|
+
height: video.height,
|
|
109
|
+
fps: video.fps,
|
|
110
|
+
bitrate: max,
|
|
111
|
+
initialBitrateBps,
|
|
112
|
+
minBitrateBps,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
54
116
|
/**
|
|
55
117
|
* Android's WifiNetworkSpecifier called onUnavailable. The native message lists three causes
|
|
56
118
|
* because the callback does not say which one happened; on the 18:02 Samsung path the SSID was
|
|
@@ -94,6 +156,15 @@ export interface SoftapProgress {
|
|
|
94
156
|
export type SoftapStepReporter = (detail: string) => void
|
|
95
157
|
|
|
96
158
|
export interface SoftapCallDeps {
|
|
159
|
+
/**
|
|
160
|
+
* Is this phone's Wi-Fi radio on? Asked before anything is built.
|
|
161
|
+
*
|
|
162
|
+
* The station radio is what reaches the glasses, and no app can turn it on since Android 10.
|
|
163
|
+
* Finding out at `scopedJoin` means the glasses already raised a hotspot for a join that cannot
|
|
164
|
+
* happen, so the wearer pays a teardown for a condition that was knowable up front. Optional:
|
|
165
|
+
* a host that cannot answer skips the preflight and relies on the native throw at `scopedJoin`.
|
|
166
|
+
*/
|
|
167
|
+
isWifiEnabled?(): Promise<boolean>
|
|
97
168
|
/** Enable the glasses hotspot and return its credentials. */
|
|
98
169
|
startHotspot(report?: SoftapStepReporter): Promise<{ssid: string; passphrase: string}>
|
|
99
170
|
/**
|
|
@@ -334,6 +405,43 @@ export class SoftapCallTransport {
|
|
|
334
405
|
return [...this.teardownFailures]
|
|
335
406
|
}
|
|
336
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Refuse the call while the Wi-Fi radio is off, before the glasses are asked for anything.
|
|
410
|
+
*
|
|
411
|
+
* Ordered ahead of the hotspot step rather than folded into `scopedJoin` because the cost of
|
|
412
|
+
* learning late is paid by the glasses: a hotspot raised, a teardown, and a wearer watching a
|
|
413
|
+
* checklist fail at step 2 for a toggle on their own phone. The native join keeps its own throw
|
|
414
|
+
* for the radio that goes off in between — this is the explainable path, that one is the racy
|
|
415
|
+
* backstop.
|
|
416
|
+
*
|
|
417
|
+
* A probe that throws is treated as "unknown, carry on": an unanswerable question about the
|
|
418
|
+
* radio must not be the thing that stops a call the radio would have carried.
|
|
419
|
+
*/
|
|
420
|
+
private async preflightWifi(): Promise<void> {
|
|
421
|
+
if (!this.deps.isWifiEnabled) return
|
|
422
|
+
let enabled: boolean
|
|
423
|
+
try {
|
|
424
|
+
enabled = await this.deps.isWifiEnabled()
|
|
425
|
+
} catch (error) {
|
|
426
|
+
softapTraceFailure("softap_wifi_preflight_unknown", {
|
|
427
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
428
|
+
})
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
softapTrace("softap_wifi_preflight", {enabled})
|
|
432
|
+
if (enabled) return
|
|
433
|
+
this.setStep("hotspot", {status: "failed", error: "This phone's Wi-Fi is off"})
|
|
434
|
+
this.emitProgress()
|
|
435
|
+
// Code in the message as well as on the error: the miniapp classifies on the message today
|
|
436
|
+
// (the structured code only arrives on the join rejection), and every other SoftAP failure
|
|
437
|
+
// carries its code the same way.
|
|
438
|
+
throw new SoftapCallError(
|
|
439
|
+
"hotspot",
|
|
440
|
+
"SOFTAP_WIFI_DISABLED",
|
|
441
|
+
"SOFTAP_WIFI_DISABLED: this phone's Wi-Fi is off, so it cannot reach the glasses.",
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
337
445
|
/**
|
|
338
446
|
* Runs the sequence. On any failure the partial sequence is torn down before the error is
|
|
339
447
|
* rethrown, so a failed start never leaves a hotspot up or a publisher running.
|
|
@@ -370,6 +478,8 @@ export class SoftapCallTransport {
|
|
|
370
478
|
this.emitProgress()
|
|
371
479
|
|
|
372
480
|
try {
|
|
481
|
+
await this.preflightWifi()
|
|
482
|
+
|
|
373
483
|
await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
|
|
374
484
|
report("Asking the glasses to turn on their hotspot")
|
|
375
485
|
const hotspot = await this.deps.startHotspot(report)
|
|
@@ -389,9 +499,7 @@ export class SoftapCallTransport {
|
|
|
389
499
|
const hotspot = this.requireHotspot()
|
|
390
500
|
let bindAddress: string | undefined
|
|
391
501
|
await this.step(generation, "scopedJoin", "SCOPED_JOIN_FAILED", async (report) => {
|
|
392
|
-
report(
|
|
393
|
-
`Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`,
|
|
394
|
-
)
|
|
502
|
+
report(`Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`)
|
|
395
503
|
bindAddress = await this.deps.joinScopedNetwork(hotspot.ssid, hotspot.passphrase, report)
|
|
396
504
|
softapTrace("scoped_network_joined", {bindAddress: bindAddress ?? "unknown"})
|
|
397
505
|
if (bindAddress) report(`Phone is ${bindAddress} on ${hotspot.ssid}`)
|
|
@@ -718,10 +826,17 @@ export function createSoftapCallDeps(args: {
|
|
|
718
826
|
meetingUrl: string
|
|
719
827
|
token: string
|
|
720
828
|
displayName?: string
|
|
829
|
+
/**
|
|
830
|
+
* The ACS outgoing profile. Used here only to size the glasses→phone hop; when absent the
|
|
831
|
+
* glasses keep their WHIP defaults, which is what every pre-policy build did.
|
|
832
|
+
*/
|
|
833
|
+
video?: {width: number; height: number; fps: number; maxBitrateBps: number}
|
|
721
834
|
/** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
|
|
722
835
|
awaitFirstFrame: () => Promise<void>
|
|
723
836
|
subsystems: {
|
|
724
837
|
setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
|
|
838
|
+
/** Whether this phone's Wi-Fi radio is on. Optional: only Android hosts can answer it. */
|
|
839
|
+
isWifiEnabled?: () => Promise<boolean>
|
|
725
840
|
joinScopedNetwork: (ssid: string, passphrase: string, gateway?: string) => Promise<string | undefined>
|
|
726
841
|
leaveScopedNetwork: () => Promise<void>
|
|
727
842
|
cancelScopedNetworkJoin?: () => Promise<void>
|
|
@@ -740,7 +855,13 @@ export function createSoftapCallDeps(args: {
|
|
|
740
855
|
ingestUrl: () => string | null
|
|
741
856
|
startPublishing: (
|
|
742
857
|
packageName: string,
|
|
743
|
-
options: {
|
|
858
|
+
options: {
|
|
859
|
+
streamUrl: string
|
|
860
|
+
ice: {stun: string}
|
|
861
|
+
traceId: string
|
|
862
|
+
captureAudio?: boolean
|
|
863
|
+
video?: SoftapVideoPolicy
|
|
864
|
+
},
|
|
744
865
|
) => Promise<unknown>
|
|
745
866
|
stopPublishing: (packageName: string) => Promise<void>
|
|
746
867
|
/**
|
|
@@ -784,6 +905,7 @@ export function createSoftapCallDeps(args: {
|
|
|
784
905
|
const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS
|
|
785
906
|
let gatewayAddress: string | undefined
|
|
786
907
|
return {
|
|
908
|
+
isWifiEnabled: subsystems.isWifiEnabled ? () => subsystems.isWifiEnabled!() : undefined,
|
|
787
909
|
startHotspot: async (report) => {
|
|
788
910
|
const enable = async () => {
|
|
789
911
|
const status = await subsystems.setHotspotState(true)
|
|
@@ -809,7 +931,7 @@ export function createSoftapCallDeps(args: {
|
|
|
809
931
|
report?.("Glasses hotspot did not start; turning it off and trying again")
|
|
810
932
|
await subsystems.setHotspotState(false)
|
|
811
933
|
if (hotspotBroadcastWaitMs > 0) {
|
|
812
|
-
await new Promise<void>(resolve => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)))
|
|
934
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)))
|
|
813
935
|
}
|
|
814
936
|
return await enable()
|
|
815
937
|
}
|
|
@@ -818,7 +940,7 @@ export function createSoftapCallDeps(args: {
|
|
|
818
940
|
if (hotspotBroadcastWaitMs <= 0) return
|
|
819
941
|
softapTrace("hotspot_broadcast_wait", {ms: hotspotBroadcastWaitMs})
|
|
820
942
|
report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`)
|
|
821
|
-
await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
|
|
943
|
+
await new Promise<void>((resolve) => setTimeout(resolve, hotspotBroadcastWaitMs))
|
|
822
944
|
},
|
|
823
945
|
stopHotspot: async () => {
|
|
824
946
|
await subsystems.setHotspotState(false)
|
|
@@ -842,10 +964,8 @@ export function createSoftapCallDeps(args: {
|
|
|
842
964
|
if (status.state !== "enabled" || !status.ssid || !status.password) throw error
|
|
843
965
|
gatewayAddress = status.localIp
|
|
844
966
|
if (hotspotBroadcastWaitMs > 0) {
|
|
845
|
-
report?.(
|
|
846
|
-
|
|
847
|
-
)
|
|
848
|
-
await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
|
|
967
|
+
report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`)
|
|
968
|
+
await new Promise<void>((resolve) => setTimeout(resolve, hotspotBroadcastWaitMs))
|
|
849
969
|
}
|
|
850
970
|
address = await joinOnce(status.ssid, status.password)
|
|
851
971
|
}
|
|
@@ -930,6 +1050,20 @@ export function createSoftapCallDeps(args: {
|
|
|
930
1050
|
? "Publishing video only; the wearer's voice comes over Bluetooth LC3"
|
|
931
1051
|
: "Publishing video and the glasses microphone",
|
|
932
1052
|
)
|
|
1053
|
+
const policy = args.video ? softapVideoPolicy(args.video) : undefined
|
|
1054
|
+
if (policy) {
|
|
1055
|
+
// Traced at send time rather than read back: the glasses log what they applied in
|
|
1056
|
+
// `applyBitrateConstraints`, and the pair of lines is what shows a clamp we did not
|
|
1057
|
+
// intend. One line alone can only ever show agreement with itself.
|
|
1058
|
+
softapTrace("softap_video_policy", {
|
|
1059
|
+
width: policy.width,
|
|
1060
|
+
height: policy.height,
|
|
1061
|
+
fps: policy.fps,
|
|
1062
|
+
min: policy.minBitrateBps,
|
|
1063
|
+
start: policy.initialBitrateBps,
|
|
1064
|
+
max: policy.bitrate,
|
|
1065
|
+
})
|
|
1066
|
+
}
|
|
933
1067
|
try {
|
|
934
1068
|
await subsystems.startPublishing(packageName, {
|
|
935
1069
|
streamUrl: ingestUrl,
|
|
@@ -938,6 +1072,7 @@ export function createSoftapCallDeps(args: {
|
|
|
938
1072
|
ice: {stun: ""},
|
|
939
1073
|
traceId,
|
|
940
1074
|
captureAudio: !lc3Uplink,
|
|
1075
|
+
...(policy ? {video: policy} : {}),
|
|
941
1076
|
})
|
|
942
1077
|
} finally {
|
|
943
1078
|
unsubscribe?.()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type {AppletPermission, AppPermissionType} from "../types/applet"
|
|
2
|
+
|
|
3
|
+
const ALLOWED_PERMISSION_TYPES: ReadonlySet<AppPermissionType> = new Set<AppPermissionType>([
|
|
4
|
+
"MICROPHONE",
|
|
5
|
+
"CAMERA",
|
|
6
|
+
// Phone camera, not glasses camera. Mentra Call needs this on the home-screen
|
|
7
|
+
// permission list so opening the miniapp prompts for Android CAMERA; without
|
|
8
|
+
// it the join later fails because ACS publishes video from this phone.
|
|
9
|
+
"PHONE_CAMERA",
|
|
10
|
+
"CALENDAR",
|
|
11
|
+
"LOCATION",
|
|
12
|
+
"BACKGROUND_LOCATION",
|
|
13
|
+
"READ_NOTIFICATIONS",
|
|
14
|
+
"POST_NOTIFICATIONS",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize the `permissions` field from a miniapp.json manifest.
|
|
19
|
+
*
|
|
20
|
+
* New miniapps ship `[{type, required?, description?}]` objects. A few older
|
|
21
|
+
* installed bundles may have `["MICROPHONE", ...]` plain strings. Accept both.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeManifestPermissions(
|
|
24
|
+
raw: Array<string | {type: string; required?: boolean; description?: string}> | undefined,
|
|
25
|
+
): AppletPermission[] {
|
|
26
|
+
if (!Array.isArray(raw)) return []
|
|
27
|
+
const out: AppletPermission[] = []
|
|
28
|
+
for (const p of raw) {
|
|
29
|
+
if (typeof p === "string") {
|
|
30
|
+
if (ALLOWED_PERMISSION_TYPES.has(p as AppPermissionType)) {
|
|
31
|
+
out.push({type: p as AppPermissionType, required: true})
|
|
32
|
+
}
|
|
33
|
+
} else if (p && typeof p === "object" && typeof p.type === "string") {
|
|
34
|
+
if (ALLOWED_PERMISSION_TYPES.has(p.type as AppPermissionType)) {
|
|
35
|
+
out.push({
|
|
36
|
+
type: p.type as AppPermissionType,
|
|
37
|
+
...(typeof p.required === "boolean" ? {required: p.required} : {}),
|
|
38
|
+
...(typeof p.description === "string" ? {description: p.description} : {}),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
package/src/types/applet.ts
CHANGED
|
@@ -19,7 +19,18 @@ export type AppletType = "standard" | "background" | "system_dashboard"
|
|
|
19
19
|
export type AppPermissionType =
|
|
20
20
|
| "ALL"
|
|
21
21
|
| "MICROPHONE"
|
|
22
|
+
/** The glasses camera. */
|
|
22
23
|
| "CAMERA"
|
|
24
|
+
/**
|
|
25
|
+
* This phone's own camera.
|
|
26
|
+
*
|
|
27
|
+
* Separate from `CAMERA` because that one has always meant the glasses, for every miniapp, and
|
|
28
|
+
* is never prompted for as an OS permission. A miniapp that publishes video *from the phone*
|
|
29
|
+
* (an ACS Teams call does, even though the frames originate on the glasses) needs the Android
|
|
30
|
+
* `CAMERA` runtime permission, and asking for it under the existing type would start prompting
|
|
31
|
+
* every miniapp on the platform for a camera it does not use.
|
|
32
|
+
*/
|
|
33
|
+
| "PHONE_CAMERA"
|
|
23
34
|
| "CALENDAR"
|
|
24
35
|
| "LOCATION"
|
|
25
36
|
| "BACKGROUND_LOCATION"
|