@mentra/crust 0.1.0-dev.0 → 3.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/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +237 -33
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +42 -1
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +2 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +273 -90
- package/android/src/main/java/com/mentra/crust/services/NotificationProcessBridge.kt +222 -0
- package/build/Crust.types.d.ts +6 -1
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +9 -1
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +10 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +13 -1
- package/build/CrustModule.web.js.map +1 -1
- package/ios/CrustModule.swift +288 -47
- package/package.json +7 -3
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +56 -1
- package/src/Crust.types.ts +6 -1
- package/src/CrustModule.ts +10 -4
- package/src/CrustModule.web.ts +13 -1
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.
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
<permission
|
|
3
|
+
android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE"
|
|
4
|
+
android:protectionLevel="signature" />
|
|
5
|
+
<uses-permission android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE" />
|
|
6
|
+
|
|
2
7
|
<application>
|
|
3
8
|
<service
|
|
4
9
|
android:name="com.mentra.crust.services.NotificationListenerServiceImpl"
|
|
5
10
|
android:label="@string/mentra_crust_notification_listener_label"
|
|
11
|
+
android:enabled="false"
|
|
6
12
|
android:exported="false"
|
|
13
|
+
android:process=":notif"
|
|
7
14
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
|
8
15
|
<intent-filter>
|
|
9
16
|
<action android:name="android.service.notification.NotificationListenerService" />
|
|
@@ -12,6 +19,13 @@
|
|
|
12
19
|
<meta-data android:name="android.service.notification.disabled_filter_types" android:value="ongoing|silent" />
|
|
13
20
|
</service>
|
|
14
21
|
|
|
22
|
+
<receiver
|
|
23
|
+
android:name="com.mentra.crust.services.NotificationConfigReceiver"
|
|
24
|
+
android:enabled="true"
|
|
25
|
+
android:exported="false"
|
|
26
|
+
android:permission="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE"
|
|
27
|
+
android:process=":notif" />
|
|
28
|
+
|
|
15
29
|
<!-- The Mapbox Navigation SDK delivers turn-by-turn steps inline via
|
|
16
30
|
RouteProgress (no Messenger-IPC service), so the Google-era
|
|
17
31
|
NavInfoReceiverService is gone. Nothing to register here for nav. -->
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package com.mentra.crust
|
|
2
2
|
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
3
4
|
import android.util.Log
|
|
4
5
|
import com.mentra.crust.services.NotificationListener
|
|
6
|
+
import com.mentra.crust.services.NotificationProcessBridge
|
|
5
7
|
import expo.modules.kotlin.modules.Module
|
|
6
8
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
7
9
|
import java.net.URL
|
|
@@ -19,34 +21,33 @@ class CrustModule : Module() {
|
|
|
19
21
|
@Volatile private var eventEmitter: ((String, Map<String, Any>) -> Unit)? = null
|
|
20
22
|
|
|
21
23
|
fun emitPhoneNotification(
|
|
24
|
+
context: android.content.Context,
|
|
22
25
|
notificationKey: String,
|
|
23
26
|
packageName: String,
|
|
24
27
|
appName: String,
|
|
25
28
|
title: String,
|
|
26
29
|
text: String,
|
|
27
30
|
timestamp: Long,
|
|
31
|
+
priority: Int,
|
|
28
32
|
) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
emitEvent("phone_notification", data)
|
|
33
|
+
NotificationProcessBridge.emitPosted(
|
|
34
|
+
context,
|
|
35
|
+
notificationKey,
|
|
36
|
+
packageName,
|
|
37
|
+
appName,
|
|
38
|
+
title,
|
|
39
|
+
text,
|
|
40
|
+
timestamp,
|
|
41
|
+
priority,
|
|
42
|
+
)
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
fun emitPhoneNotificationDismissed(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
emitEvent("phone_notification_dismissed", data)
|
|
45
|
+
fun emitPhoneNotificationDismissed(
|
|
46
|
+
context: android.content.Context,
|
|
47
|
+
notificationKey: String,
|
|
48
|
+
packageName: String,
|
|
49
|
+
) {
|
|
50
|
+
NotificationProcessBridge.emitDismissed(context, notificationKey, packageName)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
fun emitCaptionsTesterIncident(data: Map<String, Any>) {
|
|
@@ -75,6 +76,23 @@ class CrustModule : Module() {
|
|
|
75
76
|
// __dispatch from a per-miniapp QuickJS context (SUBSCRIBE, mic, location,
|
|
76
77
|
// display, send, etc.) would be silently dropped on Android.
|
|
77
78
|
@Volatile private var runtimeInstalled: Boolean = false
|
|
79
|
+
private var notificationEventReceiver: BroadcastReceiver? = null
|
|
80
|
+
private var notificationBridgeContext: android.content.Context? = null
|
|
81
|
+
|
|
82
|
+
private fun registerNotificationBridgeIfPossible(): Boolean {
|
|
83
|
+
if (notificationEventReceiver != null) return true
|
|
84
|
+
val context = appContext.reactContext ?: appContext.currentActivity ?: return false
|
|
85
|
+
val applicationContext = context.applicationContext
|
|
86
|
+
notificationEventReceiver =
|
|
87
|
+
NotificationProcessBridge.register(applicationContext) { eventName, data ->
|
|
88
|
+
emitEvent(eventName, data)
|
|
89
|
+
}
|
|
90
|
+
// Keep the exact long-lived context used to register the receiver. Expo may
|
|
91
|
+
// clear reactContext/currentActivity before OnDestroy, but Android still
|
|
92
|
+
// requires this receiver to be unregistered when the module is recreated.
|
|
93
|
+
notificationBridgeContext = applicationContext
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
78
96
|
|
|
79
97
|
private fun installRuntimeIfPossible(reason: String): Boolean {
|
|
80
98
|
if (runtimeInstalled) return true
|
|
@@ -118,9 +136,21 @@ class CrustModule : Module() {
|
|
|
118
136
|
|
|
119
137
|
OnCreate {
|
|
120
138
|
eventEmitter = { eventName, data -> sendEvent(eventName, data) }
|
|
139
|
+
registerNotificationBridgeIfPossible()
|
|
121
140
|
installRuntimeIfPossible("OnCreate")
|
|
122
141
|
}
|
|
123
142
|
|
|
143
|
+
OnDestroy {
|
|
144
|
+
val context = notificationBridgeContext
|
|
145
|
+
val receiver = notificationEventReceiver
|
|
146
|
+
if (context != null && receiver != null) {
|
|
147
|
+
NotificationProcessBridge.unregister(context, receiver)
|
|
148
|
+
}
|
|
149
|
+
notificationEventReceiver = null
|
|
150
|
+
notificationBridgeContext = null
|
|
151
|
+
eventEmitter = null
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
Function("hello") {
|
|
125
155
|
"Hello world! 👋"
|
|
126
156
|
}
|
|
@@ -129,6 +159,17 @@ class CrustModule : Module() {
|
|
|
129
159
|
sendEvent("onChange", mapOf("value" to value))
|
|
130
160
|
}
|
|
131
161
|
|
|
162
|
+
AsyncFunction("nativeHttpRequest") {
|
|
163
|
+
method: String, url: String, headers: Map<String, String>, body: String? ->
|
|
164
|
+
val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
|
|
165
|
+
mapOf(
|
|
166
|
+
"status" to result.status,
|
|
167
|
+
"statusText" to result.statusText,
|
|
168
|
+
"headers" to result.headers,
|
|
169
|
+
"body" to result.body,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
132
173
|
Function("showAVRoutePicker") { _: String? ->
|
|
133
174
|
// iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
|
|
134
175
|
}
|
|
@@ -141,12 +182,18 @@ class CrustModule : Module() {
|
|
|
141
182
|
|
|
142
183
|
// MARK: - MentraOS Notification Commands
|
|
143
184
|
|
|
144
|
-
AsyncFunction("setNotificationConfig") {
|
|
185
|
+
AsyncFunction("setNotificationConfig") {
|
|
186
|
+
listenerEnabled: Boolean, blocklist: List<String> ->
|
|
187
|
+
registerNotificationBridgeIfPossible()
|
|
145
188
|
val context =
|
|
146
189
|
appContext.reactContext
|
|
147
190
|
?: appContext.currentActivity
|
|
148
191
|
?: throw IllegalStateException("No context available")
|
|
149
|
-
NotificationListener.
|
|
192
|
+
NotificationListener.setNotificationConfig(
|
|
193
|
+
context,
|
|
194
|
+
listenerEnabled,
|
|
195
|
+
blocklist,
|
|
196
|
+
)
|
|
150
197
|
}
|
|
151
198
|
|
|
152
199
|
AsyncFunction("getInstalledApps") {
|
|
@@ -154,7 +201,7 @@ class CrustModule : Module() {
|
|
|
154
201
|
appContext.reactContext
|
|
155
202
|
?: appContext.currentActivity
|
|
156
203
|
?: throw IllegalStateException("No context available")
|
|
157
|
-
NotificationListener.
|
|
204
|
+
NotificationListener.getInstalledApps(context)
|
|
158
205
|
}
|
|
159
206
|
|
|
160
207
|
AsyncFunction("getInstalledAppsForNotifications") {
|
|
@@ -162,7 +209,7 @@ class CrustModule : Module() {
|
|
|
162
209
|
appContext.reactContext
|
|
163
210
|
?: appContext.currentActivity
|
|
164
211
|
?: throw IllegalStateException("No context available")
|
|
165
|
-
NotificationListener.
|
|
212
|
+
NotificationListener.getInstalledApps(context)
|
|
166
213
|
}
|
|
167
214
|
|
|
168
215
|
AsyncFunction("hasNotificationListenerPermission") {
|
|
@@ -170,7 +217,15 @@ class CrustModule : Module() {
|
|
|
170
217
|
appContext.reactContext
|
|
171
218
|
?: appContext.currentActivity
|
|
172
219
|
?: throw IllegalStateException("No context available")
|
|
173
|
-
NotificationListener.
|
|
220
|
+
NotificationListener.hasNotificationListenerPermission(context)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
AsyncFunction("refreshNotificationListener") {
|
|
224
|
+
val context =
|
|
225
|
+
appContext.reactContext
|
|
226
|
+
?: appContext.currentActivity
|
|
227
|
+
?: throw IllegalStateException("No context available")
|
|
228
|
+
NotificationListener.refreshComponentForPermission(context)
|
|
174
229
|
}
|
|
175
230
|
|
|
176
231
|
AsyncFunction("openNotificationListenerSettings") {
|
|
@@ -178,7 +233,7 @@ class CrustModule : Module() {
|
|
|
178
233
|
appContext.reactContext
|
|
179
234
|
?: appContext.currentActivity
|
|
180
235
|
?: throw IllegalStateException("No context available")
|
|
181
|
-
NotificationListener.
|
|
236
|
+
NotificationListener.openNotificationListenerSettings(context)
|
|
182
237
|
true
|
|
183
238
|
}
|
|
184
239
|
|
|
@@ -575,6 +630,55 @@ class CrustModule : Module() {
|
|
|
575
630
|
}
|
|
576
631
|
}
|
|
577
632
|
|
|
633
|
+
val relativePath =
|
|
634
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
635
|
+
if (isVideo) "Movies/Mentra" else "Pictures/Mentra"
|
|
636
|
+
} else {
|
|
637
|
+
null
|
|
638
|
+
}
|
|
639
|
+
val resolver = context.contentResolver
|
|
640
|
+
val stableDisplayName =
|
|
641
|
+
mediaDisplayName.takeIf {
|
|
642
|
+
it.startsWith("IMG_") || it.startsWith("VID_")
|
|
643
|
+
}
|
|
644
|
+
val existingUri =
|
|
645
|
+
stableDisplayName?.let {
|
|
646
|
+
findExistingGalleryAsset(
|
|
647
|
+
resolver,
|
|
648
|
+
collection,
|
|
649
|
+
listOf(it),
|
|
650
|
+
file.length(),
|
|
651
|
+
captureTimeMillis,
|
|
652
|
+
relativePath,
|
|
653
|
+
context.packageName,
|
|
654
|
+
requireUniqueMatch = false,
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
?: findExistingGalleryAsset(
|
|
658
|
+
resolver,
|
|
659
|
+
collection,
|
|
660
|
+
listOfNotNull(
|
|
661
|
+
mediaDisplayName.takeIf { stableDisplayName == null },
|
|
662
|
+
file.name.takeIf {
|
|
663
|
+
it.isNotBlank() && it != stableDisplayName
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
.distinct(),
|
|
667
|
+
file.length(),
|
|
668
|
+
captureTimeMillis,
|
|
669
|
+
relativePath,
|
|
670
|
+
context.packageName,
|
|
671
|
+
requireUniqueMatch = true,
|
|
672
|
+
)
|
|
673
|
+
if (existingUri != null) {
|
|
674
|
+
android.util.Log.d("CrustModule", "Reusing existing gallery asset")
|
|
675
|
+
return@AsyncFunction mapOf(
|
|
676
|
+
"success" to true,
|
|
677
|
+
"uri" to existingUri.toString(),
|
|
678
|
+
"existing" to true,
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
578
682
|
val values =
|
|
579
683
|
android.content.ContentValues().apply {
|
|
580
684
|
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
|
|
@@ -593,19 +697,12 @@ class CrustModule : Module() {
|
|
|
593
697
|
)
|
|
594
698
|
}
|
|
595
699
|
|
|
596
|
-
if (
|
|
597
|
-
val relativePath =
|
|
598
|
-
if (isVideo) {
|
|
599
|
-
"Movies/Mentra"
|
|
600
|
-
} else {
|
|
601
|
-
"Pictures/Mentra"
|
|
602
|
-
}
|
|
700
|
+
if (relativePath != null) {
|
|
603
701
|
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
|
604
702
|
put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
|
|
605
703
|
}
|
|
606
704
|
}
|
|
607
705
|
|
|
608
|
-
val resolver = context.contentResolver
|
|
609
706
|
val uri =
|
|
610
707
|
resolver.insert(collection, values)
|
|
611
708
|
?: throw IllegalStateException("Failed to create MediaStore entry")
|
|
@@ -879,4 +976,111 @@ class CrustModule : Module() {
|
|
|
879
976
|
mapOf("ok" to true)
|
|
880
977
|
}
|
|
881
978
|
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Find a completed export from a previous attempt. A crash can happen after MediaStore commits
|
|
982
|
+
* but before JavaScript persists the URI receipt; the stable capture display name, size, and
|
|
983
|
+
* capture time, and Mentra album path let the retry return that receipt instead of inserting a
|
|
984
|
+
* duplicate. Scoping by album is also important before deleting interrupted pending rows: a
|
|
985
|
+
* same-named asset owned by another album must never be treated as ours.
|
|
986
|
+
*/
|
|
987
|
+
private fun findExistingGalleryAsset(
|
|
988
|
+
resolver: android.content.ContentResolver,
|
|
989
|
+
collection: android.net.Uri,
|
|
990
|
+
displayNames: List<String>,
|
|
991
|
+
size: Long,
|
|
992
|
+
captureTimeMillis: Long?,
|
|
993
|
+
relativePath: String?,
|
|
994
|
+
ownerPackageName: String,
|
|
995
|
+
requireUniqueMatch: Boolean,
|
|
996
|
+
): android.net.Uri? {
|
|
997
|
+
val dateColumn = android.provider.MediaStore.Images.ImageColumns.DATE_TAKEN
|
|
998
|
+
if (displayNames.isEmpty()) return null
|
|
999
|
+
val namePlaceholders = displayNames.joinToString(",") { "?" }
|
|
1000
|
+
val selectionParts =
|
|
1001
|
+
mutableListOf(
|
|
1002
|
+
"${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} IN ($namePlaceholders)",
|
|
1003
|
+
)
|
|
1004
|
+
val selectionArgs = displayNames.toMutableList()
|
|
1005
|
+
if (captureTimeMillis != null) {
|
|
1006
|
+
selectionParts.add("$dateColumn = ?")
|
|
1007
|
+
selectionArgs.add(captureTimeMillis.toString())
|
|
1008
|
+
}
|
|
1009
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
|
|
1010
|
+
relativePath != null
|
|
1011
|
+
) {
|
|
1012
|
+
val pathWithoutTrailingSlash = relativePath.trimEnd('/')
|
|
1013
|
+
selectionParts.add(
|
|
1014
|
+
"(${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ? OR " +
|
|
1015
|
+
"${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ?)"
|
|
1016
|
+
)
|
|
1017
|
+
// MediaProvider normally canonicalizes RELATIVE_PATH with a trailing slash. Accept the
|
|
1018
|
+
// caller's original representation too so exports created by older Android builds remain
|
|
1019
|
+
// reconcilable, while still requiring an exact Mentra album match.
|
|
1020
|
+
selectionArgs.add(pathWithoutTrailingSlash)
|
|
1021
|
+
selectionArgs.add("$pathWithoutTrailingSlash/")
|
|
1022
|
+
// Never reconcile or delete another application's pending MediaStore row.
|
|
1023
|
+
selectionParts.add("${android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?")
|
|
1024
|
+
selectionArgs.add(ownerPackageName)
|
|
1025
|
+
}
|
|
1026
|
+
val projection =
|
|
1027
|
+
mutableListOf(
|
|
1028
|
+
android.provider.BaseColumns._ID,
|
|
1029
|
+
android.provider.MediaStore.MediaColumns.SIZE,
|
|
1030
|
+
)
|
|
1031
|
+
.apply {
|
|
1032
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
1033
|
+
add(android.provider.MediaStore.MediaColumns.IS_PENDING)
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return try {
|
|
1038
|
+
resolver
|
|
1039
|
+
.query(
|
|
1040
|
+
collection,
|
|
1041
|
+
projection.toTypedArray(),
|
|
1042
|
+
selectionParts.joinToString(" AND "),
|
|
1043
|
+
selectionArgs.toTypedArray(),
|
|
1044
|
+
"${android.provider.BaseColumns._ID} DESC",
|
|
1045
|
+
)
|
|
1046
|
+
?.use { cursor ->
|
|
1047
|
+
val candidates = mutableListOf<android.net.Uri>()
|
|
1048
|
+
val idColumn = cursor.getColumnIndexOrThrow(android.provider.BaseColumns._ID)
|
|
1049
|
+
val sizeColumn =
|
|
1050
|
+
cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns.SIZE)
|
|
1051
|
+
val pendingColumn =
|
|
1052
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
1053
|
+
cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.IS_PENDING)
|
|
1054
|
+
} else {
|
|
1055
|
+
-1
|
|
1056
|
+
}
|
|
1057
|
+
while (cursor.moveToNext()) {
|
|
1058
|
+
val uri =
|
|
1059
|
+
android.content.ContentUris.withAppendedId(
|
|
1060
|
+
collection,
|
|
1061
|
+
cursor.getLong(idColumn),
|
|
1062
|
+
)
|
|
1063
|
+
if (pendingColumn >= 0 && cursor.getInt(pendingColumn) != 0) {
|
|
1064
|
+
// This app owns the row and it was never published. Remove the interrupted
|
|
1065
|
+
// placeholder before retrying the copy, even if it contains only a prefix.
|
|
1066
|
+
resolver.delete(uri, null, null)
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
1069
|
+
if (cursor.getLong(sizeColumn) == size) candidates.add(uri)
|
|
1070
|
+
}
|
|
1071
|
+
if (requireUniqueMatch) {
|
|
1072
|
+
// A legacy `base.jpg`/`base.mp4` match is safe only when the complete
|
|
1073
|
+
// name/date/size/path/owner fingerprint identifies exactly one asset.
|
|
1074
|
+
candidates.singleOrNull()
|
|
1075
|
+
} else {
|
|
1076
|
+
// Capture-derived display names are stable. If an older retry already made
|
|
1077
|
+
// duplicates, reuse the newest completed row instead of creating another.
|
|
1078
|
+
candidates.firstOrNull()
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
} catch (error: Exception) {
|
|
1082
|
+
android.util.Log.w("CrustModule", "Unable to reconcile existing gallery asset", error)
|
|
1083
|
+
null
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
882
1086
|
}
|
|
@@ -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
|
|
|
@@ -436,6 +436,8 @@ object NavigationManager {
|
|
|
436
436
|
.alternatives(false)
|
|
437
437
|
.steps(true)
|
|
438
438
|
.bannerInstructions(true)
|
|
439
|
+
// The onboard router rejects voice units when voice instructions are disabled.
|
|
440
|
+
.voiceUnits(null)
|
|
439
441
|
.voiceInstructions(false)
|
|
440
442
|
.exclude(buildExclude(options))
|
|
441
443
|
.build()
|