@mentra/crust 3.2.1 → 3.3.0-beta.942
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/README.md +27 -0
- package/android/build.gradle +15 -9
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +44 -15
- package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
- package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +1 -25
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +4 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
- package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
- package/build/Crust.types.d.ts +25 -11
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +9 -2
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +3 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +20 -8
- package/build/CrustModule.web.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/nativeHttpRequest.d.ts +13 -0
- package/build/nativeHttpRequest.d.ts.map +1 -0
- package/build/nativeHttpRequest.js +51 -0
- package/build/nativeHttpRequest.js.map +1 -0
- package/build/nativeHttpRequest.test.d.ts +2 -0
- package/build/nativeHttpRequest.test.d.ts.map +1 -0
- package/build/nativeHttpRequest.test.js +88 -0
- package/build/nativeHttpRequest.test.js.map +1 -0
- package/ios/Crust.podspec +1 -1
- package/ios/CrustModule.swift +18 -7
- package/ios/PermissionStatusReceipt.swift +105 -0
- package/package.json +2 -2
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +39 -0
- package/src/Crust.types.ts +26 -12
- package/src/CrustModule.ts +9 -1
- package/src/CrustModule.web.ts +18 -7
- package/src/index.ts +1 -0
- package/src/nativeHttpRequest.test.ts +94 -0
- package/src/nativeHttpRequest.ts +74 -0
- package/android/src/internal/AndroidManifest.xml +0 -9
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
package/src/Crust.types.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type CrustModuleEvents = {
|
|
|
16
16
|
onHeading: (params: HeadingPayload) => void
|
|
17
17
|
phone_notification: (event: PhoneNotificationEvent) => void
|
|
18
18
|
phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void
|
|
19
|
-
captions_tester_incident: (event: CaptionsTesterIncidentEvent) => void
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export type NavOffRoutePayload = {
|
|
@@ -114,6 +113,8 @@ export type NavManeuverPayload = {
|
|
|
114
113
|
|
|
115
114
|
export type NavErrorPayload = {
|
|
116
115
|
message: string
|
|
116
|
+
/** True only when native navigation has ended; absent for recoverable reroute errors. */
|
|
117
|
+
terminal?: boolean
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
export type InstalledApp = {
|
|
@@ -144,19 +145,32 @@ export type PhoneNotificationDismissedEvent = {
|
|
|
144
145
|
notificationId: string
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
export type CaptionsTesterIncidentEvent = {
|
|
148
|
-
action?: string
|
|
149
|
-
timestamp?: number
|
|
150
|
-
failure_code?: string
|
|
151
|
-
failure_message?: string
|
|
152
|
-
test_run_id?: string
|
|
153
|
-
scenario_name?: string
|
|
154
|
-
source?: string
|
|
155
|
-
[key: string]: unknown
|
|
156
|
-
}
|
|
157
|
-
|
|
158
148
|
export type CrustViewProps = {
|
|
159
149
|
url: string
|
|
160
150
|
onLoad: (event: {nativeEvent: OnLoadEventPayload}) => void
|
|
161
151
|
style?: StyleProp<ViewStyle>
|
|
162
152
|
}
|
|
153
|
+
|
|
154
|
+
/** Passive OS observations for the currently executing Mentra App. */
|
|
155
|
+
export type AppPermissionStatus = {
|
|
156
|
+
rawStatus: string | null
|
|
157
|
+
status: "granted" | "denied" | "blocked" | "unavailable" | "unknown"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type AppPermissionSnapshot = {
|
|
161
|
+
schemaVersion: 1
|
|
162
|
+
observedAt: string
|
|
163
|
+
identity: {
|
|
164
|
+
pid: number
|
|
165
|
+
uid: number
|
|
166
|
+
bundleId: string
|
|
167
|
+
bundlePath: string
|
|
168
|
+
executablePath: string
|
|
169
|
+
version: string
|
|
170
|
+
build: string
|
|
171
|
+
}
|
|
172
|
+
locationServicesEnabled: boolean
|
|
173
|
+
permissions: Record<"location" | "camera" | "microphone" | "bluetooth" | "localNetwork", AppPermissionStatus>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type AppPermissionReceipt = AppPermissionSnapshot & {requestId: string}
|
package/src/CrustModule.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import {NativeModule, requireNativeModule} from "expo"
|
|
2
2
|
|
|
3
|
-
import {CrustModuleEvents, InstalledApp} from "./Crust.types"
|
|
3
|
+
import {AppPermissionReceipt, AppPermissionSnapshot, CrustModuleEvents, InstalledApp} from "./Crust.types"
|
|
4
4
|
|
|
5
5
|
declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
6
6
|
/** True for the native iOS app running on an Apple Silicon Mac. */
|
|
7
7
|
readonly isIOSAppOnMac?: boolean
|
|
8
|
+
/** iOS: passive actual-app authorization status; never requests consent. */
|
|
9
|
+
getPermissionStatus(requestId: null): Promise<AppPermissionSnapshot>
|
|
10
|
+
/** iOS: a nonce publishes fresh status at the fixed managed-cache path. */
|
|
11
|
+
getPermissionStatus(requestId: string): Promise<AppPermissionReceipt>
|
|
8
12
|
PI: number
|
|
9
13
|
hello(): string
|
|
10
14
|
setValueAsync(value: string): Promise<void>
|
|
15
|
+
/** Android: copy the displayed view's window pixels to a temporary JPEG URI. */
|
|
16
|
+
captureMiniappPreview(viewTag: number): Promise<string>
|
|
11
17
|
nativeHttpRequest(
|
|
18
|
+
requestId: string,
|
|
12
19
|
method: string,
|
|
13
20
|
url: string,
|
|
14
21
|
headers: Record<string, string>,
|
|
15
22
|
body?: string | null,
|
|
16
23
|
): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>
|
|
24
|
+
cancelNativeHttpRequest(requestId: string): Promise<void>
|
|
17
25
|
showAVRoutePicker(tintColor?: string | null): void
|
|
18
26
|
|
|
19
27
|
/**
|
package/src/CrustModule.web.ts
CHANGED
|
@@ -4,18 +4,29 @@ import {CrustModuleEvents} from "./Crust.types"
|
|
|
4
4
|
|
|
5
5
|
class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
6
6
|
PI = Math.PI
|
|
7
|
+
private readonly httpRequests = new Map<string, AbortController>()
|
|
7
8
|
async setValueAsync(value: string): Promise<void> {
|
|
8
9
|
this.emit("onChange", {value})
|
|
9
10
|
}
|
|
10
|
-
async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
async nativeHttpRequest(requestId: string, method: string, url: string, headers: Record<string, string>, body?: string | null) {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
this.httpRequests.set(requestId, controller)
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(url, {method, headers, body, signal: controller.signal})
|
|
16
|
+
return {
|
|
17
|
+
status: response.status,
|
|
18
|
+
statusText: response.statusText,
|
|
19
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
20
|
+
body: await response.text(),
|
|
21
|
+
}
|
|
22
|
+
} finally {
|
|
23
|
+
this.httpRequests.delete(requestId)
|
|
17
24
|
}
|
|
18
25
|
}
|
|
26
|
+
async cancelNativeHttpRequest(requestId: string): Promise<void> {
|
|
27
|
+
this.httpRequests.get(requestId)?.abort()
|
|
28
|
+
this.httpRequests.delete(requestId)
|
|
29
|
+
}
|
|
19
30
|
hello() {
|
|
20
31
|
return "Hello world! 👋"
|
|
21
32
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {nativeHttpRequest, type NativeHttpResult, type NativeHttpTransport} from "./nativeHttpRequest"
|
|
2
|
+
|
|
3
|
+
const response: NativeHttpResult = {status: 200, statusText: "OK", headers: {}, body: "{}"}
|
|
4
|
+
|
|
5
|
+
function deferred<T>() {
|
|
6
|
+
let resolve!: (value: T) => void
|
|
7
|
+
let reject!: (error: unknown) => void
|
|
8
|
+
const promise = new Promise<T>((yes, no) => {
|
|
9
|
+
resolve = yes
|
|
10
|
+
reject = no
|
|
11
|
+
})
|
|
12
|
+
return {promise, resolve, reject}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function transport(request = deferred<NativeHttpResult>()) {
|
|
16
|
+
const native: NativeHttpTransport = {
|
|
17
|
+
nativeHttpRequest: jest.fn(() => request.promise),
|
|
18
|
+
cancelNativeHttpRequest: jest.fn(async () => {}),
|
|
19
|
+
}
|
|
20
|
+
return {native, request}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("nativeHttpRequest", () => {
|
|
24
|
+
test("pre-aborted requests do not start or cancel native work", async () => {
|
|
25
|
+
const {native} = transport()
|
|
26
|
+
const controller = new AbortController()
|
|
27
|
+
const reason = new Error("deadline")
|
|
28
|
+
controller.abort(reason)
|
|
29
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toBe(reason)
|
|
30
|
+
expect(native.nativeHttpRequest).not.toHaveBeenCalled()
|
|
31
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("abort cancels only this request and waits for native cancellation acceptance", async () => {
|
|
35
|
+
const {native, request} = transport()
|
|
36
|
+
const cancellation = deferred<void>()
|
|
37
|
+
native.cancelNativeHttpRequest = jest.fn(() => cancellation.promise)
|
|
38
|
+
const controller = new AbortController()
|
|
39
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener")
|
|
40
|
+
const promise = nativeHttpRequest(native, "POST", "https://example.com", {test: "header"}, "body", controller.signal)
|
|
41
|
+
const rejected = expect(promise).rejects.toMatchObject({name: "AbortError"})
|
|
42
|
+
controller.abort()
|
|
43
|
+
const id = (native.nativeHttpRequest as jest.Mock).mock.calls[0][0]
|
|
44
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledWith(id)
|
|
45
|
+
expect(remove).not.toHaveBeenCalled()
|
|
46
|
+
request.reject(new Error("cancelled socket"))
|
|
47
|
+
cancellation.resolve()
|
|
48
|
+
await rejected
|
|
49
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("an abort during native start is cancelled after start and removes its listener", async () => {
|
|
54
|
+
const {native} = transport()
|
|
55
|
+
const controller = new AbortController()
|
|
56
|
+
const order: string[] = []
|
|
57
|
+
native.nativeHttpRequest = jest.fn(() => {
|
|
58
|
+
order.push("start")
|
|
59
|
+
controller.abort()
|
|
60
|
+
return new Promise<NativeHttpResult>(() => {})
|
|
61
|
+
})
|
|
62
|
+
native.cancelNativeHttpRequest = jest.fn(async () => {order.push("cancel")})
|
|
63
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toMatchObject({name: "AbortError"})
|
|
64
|
+
expect(order).toEqual(["start", "cancel"])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test.each(["success", "failure"])("%s removes its abort listener and a later abort does not cancel", async (outcome) => {
|
|
68
|
+
const {native, request} = transport()
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener")
|
|
71
|
+
const promise = nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)
|
|
72
|
+
if (outcome === "success") {
|
|
73
|
+
request.resolve(response)
|
|
74
|
+
await expect(promise).resolves.toBe(response)
|
|
75
|
+
} else {
|
|
76
|
+
request.reject(new Error("offline"))
|
|
77
|
+
await expect(promise).rejects.toThrow("offline")
|
|
78
|
+
}
|
|
79
|
+
controller.abort()
|
|
80
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("independent calls have separate IDs and unsignalled calls keep the native path", async () => {
|
|
85
|
+
const {native, request} = transport()
|
|
86
|
+
const first = nativeHttpRequest(native, "GET", "https://example.com/first", {}, null)
|
|
87
|
+
const second = nativeHttpRequest(native, "GET", "https://example.com/second", {}, null)
|
|
88
|
+
const calls = (native.nativeHttpRequest as jest.Mock).mock.calls
|
|
89
|
+
expect(calls[0][0]).not.toBe(calls[1][0])
|
|
90
|
+
request.resolve(response)
|
|
91
|
+
await Promise.all([first, second])
|
|
92
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface NativeHttpResult {
|
|
2
|
+
status: number
|
|
3
|
+
statusText: string
|
|
4
|
+
headers: Record<string, string>
|
|
5
|
+
body: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NativeHttpTransport {
|
|
9
|
+
nativeHttpRequest(
|
|
10
|
+
requestId: string,
|
|
11
|
+
method: string,
|
|
12
|
+
url: string,
|
|
13
|
+
headers: Record<string, string>,
|
|
14
|
+
body: string | null,
|
|
15
|
+
): Promise<NativeHttpResult>
|
|
16
|
+
cancelNativeHttpRequest(requestId: string): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let requestSequence = 0
|
|
20
|
+
|
|
21
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
22
|
+
if (signal.reason !== undefined) return signal.reason
|
|
23
|
+
const error = new Error("Native HTTP request aborted")
|
|
24
|
+
error.name = "AbortError"
|
|
25
|
+
return error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Keep Android's background-safe HTTP path while cancelling its actual native request. */
|
|
29
|
+
export function nativeHttpRequest(
|
|
30
|
+
native: NativeHttpTransport,
|
|
31
|
+
method: string,
|
|
32
|
+
url: string,
|
|
33
|
+
headers: Record<string, string>,
|
|
34
|
+
body: string | null,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Promise<NativeHttpResult> {
|
|
37
|
+
if (signal?.aborted) return Promise.reject(abortReason(signal))
|
|
38
|
+
const requestId = `http-${Date.now().toString(36)}-${++requestSequence}`
|
|
39
|
+
// Start first, then subscribe: Expo delivers start and cancel on the same serial queue.
|
|
40
|
+
const request = native.nativeHttpRequest(requestId, method, url, headers, body)
|
|
41
|
+
if (!signal) return request
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let settled = false
|
|
45
|
+
let cancelling = false
|
|
46
|
+
const finish = (action: () => void) => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
signal.removeEventListener("abort", onAbort)
|
|
50
|
+
action()
|
|
51
|
+
}
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
if (settled || cancelling) return
|
|
54
|
+
cancelling = true
|
|
55
|
+
void native.cancelNativeHttpRequest(requestId).then(
|
|
56
|
+
() => finish(() => reject(abortReason(signal))),
|
|
57
|
+
(error: unknown) => finish(() => reject(error)),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
request.then(
|
|
61
|
+
(result) => {
|
|
62
|
+
if (signal.aborted) onAbort()
|
|
63
|
+
else finish(() => resolve(result))
|
|
64
|
+
},
|
|
65
|
+
(error: unknown) => {
|
|
66
|
+
if (signal.aborted) onAbort()
|
|
67
|
+
else finish(() => reject(error))
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
signal.addEventListener("abort", onAbort, {once: true})
|
|
71
|
+
// Handles an abort fired synchronously during native registration.
|
|
72
|
+
if (signal.aborted) onAbort()
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
-
<application>
|
|
3
|
-
<receiver android:name="com.mentra.crust.receivers.CaptionsTesterIncidentReceiver" android:exported="true">
|
|
4
|
-
<intent-filter>
|
|
5
|
-
<action android:name="com.mentra.CAPTIONS_TESTER_INCIDENT"/>
|
|
6
|
-
</intent-filter>
|
|
7
|
-
</receiver>
|
|
8
|
-
</application>
|
|
9
|
-
</manifest>
|
package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
package com.mentra.crust.receivers
|
|
2
|
-
|
|
3
|
-
import android.content.BroadcastReceiver
|
|
4
|
-
import android.content.Context
|
|
5
|
-
import android.content.Intent
|
|
6
|
-
import android.util.Log
|
|
7
|
-
import com.mentra.crust.CrustModule
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Receives broadcasts from the external captions tester and forwards them into React Native so
|
|
11
|
-
* the app can file a normal incident through the existing mobile incident pipeline.
|
|
12
|
-
*/
|
|
13
|
-
class CaptionsTesterIncidentReceiver : BroadcastReceiver() {
|
|
14
|
-
@Suppress("DEPRECATION")
|
|
15
|
-
override fun onReceive(context: Context?, intent: Intent?) {
|
|
16
|
-
if (intent == null) {
|
|
17
|
-
Log.w("CaptionsTesterIncident", "Received null intent")
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
val body =
|
|
22
|
-
hashMapOf<String, Any>(
|
|
23
|
-
"action" to (intent.action ?: "unknown"),
|
|
24
|
-
"timestamp" to System.currentTimeMillis(),
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
intent.getStringExtra("failure_code")?.let { body["failure_code"] = it }
|
|
28
|
-
intent.getStringExtra("failure_message")?.let { body["failure_message"] = it }
|
|
29
|
-
intent.getStringExtra("test_run_id")?.let { body["test_run_id"] = it }
|
|
30
|
-
intent.getStringExtra("scenario_name")?.let { body["scenario_name"] = it }
|
|
31
|
-
intent.getStringExtra("source")?.let { body["source"] = it }
|
|
32
|
-
|
|
33
|
-
intent.extras?.keySet()?.forEach { key ->
|
|
34
|
-
if (body.containsKey(key)) {
|
|
35
|
-
return@forEach
|
|
36
|
-
}
|
|
37
|
-
val value = intent.extras?.get(key) ?: return@forEach
|
|
38
|
-
when (value) {
|
|
39
|
-
is String, is Int, is Long, is Boolean, is Double, is Float -> body[key] = value
|
|
40
|
-
else -> body[key] = value.toString()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
CrustModule.emitCaptionsTesterIncident(body)
|
|
45
|
-
}
|
|
46
|
-
}
|