@mentra/crust 0.1.0-dev.0 → 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -22
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +168 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +42 -1
- package/build/CrustModule.d.ts +7 -0
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +8 -0
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +9 -0
- package/build/CrustModule.web.js.map +1 -1
- package/ios/CrustModule.swift +284 -47
- package/package.json +1 -1
- package/src/CrustModule.ts +8 -3
- package/src/CrustModule.web.ts +9 -0
package/README.md
CHANGED
|
@@ -1,35 +1,41 @@
|
|
|
1
|
-
# crust
|
|
1
|
+
# @mentra/crust
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The MentraOS native runtime layer: an [Expo module](https://docs.expo.dev/modules/overview/)
|
|
4
|
+
providing the native capabilities the Mentra Engine's miniapp runtime sits on —
|
|
5
|
+
per-miniapp JS contexts (QuickJS on Android, JavaScriptCore on iOS), the
|
|
6
|
+
native side of the MentraJS bridge, navigation, and device utilities.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
You don't call crust directly from app code: it's a **peer dependency of
|
|
9
|
+
[`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine)**. A host app
|
|
10
|
+
embedding the engine installs crust alongside it and Expo autolinking picks it
|
|
11
|
+
up.
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/crust/)
|
|
13
|
+
## Install
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
|
13
|
-
|
|
14
|
-
# Installation in bare React Native projects
|
|
15
|
-
|
|
16
|
-
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
|
17
|
-
|
|
18
|
-
### Add the package to your npm dependencies
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
npm install crust
|
|
15
|
+
```sh
|
|
16
|
+
npm install @mentra/crust@dev
|
|
22
17
|
```
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
> Currently published on the `dev` dist-tag (prerelease channel).
|
|
25
20
|
|
|
21
|
+
## Config plugin
|
|
26
22
|
|
|
23
|
+
The package ships an Expo config plugin (`app.plugin.js`) that carries its
|
|
24
|
+
Android build contract — Mapbox's maven repository, protobuf exclusions, and
|
|
25
|
+
core-library desugaring. Add it to the host app's Expo config:
|
|
27
26
|
|
|
27
|
+
```json
|
|
28
|
+
{"expo": {"plugins": ["@mentra/crust"]}}
|
|
29
|
+
```
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
Building with the navigation feature requires a `MAPBOX_DOWNLOADS_TOKEN` in
|
|
32
|
+
the Android build environment (Mapbox's SDK repository is authenticated).
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
At build time the Android side also reads the MentraJS polyfill bundle from
|
|
35
|
+
its [`@mentra/jspolyfill`](https://www.npmjs.com/package/@mentra/jspolyfill)
|
|
36
|
+
sibling, which is declared as a dependency.
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
## Part of MentraOS
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
|
|
41
|
+
under `mobile/modules/crust`. Issues and contributions welcome there.
|
|
@@ -129,6 +129,17 @@ class CrustModule : Module() {
|
|
|
129
129
|
sendEvent("onChange", mapOf("value" to value))
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
AsyncFunction("nativeHttpRequest") {
|
|
133
|
+
method: String, url: String, headers: Map<String, String>, body: String? ->
|
|
134
|
+
val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
|
|
135
|
+
mapOf(
|
|
136
|
+
"status" to result.status,
|
|
137
|
+
"statusText" to result.statusText,
|
|
138
|
+
"headers" to result.headers,
|
|
139
|
+
"body" to result.body,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
Function("showAVRoutePicker") { _: String? ->
|
|
133
144
|
// iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
|
|
134
145
|
}
|
|
@@ -575,6 +586,55 @@ class CrustModule : Module() {
|
|
|
575
586
|
}
|
|
576
587
|
}
|
|
577
588
|
|
|
589
|
+
val relativePath =
|
|
590
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
591
|
+
if (isVideo) "Movies/Mentra" else "Pictures/Mentra"
|
|
592
|
+
} else {
|
|
593
|
+
null
|
|
594
|
+
}
|
|
595
|
+
val resolver = context.contentResolver
|
|
596
|
+
val stableDisplayName =
|
|
597
|
+
mediaDisplayName.takeIf {
|
|
598
|
+
it.startsWith("IMG_") || it.startsWith("VID_")
|
|
599
|
+
}
|
|
600
|
+
val existingUri =
|
|
601
|
+
stableDisplayName?.let {
|
|
602
|
+
findExistingGalleryAsset(
|
|
603
|
+
resolver,
|
|
604
|
+
collection,
|
|
605
|
+
listOf(it),
|
|
606
|
+
file.length(),
|
|
607
|
+
captureTimeMillis,
|
|
608
|
+
relativePath,
|
|
609
|
+
context.packageName,
|
|
610
|
+
requireUniqueMatch = false,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
?: findExistingGalleryAsset(
|
|
614
|
+
resolver,
|
|
615
|
+
collection,
|
|
616
|
+
listOfNotNull(
|
|
617
|
+
mediaDisplayName.takeIf { stableDisplayName == null },
|
|
618
|
+
file.name.takeIf {
|
|
619
|
+
it.isNotBlank() && it != stableDisplayName
|
|
620
|
+
},
|
|
621
|
+
)
|
|
622
|
+
.distinct(),
|
|
623
|
+
file.length(),
|
|
624
|
+
captureTimeMillis,
|
|
625
|
+
relativePath,
|
|
626
|
+
context.packageName,
|
|
627
|
+
requireUniqueMatch = true,
|
|
628
|
+
)
|
|
629
|
+
if (existingUri != null) {
|
|
630
|
+
android.util.Log.d("CrustModule", "Reusing existing gallery asset")
|
|
631
|
+
return@AsyncFunction mapOf(
|
|
632
|
+
"success" to true,
|
|
633
|
+
"uri" to existingUri.toString(),
|
|
634
|
+
"existing" to true,
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
578
638
|
val values =
|
|
579
639
|
android.content.ContentValues().apply {
|
|
580
640
|
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
|
|
@@ -593,19 +653,12 @@ class CrustModule : Module() {
|
|
|
593
653
|
)
|
|
594
654
|
}
|
|
595
655
|
|
|
596
|
-
if (
|
|
597
|
-
val relativePath =
|
|
598
|
-
if (isVideo) {
|
|
599
|
-
"Movies/Mentra"
|
|
600
|
-
} else {
|
|
601
|
-
"Pictures/Mentra"
|
|
602
|
-
}
|
|
656
|
+
if (relativePath != null) {
|
|
603
657
|
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
|
604
658
|
put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
|
|
605
659
|
}
|
|
606
660
|
}
|
|
607
661
|
|
|
608
|
-
val resolver = context.contentResolver
|
|
609
662
|
val uri =
|
|
610
663
|
resolver.insert(collection, values)
|
|
611
664
|
?: throw IllegalStateException("Failed to create MediaStore entry")
|
|
@@ -879,4 +932,111 @@ class CrustModule : Module() {
|
|
|
879
932
|
mapOf("ok" to true)
|
|
880
933
|
}
|
|
881
934
|
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Find a completed export from a previous attempt. A crash can happen after MediaStore commits
|
|
938
|
+
* but before JavaScript persists the URI receipt; the stable capture display name, size, and
|
|
939
|
+
* capture time, and Mentra album path let the retry return that receipt instead of inserting a
|
|
940
|
+
* duplicate. Scoping by album is also important before deleting interrupted pending rows: a
|
|
941
|
+
* same-named asset owned by another album must never be treated as ours.
|
|
942
|
+
*/
|
|
943
|
+
private fun findExistingGalleryAsset(
|
|
944
|
+
resolver: android.content.ContentResolver,
|
|
945
|
+
collection: android.net.Uri,
|
|
946
|
+
displayNames: List<String>,
|
|
947
|
+
size: Long,
|
|
948
|
+
captureTimeMillis: Long?,
|
|
949
|
+
relativePath: String?,
|
|
950
|
+
ownerPackageName: String,
|
|
951
|
+
requireUniqueMatch: Boolean,
|
|
952
|
+
): android.net.Uri? {
|
|
953
|
+
val dateColumn = android.provider.MediaStore.Images.ImageColumns.DATE_TAKEN
|
|
954
|
+
if (displayNames.isEmpty()) return null
|
|
955
|
+
val namePlaceholders = displayNames.joinToString(",") { "?" }
|
|
956
|
+
val selectionParts =
|
|
957
|
+
mutableListOf(
|
|
958
|
+
"${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} IN ($namePlaceholders)",
|
|
959
|
+
)
|
|
960
|
+
val selectionArgs = displayNames.toMutableList()
|
|
961
|
+
if (captureTimeMillis != null) {
|
|
962
|
+
selectionParts.add("$dateColumn = ?")
|
|
963
|
+
selectionArgs.add(captureTimeMillis.toString())
|
|
964
|
+
}
|
|
965
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
|
|
966
|
+
relativePath != null
|
|
967
|
+
) {
|
|
968
|
+
val pathWithoutTrailingSlash = relativePath.trimEnd('/')
|
|
969
|
+
selectionParts.add(
|
|
970
|
+
"(${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ? OR " +
|
|
971
|
+
"${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ?)"
|
|
972
|
+
)
|
|
973
|
+
// MediaProvider normally canonicalizes RELATIVE_PATH with a trailing slash. Accept the
|
|
974
|
+
// caller's original representation too so exports created by older Android builds remain
|
|
975
|
+
// reconcilable, while still requiring an exact Mentra album match.
|
|
976
|
+
selectionArgs.add(pathWithoutTrailingSlash)
|
|
977
|
+
selectionArgs.add("$pathWithoutTrailingSlash/")
|
|
978
|
+
// Never reconcile or delete another application's pending MediaStore row.
|
|
979
|
+
selectionParts.add("${android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?")
|
|
980
|
+
selectionArgs.add(ownerPackageName)
|
|
981
|
+
}
|
|
982
|
+
val projection =
|
|
983
|
+
mutableListOf(
|
|
984
|
+
android.provider.BaseColumns._ID,
|
|
985
|
+
android.provider.MediaStore.MediaColumns.SIZE,
|
|
986
|
+
)
|
|
987
|
+
.apply {
|
|
988
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
989
|
+
add(android.provider.MediaStore.MediaColumns.IS_PENDING)
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return try {
|
|
994
|
+
resolver
|
|
995
|
+
.query(
|
|
996
|
+
collection,
|
|
997
|
+
projection.toTypedArray(),
|
|
998
|
+
selectionParts.joinToString(" AND "),
|
|
999
|
+
selectionArgs.toTypedArray(),
|
|
1000
|
+
"${android.provider.BaseColumns._ID} DESC",
|
|
1001
|
+
)
|
|
1002
|
+
?.use { cursor ->
|
|
1003
|
+
val candidates = mutableListOf<android.net.Uri>()
|
|
1004
|
+
val idColumn = cursor.getColumnIndexOrThrow(android.provider.BaseColumns._ID)
|
|
1005
|
+
val sizeColumn =
|
|
1006
|
+
cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns.SIZE)
|
|
1007
|
+
val pendingColumn =
|
|
1008
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
1009
|
+
cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.IS_PENDING)
|
|
1010
|
+
} else {
|
|
1011
|
+
-1
|
|
1012
|
+
}
|
|
1013
|
+
while (cursor.moveToNext()) {
|
|
1014
|
+
val uri =
|
|
1015
|
+
android.content.ContentUris.withAppendedId(
|
|
1016
|
+
collection,
|
|
1017
|
+
cursor.getLong(idColumn),
|
|
1018
|
+
)
|
|
1019
|
+
if (pendingColumn >= 0 && cursor.getInt(pendingColumn) != 0) {
|
|
1020
|
+
// This app owns the row and it was never published. Remove the interrupted
|
|
1021
|
+
// placeholder before retrying the copy, even if it contains only a prefix.
|
|
1022
|
+
resolver.delete(uri, null, null)
|
|
1023
|
+
continue
|
|
1024
|
+
}
|
|
1025
|
+
if (cursor.getLong(sizeColumn) == size) candidates.add(uri)
|
|
1026
|
+
}
|
|
1027
|
+
if (requireUniqueMatch) {
|
|
1028
|
+
// A legacy `base.jpg`/`base.mp4` match is safe only when the complete
|
|
1029
|
+
// name/date/size/path/owner fingerprint identifies exactly one asset.
|
|
1030
|
+
candidates.singleOrNull()
|
|
1031
|
+
} else {
|
|
1032
|
+
// Capture-derived display names are stable. If an older retry already made
|
|
1033
|
+
// duplicates, reuse the newest completed row instead of creating another.
|
|
1034
|
+
candidates.firstOrNull()
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} catch (error: Exception) {
|
|
1038
|
+
android.util.Log.w("CrustModule", "Unable to reconcile existing gallery asset", error)
|
|
1039
|
+
null
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
882
1042
|
}
|
|
@@ -45,6 +45,41 @@ object JSCPolyfillBridge {
|
|
|
45
45
|
.build()
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
data class HttpResult(
|
|
49
|
+
val status: Int,
|
|
50
|
+
val statusText: String,
|
|
51
|
+
val headers: Map<String, String>,
|
|
52
|
+
val body: String,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** Shared OkHttp execution path for host cloud-client requests. */
|
|
56
|
+
fun executeHttp(method: String, url: String, headers: Map<String, String>, bodyString: String?): HttpResult {
|
|
57
|
+
val builder = Request.Builder().url(url)
|
|
58
|
+
for ((name, value) in headers) builder.header(name, value)
|
|
59
|
+
val contentType = headers.entries
|
|
60
|
+
.firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
|
|
61
|
+
?.value
|
|
62
|
+
val upperMethod = method.uppercase()
|
|
63
|
+
val requestBody = when {
|
|
64
|
+
bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
|
|
65
|
+
upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
|
|
66
|
+
else -> null
|
|
67
|
+
}
|
|
68
|
+
builder.method(upperMethod, requestBody)
|
|
69
|
+
httpClient.newCall(builder.build()).execute().use { response ->
|
|
70
|
+
val responseHeaders = mutableMapOf<String, String>()
|
|
71
|
+
for (name in response.headers.names()) {
|
|
72
|
+
responseHeaders[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
73
|
+
}
|
|
74
|
+
return HttpResult(
|
|
75
|
+
status = response.code,
|
|
76
|
+
statusText = response.message,
|
|
77
|
+
headers = responseHeaders,
|
|
78
|
+
body = response.body?.string() ?: "",
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
/** Idempotent. Call once on host boot, after the dispatcher is created. */
|
|
49
84
|
fun install(runtime: JSCRuntime) {
|
|
50
85
|
installFetch(runtime)
|
|
@@ -187,8 +222,14 @@ object JSCPolyfillBridge {
|
|
|
187
222
|
|
|
188
223
|
val builder = Request.Builder().url(url)
|
|
189
224
|
for ((k, v) in headers) builder.header(k, v)
|
|
225
|
+
// HTTP header names are case-insensitive. The JS fetch caller will
|
|
226
|
+
// commonly provide `Content-Type`; a direct lowercase map lookup
|
|
227
|
+
// misses that value and causes OkHttp to emit application/octet-stream.
|
|
228
|
+
val contentType = headers.entries
|
|
229
|
+
.firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
|
|
230
|
+
?.value
|
|
190
231
|
val body = if (bodyString.isNullOrEmpty()) null else bodyString.toRequestBody(
|
|
191
|
-
(
|
|
232
|
+
(contentType ?: "application/octet-stream").toMediaTypeOrNull()
|
|
192
233
|
)
|
|
193
234
|
builder.method(method.uppercase(), body)
|
|
194
235
|
|
package/build/CrustModule.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
4
4
|
PI: number;
|
|
5
5
|
hello(): string;
|
|
6
6
|
setValueAsync(value: string): Promise<void>;
|
|
7
|
+
nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null): Promise<{
|
|
8
|
+
status: number;
|
|
9
|
+
statusText: string;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
body: string;
|
|
12
|
+
}>;
|
|
7
13
|
showAVRoutePicker(tintColor?: string | null): void;
|
|
8
14
|
/**
|
|
9
15
|
* iOS: configure `preferredScreenEdgesDeferringSystemGestures`. When an
|
|
@@ -51,6 +57,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
51
57
|
success: boolean;
|
|
52
58
|
uri?: string;
|
|
53
59
|
identifier?: string;
|
|
60
|
+
existing?: boolean;
|
|
54
61
|
error?: string;
|
|
55
62
|
}>;
|
|
56
63
|
startNavigation(lat: number, lng: number, options?: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CrustModule.d.ts","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,EAAC,iBAAiB,EAAE,YAAY,EAAC,MAAM,eAAe,CAAA;AAE7D,OAAO,OAAO,WAAY,SAAQ,YAAY,CAAC,iBAAiB,CAAC;IAC/D,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,IAAI,MAAM;IACf,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3C,iBAAiB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAElD;;;;;;;;OAQG;IACH,yBAAyB,
|
|
1
|
+
{"version":3,"file":"CrustModule.d.ts","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"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"}
|
package/build/CrustModule.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CrustModule.js","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"CrustModule.js","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AA6LtD,yDAAyD;AACzD,eAAe,mBAAmB,CAAc,OAAO,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport {CrustModuleEvents, InstalledApp} from \"./Crust.types\"\n\ndeclare class CrustModule extends NativeModule<CrustModuleEvents> {\n PI: number\n hello(): string\n setValueAsync(value: string): Promise<void>\n nativeHttpRequest(\n method: string,\n url: string,\n headers: Record<string, string>,\n body?: string | null,\n ): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>\n showAVRoutePicker(tintColor?: string | null): void\n\n /**\n * iOS: configure `preferredScreenEdgesDeferringSystemGestures`. When an\n * edge is deferred, the first swipe across that edge is consumed by the\n * app and the system gesture (Control Center, Notification Center, Home\n * indicator) only fires on a second swipe — i.e. a two-swipe-to-exit UX.\n *\n * Pass `[]` to restore default behavior. Android: no-op (Android has no\n * per-app equivalent; system gestures are configured at the OS level).\n */\n setDeferredSystemGestures(edges: Array<\"top\" | \"bottom\" | \"left\" | \"right\" | \"all\">): Promise<void>\n\n // MentraOS Notification Commands\n setNotificationConfig(enabled: boolean, blocklist: string[]): Promise<void>\n getInstalledApps(): Promise<InstalledApp[]>\n getInstalledAppsForNotifications(): Promise<InstalledApp[]>\n hasNotificationListenerPermission(): Promise<boolean>\n openNotificationListenerSettings(): Promise<boolean>\n isBetaBuild(): Promise<boolean>\n // location services commands\n showLocationServicesDialog(): Promise<boolean>\n isLocationServicesEnabled(): Promise<boolean>\n openLocationSettings(): Promise<boolean>\n openAppSettings(): Promise<boolean>\n openBluetoothSettings(): Promise<boolean>\n\n // Image Processing Commands\n processGalleryImage(\n inputPath: string,\n outputPath: string,\n options: {\n lensCorrection?: boolean\n colorCorrection?: boolean\n },\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n mergeHdrBrackets(\n underPath: string,\n normalPath: string,\n overPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n stabilizeVideo(\n inputPath: string,\n imuPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n // Media Library Commands\n saveToGalleryWithDate(\n filePath: string,\n captureTimeMillis?: number,\n displayName?: string,\n ): Promise<{\n success: boolean\n uri?: string\n identifier?: string\n existing?: boolean\n error?: string\n }>\n\n // Navigation (Android only — iOS stubs return error)\n startNavigation(\n lat: number,\n lng: number,\n options?: {\n simulate?: boolean\n speedMultiplier?: number\n /** Optional multi-stop list. When present takes precedence over lat/lng. Last entry is the final destination. */\n stops?: Array<{lat: number; lng: number}>\n /** \"walking\" | \"driving\" | \"cycling\" | \"two_wheeler\". Defaults driving. */\n mode?: string\n avoid?: {highways?: boolean; tolls?: boolean; ferries?: boolean}\n /** Force a reroute when the user is more than N meters past a pivot they didn't take. */\n missedTurnRerouteMeters?: number\n },\n ): Promise<{ok: boolean; error?: string}>\n stopNavigation(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Show the Google Nav SDK Terms & Conditions dialog if not already\n * accepted. Idempotent — resolves immediately with `{accepted: true}`\n * when the user has already accepted (cached in-process / on-disk /\n * inside the SDK).\n */\n requestNavigationPermission(): Promise<{ok: boolean; accepted: boolean; error?: string}>\n\n /**\n * Dev-only: clear cached T&C acceptance (SDK flag + on-disk pref +\n * in-process flag) so the next requestNavigationPermission() re-shows\n * the dialog. Android only; iOS returns {ok: false, error: \"not\n * supported on iOS\"}.\n */\n resetNavigationPermission(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev-only: nudge the simulated user position ~offsetMeters perpendicular\n * to the route so the Nav SDK reroutes. Default 20m. Android only.\n */\n simulateDeviation(offsetMeters?: number): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager shifts every\n * reported location ~8m perpendicular to the route bearing, simulating\n * a pedestrian walking on the wrong sidewalk. Only meaningful in\n * simulate mode; lets us verify the SDK's along-path pivot trigger\n * fires even when the user never crosses the 7m pivot point radius.\n * Android-only today; iOS is a no-op stub.\n */\n setWrongSidewalkOffset(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager takes over\n * from the Google simulator and walks the user along a modified\n * polyline that omits crossing micro-steps — reproducing the\n * wrong-sidewalk-then-missed-the-turn scenario. Android-only today;\n * iOS is a no-op stub.\n */\n setSkipCrossings(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n // Heading / compass (Android only)\n startHeading(): Promise<{ok: boolean; error?: string}>\n stopHeading(): Promise<{ok: boolean; error?: string}>\n\n // MentraJS Runtime — per-miniapp JSContext lifecycle.\n /**\n * Spawn a per-miniapp JS context. Re-spawn is allowed: a live context\n * with the same packageName is killed first. Returns true if the\n * polyfill bundle + miniapp source evaluated without throwing.\n *\n * The polyfill bundle is `mobile/modules/mentrajs-runtime/dist/startup.js`\n * (shipped inside the host binary). It installs window-style globals\n * (console, timers, fetch, localStorage, crypto) atop the JSC runtime.\n */\n mentraJsSpawn(packageName: string, polyfillBundle: string, miniappJs: string): Promise<boolean>\n mentraJsEvaluate(packageName: string, source: string): Promise<unknown>\n mentraJsKill(packageName: string): Promise<void>\n /**\n * Push a `{kind: \"event\"|\"response\", …}` envelope into the named\n * context's globalThis.__deliver. Returns when the underlying\n * evaluateScript completes (the JS handler runs synchronously on the\n * context's queue).\n */\n mentraJsDispatchToJs(packageName: string, envelope: Record<string, unknown>): Promise<void>\n mentraJsSetManifest(packageName: string, permissions: string[]): Promise<void>\n /** Diagnostic — returns the packageNames of every live JSContext. */\n mentraJsAlivePackages(): string[]\n /** Diagnostic — force a GC cycle on the named context. */\n mentraJsDebugForceGC(packageName: string): Promise<boolean>\n /**\n * Read the bundled MentraJS polyfill (startup.js) shipped inside the\n * host binary. Synchronous — host RN code calls this once on app boot\n * and caches the string, then passes it to every mentraJsSpawn so\n * every JSContext starts with the same polyfill ABI.\n */\n mentraJsLoadPolyfillBundle(): string\n}\n\n// This call loads the native module object from the JSI.\nexport default requireNativeModule<CrustModule>(\"Crust\")\n"]}
|
|
@@ -3,6 +3,14 @@ import { CrustModuleEvents } from "./Crust.types";
|
|
|
3
3
|
declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
4
4
|
PI: number;
|
|
5
5
|
setValueAsync(value: string): Promise<void>;
|
|
6
|
+
nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null): Promise<{
|
|
7
|
+
status: number;
|
|
8
|
+
statusText: string;
|
|
9
|
+
headers: {
|
|
10
|
+
[k: string]: string;
|
|
11
|
+
};
|
|
12
|
+
body: string;
|
|
13
|
+
}>;
|
|
6
14
|
hello(): string;
|
|
7
15
|
showAVRoutePicker(_tintColor?: string | null): void;
|
|
8
16
|
setDeferredSystemGestures(_edges: string[]): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CrustModule.web.d.ts","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAC,MAAM,MAAM,CAAA;AAEpD,OAAO,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAA;AAE/C,cAAM,WAAY,SAAQ,YAAY,CAAC,iBAAiB,CAAC;IACvD,EAAE,SAAU;IACN,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"CrustModule.web.d.ts","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAC,MAAM,MAAM,CAAA;AAEpD,OAAO,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAA;AAE/C,cAAM,WAAY,SAAQ,YAAY,CAAC,iBAAiB,CAAC;IACvD,EAAE,SAAU;IACN,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG3C,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;;;;;;;;IAS1G,KAAK;IAGL,iBAAiB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IACtC,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1D,qBAAqB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC7E,gBAAgB;IAGhB,gCAAgC;IAGhC,iCAAiC;IAGjC,gCAAgC;IAGhC,WAAW;IAGX,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;IAGjE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAG3C,YAAY,CAAC,IAAI,EAAE,MAAM;IAGzB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAGhE,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAGxD,qBAAqB;IAGf,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAGvC,0BAA0B;CAG3B;;AAED,wBAA4D"}
|
package/build/CrustModule.web.js
CHANGED
|
@@ -4,6 +4,15 @@ class CrustModule extends NativeModule {
|
|
|
4
4
|
async setValueAsync(value) {
|
|
5
5
|
this.emit("onChange", { value });
|
|
6
6
|
}
|
|
7
|
+
async nativeHttpRequest(method, url, headers, body) {
|
|
8
|
+
const response = await fetch(url, { method, headers, body });
|
|
9
|
+
return {
|
|
10
|
+
status: response.status,
|
|
11
|
+
statusText: response.statusText,
|
|
12
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
13
|
+
body: await response.text(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
7
16
|
hello() {
|
|
8
17
|
return "Hello world! 👋";
|
|
9
18
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CrustModule.web.js","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,YAAY,EAAC,MAAM,MAAM,CAAA;AAIpD,MAAM,WAAY,SAAQ,YAA+B;IACvD,EAAE,GAAG,IAAI,CAAC,EAAE,CAAA;IACZ,KAAK,CAAC,aAAa,CAAC,KAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAC,KAAK,EAAC,CAAC,CAAA;IAChC,CAAC;IACD,KAAK;QACH,OAAO,iBAAiB,CAAA;IAC1B,CAAC;IACD,iBAAiB,CAAC,UAA0B,IAAG,CAAC;IAChD,KAAK,CAAC,yBAAyB,CAAC,MAAgB,IAAkB,CAAC;IACnE,KAAK,CAAC,qBAAqB,CAAC,QAAiB,EAAE,UAAoB,IAAkB,CAAC;IACtF,KAAK,CAAC,gBAAgB;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,iCAAiC;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,WAAW;QACf,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,IAAY,EAAE,SAAiB,EAAE,UAAkB;QACrE,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gBAAgB,CAAC,IAAY,EAAE,IAAY;QAC/C,OAAO,IAAI,CAAA;IACb,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,IAAY;QAC7B,OAAM;IACR,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY,EAAE,IAA6B;QACpE,OAAM;IACR,CAAC;IACD,KAAK,CAAC,mBAAmB,CAAC,IAAY,EAAE,MAAgB;QACtD,OAAM;IACR,CAAC;IACD,qBAAqB;QACnB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,0BAA0B;QACxB,OAAO,EAAE,CAAA;IACX,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA","sourcesContent":["import {registerWebModule, NativeModule} from \"expo\"\n\nimport {CrustModuleEvents} from \"./Crust.types\"\n\nclass CrustModule extends NativeModule<CrustModuleEvents> {\n PI = Math.PI\n async setValueAsync(value: string): Promise<void> {\n this.emit(\"onChange\", {value})\n }\n hello() {\n return \"Hello world! 👋\"\n }\n showAVRoutePicker(_tintColor?: string | null) {}\n async setDeferredSystemGestures(_edges: string[]): Promise<void> {}\n async setNotificationConfig(_enabled: boolean, _blocklist: string[]): Promise<void> {}\n async getInstalledApps() {\n return []\n }\n async getInstalledAppsForNotifications() {\n return []\n }\n async hasNotificationListenerPermission() {\n return false\n }\n async openNotificationListenerSettings() {\n return false\n }\n async isBetaBuild() {\n return false\n }\n async mentraJsSpawn(_pkg: string, _polyfill: string, _miniappJs: string) {\n return false\n }\n async mentraJsEvaluate(_pkg: string, _src: string) {\n return null\n }\n async mentraJsKill(_pkg: string) {\n return\n }\n async mentraJsDispatchToJs(_pkg: string, _env: Record<string, unknown>) {\n return\n }\n async mentraJsSetManifest(_pkg: string, _perms: string[]) {\n return\n }\n mentraJsAlivePackages() {\n return []\n }\n async mentraJsDebugForceGC(_pkg: string) {\n return false\n }\n mentraJsLoadPolyfillBundle() {\n return \"\"\n }\n}\n\nexport default registerWebModule(CrustModule, \"CrustModule\")\n"]}
|
|
1
|
+
{"version":3,"file":"CrustModule.web.js","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,YAAY,EAAC,MAAM,MAAM,CAAA;AAIpD,MAAM,WAAY,SAAQ,YAA+B;IACvD,EAAE,GAAG,IAAI,CAAC,EAAE,CAAA;IACZ,KAAK,CAAC,aAAa,CAAC,KAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAC,KAAK,EAAC,CAAC,CAAA;IAChC,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,MAAc,EAAE,GAAW,EAAE,OAA+B,EAAE,IAAoB;QACxG,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAC,CAAC,CAAA;QAC1D,OAAO;YACL,MAAM,EAAE,QAAQ,CAAC,MAAM;YACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC/B,OAAO,EAAE,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;YACvD,IAAI,EAAE,MAAM,QAAQ,CAAC,IAAI,EAAE;SAC5B,CAAA;IACH,CAAC;IACD,KAAK;QACH,OAAO,iBAAiB,CAAA;IAC1B,CAAC;IACD,iBAAiB,CAAC,UAA0B,IAAG,CAAC;IAChD,KAAK,CAAC,yBAAyB,CAAC,MAAgB,IAAkB,CAAC;IACnE,KAAK,CAAC,qBAAqB,CAAC,QAAiB,EAAE,UAAoB,IAAkB,CAAC;IACtF,KAAK,CAAC,gBAAgB;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,iCAAiC;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,WAAW;QACf,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,IAAY,EAAE,SAAiB,EAAE,UAAkB;QACrE,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gBAAgB,CAAC,IAAY,EAAE,IAAY;QAC/C,OAAO,IAAI,CAAA;IACb,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,IAAY;QAC7B,OAAM;IACR,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY,EAAE,IAA6B;QACpE,OAAM;IACR,CAAC;IACD,KAAK,CAAC,mBAAmB,CAAC,IAAY,EAAE,MAAgB;QACtD,OAAM;IACR,CAAC;IACD,qBAAqB;QACnB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,0BAA0B;QACxB,OAAO,EAAE,CAAA;IACX,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA","sourcesContent":["import {registerWebModule, NativeModule} from \"expo\"\n\nimport {CrustModuleEvents} from \"./Crust.types\"\n\nclass CrustModule extends NativeModule<CrustModuleEvents> {\n PI = Math.PI\n async setValueAsync(value: string): Promise<void> {\n this.emit(\"onChange\", {value})\n }\n async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {\n const response = await fetch(url, {method, headers, body})\n return {\n status: response.status,\n statusText: response.statusText,\n headers: Object.fromEntries(response.headers.entries()),\n body: await response.text(),\n }\n }\n hello() {\n return \"Hello world! 👋\"\n }\n showAVRoutePicker(_tintColor?: string | null) {}\n async setDeferredSystemGestures(_edges: string[]): Promise<void> {}\n async setNotificationConfig(_enabled: boolean, _blocklist: string[]): Promise<void> {}\n async getInstalledApps() {\n return []\n }\n async getInstalledAppsForNotifications() {\n return []\n }\n async hasNotificationListenerPermission() {\n return false\n }\n async openNotificationListenerSettings() {\n return false\n }\n async isBetaBuild() {\n return false\n }\n async mentraJsSpawn(_pkg: string, _polyfill: string, _miniappJs: string) {\n return false\n }\n async mentraJsEvaluate(_pkg: string, _src: string) {\n return null\n }\n async mentraJsKill(_pkg: string) {\n return\n }\n async mentraJsDispatchToJs(_pkg: string, _env: Record<string, unknown>) {\n return\n }\n async mentraJsSetManifest(_pkg: string, _perms: string[]) {\n return\n }\n mentraJsAlivePackages() {\n return []\n }\n async mentraJsDebugForceGC(_pkg: string) {\n return false\n }\n mentraJsLoadPolyfillBundle() {\n return \"\"\n }\n}\n\nexport default registerWebModule(CrustModule, \"CrustModule\")\n"]}
|
package/ios/CrustModule.swift
CHANGED
|
@@ -8,6 +8,59 @@ private enum MentraSyncedMediaAlbum {
|
|
|
8
8
|
static let localizedTitle = "Mentra"
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/// Serializes callback and timeout delivery for PhotoKit APIs that may call back more than once.
|
|
12
|
+
private final class CheckedContinuationGate<Value>: @unchecked Sendable {
|
|
13
|
+
private let lock = NSLock()
|
|
14
|
+
private var continuation: CheckedContinuation<Value, Never>?
|
|
15
|
+
|
|
16
|
+
init(_ continuation: CheckedContinuation<Value, Never>) {
|
|
17
|
+
self.continuation = continuation
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func resume(returning value: Value) {
|
|
21
|
+
lock.lock()
|
|
22
|
+
guard let continuation else {
|
|
23
|
+
lock.unlock()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
self.continuation = nil
|
|
27
|
+
lock.unlock()
|
|
28
|
+
continuation.resume(returning: value)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private final class PhotoLibrarySaveState: @unchecked Sendable {
|
|
33
|
+
private let lock = NSLock()
|
|
34
|
+
private var assetIdentifier: String?
|
|
35
|
+
private var creationFailed = false
|
|
36
|
+
|
|
37
|
+
func setAssetIdentifier(_ identifier: String) {
|
|
38
|
+
lock.lock()
|
|
39
|
+
assetIdentifier = identifier
|
|
40
|
+
lock.unlock()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func markCreationFailed() {
|
|
44
|
+
lock.lock()
|
|
45
|
+
creationFailed = true
|
|
46
|
+
lock.unlock()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func snapshot() -> (assetIdentifier: String?, creationFailed: Bool) {
|
|
50
|
+
lock.lock()
|
|
51
|
+
defer { lock.unlock() }
|
|
52
|
+
return (assetIdentifier, creationFailed)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private struct PhotoLibrarySaveResult {
|
|
57
|
+
let succeeded: Bool
|
|
58
|
+
let assetIdentifier: String?
|
|
59
|
+
let creationFailed: Bool
|
|
60
|
+
let errorMessage: String?
|
|
61
|
+
let timedOut: Bool
|
|
62
|
+
}
|
|
63
|
+
|
|
11
64
|
public class CrustModule: Module {
|
|
12
65
|
public func definition() -> ModuleDefinition {
|
|
13
66
|
Name("Crust")
|
|
@@ -411,66 +464,135 @@ public class CrustModule: Module {
|
|
|
411
464
|
// MARK: - Media Library Commands
|
|
412
465
|
|
|
413
466
|
AsyncFunction("saveToGalleryWithDate") {
|
|
414
|
-
(
|
|
467
|
+
(
|
|
468
|
+
filePath: String,
|
|
469
|
+
captureTimeMillis: Int64?,
|
|
470
|
+
displayName: String?
|
|
471
|
+
) -> [String: Any] in
|
|
415
472
|
let fileURL = URL(fileURLWithPath: filePath)
|
|
416
473
|
|
|
417
474
|
guard FileManager.default.fileExists(atPath: filePath) else {
|
|
418
475
|
return ["success": false, "error": "File does not exist"]
|
|
419
476
|
}
|
|
420
477
|
|
|
421
|
-
|
|
422
|
-
let
|
|
423
|
-
|
|
424
|
-
|
|
478
|
+
let pathExtension = fileURL.pathExtension.lowercased()
|
|
479
|
+
let isVideo = ["mp4", "mov", "avi", "m4v"].contains(pathExtension)
|
|
480
|
+
let assetFileName = displayName?.isEmpty == false
|
|
481
|
+
? displayName!
|
|
482
|
+
: fileURL.lastPathComponent
|
|
483
|
+
let captureDate = captureTimeMillis.map {
|
|
484
|
+
Date(timeIntervalSince1970: TimeInterval($0) / 1000.0)
|
|
485
|
+
}
|
|
486
|
+
let candidateFileNames = [assetFileName, fileURL.lastPathComponent]
|
|
487
|
+
.filter { !$0.isEmpty }
|
|
488
|
+
let stableFileName = assetFileName.hasPrefix("IMG_") || assetFileName.hasPrefix("VID_")
|
|
489
|
+
? assetFileName
|
|
490
|
+
: nil
|
|
491
|
+
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath)
|
|
492
|
+
let expectedFileSize = (fileAttributes?[.size] as? NSNumber)?.int64Value
|
|
493
|
+
let authorizationStatus: PHAuthorizationStatus
|
|
494
|
+
if #available(iOS 14, *) {
|
|
495
|
+
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
496
|
+
} else {
|
|
497
|
+
authorizationStatus = PHPhotoLibrary.authorizationStatus()
|
|
498
|
+
}
|
|
499
|
+
let hasLimitedAccess: Bool
|
|
500
|
+
if #available(iOS 14, *) {
|
|
501
|
+
hasLimitedAccess = authorizationStatus == .limited
|
|
502
|
+
} else {
|
|
503
|
+
hasLimitedAccess = false
|
|
504
|
+
}
|
|
425
505
|
|
|
426
|
-
|
|
427
|
-
|
|
506
|
+
// The JS ledger normally supplies the previous PhotoKit receipt. If the app was
|
|
507
|
+
// terminated after Photos committed but before that receipt was persisted, reconcile
|
|
508
|
+
// by our stable capture filename/date before creating another asset.
|
|
509
|
+
if let existingIdentifier = await self.findExistingGalleryAssetIdentifier(
|
|
510
|
+
fileNames: candidateFileNames,
|
|
511
|
+
stableFileName: stableFileName,
|
|
512
|
+
expectedFileSize: expectedFileSize,
|
|
513
|
+
captureDate: captureDate,
|
|
514
|
+
isVideo: isVideo
|
|
515
|
+
) {
|
|
516
|
+
NSLog("CrustModule: Reusing existing gallery asset")
|
|
517
|
+
return [
|
|
518
|
+
"success": true,
|
|
519
|
+
"identifier": existingIdentifier,
|
|
520
|
+
"existing": true,
|
|
521
|
+
]
|
|
522
|
+
}
|
|
428
523
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
524
|
+
let saveResult = await self.createGalleryAsset(
|
|
525
|
+
fileURL: fileURL,
|
|
526
|
+
assetFileName: assetFileName,
|
|
527
|
+
captureDate: captureDate,
|
|
528
|
+
isVideo: isVideo,
|
|
529
|
+
hasLimitedAccess: hasLimitedAccess
|
|
530
|
+
)
|
|
531
|
+
if saveResult.timedOut {
|
|
532
|
+
return [
|
|
533
|
+
"success": false,
|
|
534
|
+
"error": "Photo library save timed out; retry will reconcile the result",
|
|
535
|
+
]
|
|
536
|
+
}
|
|
537
|
+
if saveResult.creationFailed {
|
|
538
|
+
return ["success": false, "error": "Failed to create PhotoKit asset placeholder"]
|
|
539
|
+
}
|
|
540
|
+
if let errorMessage = saveResult.errorMessage {
|
|
541
|
+
NSLog("CrustModule: Error saving to gallery: \(errorMessage)")
|
|
542
|
+
return ["success": false, "error": errorMessage]
|
|
543
|
+
}
|
|
544
|
+
guard saveResult.succeeded else {
|
|
545
|
+
return ["success": false, "error": "Photo library did not commit the asset"]
|
|
546
|
+
}
|
|
451
547
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
548
|
+
NSLog("CrustModule: Successfully saved to gallery with proper creation date")
|
|
549
|
+
return ["success": true, "identifier": saveResult.assetIdentifier ?? ""]
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private func createGalleryAsset(
|
|
554
|
+
fileURL: URL,
|
|
555
|
+
assetFileName: String,
|
|
556
|
+
captureDate: Date?,
|
|
557
|
+
isVideo: Bool,
|
|
558
|
+
hasLimitedAccess: Bool
|
|
559
|
+
) async -> PhotoLibrarySaveResult {
|
|
560
|
+
await withCheckedContinuation { continuation in
|
|
561
|
+
let gate = CheckedContinuationGate(continuation)
|
|
562
|
+
let state = PhotoLibrarySaveState()
|
|
563
|
+
|
|
564
|
+
PHPhotoLibrary.shared().performChanges {
|
|
565
|
+
let creationRequest = PHAssetCreationRequest.forAsset()
|
|
566
|
+
let resourceOptions = PHAssetResourceCreationOptions()
|
|
567
|
+
resourceOptions.originalFilename = assetFileName
|
|
568
|
+
creationRequest.addResource(
|
|
569
|
+
with: isVideo ? .video : .photo,
|
|
570
|
+
fileURL: fileURL,
|
|
571
|
+
options: resourceOptions
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if let captureDate {
|
|
456
575
|
creationRequest.creationDate = captureDate
|
|
457
576
|
NSLog("CrustModule: Setting creation date to: \(captureDate)")
|
|
458
577
|
}
|
|
459
578
|
|
|
460
579
|
guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
|
|
461
580
|
NSLog("CrustModule: Missing placeholder for created asset")
|
|
462
|
-
|
|
581
|
+
state.markCreationFailed()
|
|
463
582
|
return
|
|
464
583
|
}
|
|
584
|
+
state.setAssetIdentifier(assetPlaceholder.localIdentifier)
|
|
465
585
|
|
|
466
|
-
|
|
586
|
+
// Limited-library access permits creating an asset, but does not reliably
|
|
587
|
+
// permit enumerating or mutating arbitrary user albums. Save to the camera
|
|
588
|
+
// roll and skip Mentra album mutation in that mode.
|
|
589
|
+
guard !hasLimitedAccess else { return }
|
|
467
590
|
|
|
468
591
|
let albumFetch = PHFetchOptions()
|
|
469
592
|
albumFetch.predicate = NSPredicate(
|
|
470
593
|
format: "localizedTitle == %@", MentraSyncedMediaAlbum.localizedTitle
|
|
471
594
|
)
|
|
472
595
|
albumFetch.fetchLimit = 1
|
|
473
|
-
|
|
474
596
|
let existingAlbums = PHAssetCollection.fetchAssetCollections(
|
|
475
597
|
with: .album,
|
|
476
598
|
subtype: .albumRegular,
|
|
@@ -491,26 +613,141 @@ public class CrustModule: Module {
|
|
|
491
613
|
"CrustModule: Mentra album exists but is not writable; asset saved to library only"
|
|
492
614
|
)
|
|
493
615
|
}
|
|
494
|
-
} completionHandler: {
|
|
495
|
-
|
|
496
|
-
|
|
616
|
+
} completionHandler: { succeeded, error in
|
|
617
|
+
let snapshot = state.snapshot()
|
|
618
|
+
gate.resume(returning: PhotoLibrarySaveResult(
|
|
619
|
+
succeeded: succeeded,
|
|
620
|
+
assetIdentifier: snapshot.assetIdentifier,
|
|
621
|
+
creationFailed: snapshot.creationFailed,
|
|
622
|
+
errorMessage: error?.localizedDescription,
|
|
623
|
+
timedOut: false
|
|
624
|
+
))
|
|
497
625
|
}
|
|
498
626
|
|
|
499
|
-
|
|
627
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 120) {
|
|
628
|
+
let snapshot = state.snapshot()
|
|
629
|
+
gate.resume(returning: PhotoLibrarySaveResult(
|
|
630
|
+
succeeded: false,
|
|
631
|
+
assetIdentifier: snapshot.assetIdentifier,
|
|
632
|
+
creationFailed: snapshot.creationFailed,
|
|
633
|
+
errorMessage: nil,
|
|
634
|
+
timedOut: true
|
|
635
|
+
))
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
500
639
|
|
|
501
|
-
|
|
502
|
-
|
|
640
|
+
private func findExistingGalleryAssetIdentifier(
|
|
641
|
+
fileNames: [String],
|
|
642
|
+
stableFileName: String?,
|
|
643
|
+
expectedFileSize: Int64?,
|
|
644
|
+
captureDate: Date?,
|
|
645
|
+
isVideo: Bool
|
|
646
|
+
) async -> String? {
|
|
647
|
+
guard !fileNames.isEmpty else { return nil }
|
|
648
|
+
let options = PHFetchOptions()
|
|
649
|
+
let mediaType = isVideo ? PHAssetMediaType.video : PHAssetMediaType.image
|
|
650
|
+
if let captureDate {
|
|
651
|
+
options.predicate = NSPredicate(
|
|
652
|
+
format: "mediaType == %d AND creationDate >= %@ AND creationDate <= %@",
|
|
653
|
+
mediaType.rawValue,
|
|
654
|
+
captureDate.addingTimeInterval(-1) as NSDate,
|
|
655
|
+
captureDate.addingTimeInterval(1) as NSDate
|
|
656
|
+
)
|
|
657
|
+
} else {
|
|
658
|
+
options.predicate = NSPredicate(format: "mediaType == %d", mediaType.rawValue)
|
|
659
|
+
options.fetchLimit = 200
|
|
660
|
+
}
|
|
661
|
+
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
|
662
|
+
|
|
663
|
+
let assets = PHAsset.fetchAssets(with: options)
|
|
664
|
+
var stableCandidates: [PHAsset] = []
|
|
665
|
+
var legacyCandidates: [PHAsset] = []
|
|
666
|
+
assets.enumerateObjects { asset, _, _ in
|
|
667
|
+
let resources = PHAssetResource.assetResources(for: asset)
|
|
668
|
+
let stableMatch = stableFileName.map { stableName in
|
|
669
|
+
resources.contains { $0.originalFilename == stableName }
|
|
670
|
+
} ?? false
|
|
671
|
+
if stableMatch {
|
|
672
|
+
stableCandidates.append(asset)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
let legacyNameMatch = resources.contains {
|
|
676
|
+
guard fileNames.contains($0.originalFilename), $0.originalFilename != stableFileName else {
|
|
677
|
+
return false
|
|
678
|
+
}
|
|
679
|
+
return true
|
|
680
|
+
}
|
|
681
|
+
if legacyNameMatch { legacyCandidates.append(asset) }
|
|
682
|
+
}
|
|
683
|
+
guard let expectedFileSize else { return nil }
|
|
684
|
+
// PhotoKit's original-resource lookups are asynchronous. Resolve candidates after
|
|
685
|
+
// enumeration so the fetch callback never blocks the Photos framework. Stable names
|
|
686
|
+
// may repeat across a restored/older library; require the generation's exact byte size
|
|
687
|
+
// and reuse the newest matching completed asset.
|
|
688
|
+
for asset in stableCandidates {
|
|
689
|
+
if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
|
|
690
|
+
return asset.localIdentifier
|
|
503
691
|
}
|
|
692
|
+
}
|
|
504
693
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
694
|
+
var legacyIdentifiers: [String] = []
|
|
695
|
+
for asset in legacyCandidates {
|
|
696
|
+
if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
|
|
697
|
+
legacyIdentifiers.append(asset.localIdentifier)
|
|
508
698
|
}
|
|
699
|
+
}
|
|
700
|
+
// Legacy base names remain safe only when name + capture time + media type + exact
|
|
701
|
+
// original byte size identify one asset.
|
|
702
|
+
return legacyIdentifiers.count == 1 ? legacyIdentifiers[0] : nil
|
|
703
|
+
}
|
|
509
704
|
|
|
510
|
-
|
|
511
|
-
|
|
705
|
+
/// PhotoKit does not expose resource byte size on the deployment targets we support.
|
|
706
|
+
/// Inspect a video's local original URL or an image's original bytes asynchronously.
|
|
707
|
+
/// Network access stays disabled: if the original is only in iCloud, reconciliation fails
|
|
708
|
+
/// closed and the caller creates a fresh local-library asset instead of risking data loss.
|
|
709
|
+
private func assetOriginalFile(
|
|
710
|
+
_ asset: PHAsset,
|
|
711
|
+
matchesByteCount expectedByteCount: Int64
|
|
712
|
+
) async -> Bool {
|
|
713
|
+
let manager = PHImageManager.default()
|
|
714
|
+
if asset.mediaType == .video {
|
|
715
|
+
let options = PHVideoRequestOptions()
|
|
716
|
+
options.isNetworkAccessAllowed = false
|
|
717
|
+
options.version = .original
|
|
718
|
+
return await withCheckedContinuation { continuation in
|
|
719
|
+
let gate = CheckedContinuationGate(continuation)
|
|
720
|
+
let requestID = manager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in
|
|
721
|
+
guard let originalURL = (avAsset as? AVURLAsset)?.url,
|
|
722
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: originalURL.path),
|
|
723
|
+
let byteCount = attributes[.size] as? NSNumber else {
|
|
724
|
+
gate.resume(returning: false)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
gate.resume(returning: byteCount.int64Value == expectedByteCount)
|
|
728
|
+
}
|
|
729
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
|
|
730
|
+
manager.cancelImageRequest(requestID)
|
|
731
|
+
gate.resume(returning: false)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
512
734
|
}
|
|
513
735
|
|
|
736
|
+
let options = PHImageRequestOptions()
|
|
737
|
+
options.isNetworkAccessAllowed = false
|
|
738
|
+
options.version = .original
|
|
739
|
+
options.deliveryMode = .highQualityFormat
|
|
740
|
+
return await withCheckedContinuation { continuation in
|
|
741
|
+
let gate = CheckedContinuationGate(continuation)
|
|
742
|
+
let requestID = manager.requestImageDataAndOrientation(for: asset, options: options) { data, _, _, info in
|
|
743
|
+
if info?[PHImageResultIsDegradedKey] as? Bool == true { return }
|
|
744
|
+
gate.resume(returning: data.map { Int64($0.count) == expectedByteCount } ?? false)
|
|
745
|
+
}
|
|
746
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
|
|
747
|
+
manager.cancelImageRequest(requestID)
|
|
748
|
+
gate.resume(returning: false)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
514
751
|
}
|
|
515
752
|
}
|
|
516
753
|
|
package/package.json
CHANGED
package/src/CrustModule.ts
CHANGED
|
@@ -6,6 +6,12 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
6
6
|
PI: number
|
|
7
7
|
hello(): string
|
|
8
8
|
setValueAsync(value: string): Promise<void>
|
|
9
|
+
nativeHttpRequest(
|
|
10
|
+
method: string,
|
|
11
|
+
url: string,
|
|
12
|
+
headers: Record<string, string>,
|
|
13
|
+
body?: string | null,
|
|
14
|
+
): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>
|
|
9
15
|
showAVRoutePicker(tintColor?: string | null): void
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -17,9 +23,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
17
23
|
* Pass `[]` to restore default behavior. Android: no-op (Android has no
|
|
18
24
|
* per-app equivalent; system gestures are configured at the OS level).
|
|
19
25
|
*/
|
|
20
|
-
setDeferredSystemGestures(
|
|
21
|
-
edges: Array<"top" | "bottom" | "left" | "right" | "all">,
|
|
22
|
-
): Promise<void>
|
|
26
|
+
setDeferredSystemGestures(edges: Array<"top" | "bottom" | "left" | "right" | "all">): Promise<void>
|
|
23
27
|
|
|
24
28
|
// MentraOS Notification Commands
|
|
25
29
|
setNotificationConfig(enabled: boolean, blocklist: string[]): Promise<void>
|
|
@@ -82,6 +86,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
82
86
|
success: boolean
|
|
83
87
|
uri?: string
|
|
84
88
|
identifier?: string
|
|
89
|
+
existing?: boolean
|
|
85
90
|
error?: string
|
|
86
91
|
}>
|
|
87
92
|
|
package/src/CrustModule.web.ts
CHANGED
|
@@ -7,6 +7,15 @@ class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
|
7
7
|
async setValueAsync(value: string): Promise<void> {
|
|
8
8
|
this.emit("onChange", {value})
|
|
9
9
|
}
|
|
10
|
+
async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {
|
|
11
|
+
const response = await fetch(url, {method, headers, body})
|
|
12
|
+
return {
|
|
13
|
+
status: response.status,
|
|
14
|
+
statusText: response.statusText,
|
|
15
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
16
|
+
body: await response.text(),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
10
19
|
hello() {
|
|
11
20
|
return "Hello world! 👋"
|
|
12
21
|
}
|