@mentra/crust 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +1042 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +182 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +34 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +63 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +781 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +191 -0
- package/src/CrustModule.web.ts +66 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
package com.mentra.crust
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.mentra.crust.services.NotificationListener
|
|
5
|
+
import expo.modules.kotlin.modules.Module
|
|
6
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
7
|
+
import java.net.URL
|
|
8
|
+
|
|
9
|
+
import com.mentra.crust.navigation.NavigationManager
|
|
10
|
+
import com.mentra.crust.heading.HeadingManager
|
|
11
|
+
import com.mentra.crust.jsc.JSCRuntime
|
|
12
|
+
import com.mentra.crust.jsc.InstalledMiniappManifest
|
|
13
|
+
import com.mentra.crust.jsc.JSCPolyfillBridge
|
|
14
|
+
|
|
15
|
+
class CrustModule : Module() {
|
|
16
|
+
companion object {
|
|
17
|
+
private const val TAG = "CrustModule"
|
|
18
|
+
|
|
19
|
+
@Volatile private var eventEmitter: ((String, Map<String, Any>) -> Unit)? = null
|
|
20
|
+
|
|
21
|
+
fun emitPhoneNotification(
|
|
22
|
+
notificationKey: String,
|
|
23
|
+
packageName: String,
|
|
24
|
+
appName: String,
|
|
25
|
+
title: String,
|
|
26
|
+
text: String,
|
|
27
|
+
timestamp: Long,
|
|
28
|
+
) {
|
|
29
|
+
val data =
|
|
30
|
+
mapOf(
|
|
31
|
+
"notificationId" to "$packageName-$notificationKey",
|
|
32
|
+
"app" to appName,
|
|
33
|
+
"title" to title.ifEmpty { appName },
|
|
34
|
+
"content" to text,
|
|
35
|
+
"priority" to "normal",
|
|
36
|
+
"timestamp" to timestamp,
|
|
37
|
+
"packageName" to packageName,
|
|
38
|
+
)
|
|
39
|
+
emitEvent("phone_notification", data)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fun emitPhoneNotificationDismissed(notificationKey: String, packageName: String) {
|
|
43
|
+
val data =
|
|
44
|
+
mapOf(
|
|
45
|
+
"notificationId" to "$packageName-$notificationKey",
|
|
46
|
+
"notificationKey" to notificationKey,
|
|
47
|
+
"packageName" to packageName,
|
|
48
|
+
)
|
|
49
|
+
emitEvent("phone_notification_dismissed", data)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun emitCaptionsTesterIncident(data: Map<String, Any>) {
|
|
53
|
+
emitEvent("captions_tester_incident", data)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private fun emitEvent(eventName: String, data: Map<String, Any>) {
|
|
57
|
+
val emitter = eventEmitter
|
|
58
|
+
if (emitter == null) {
|
|
59
|
+
Log.w(TAG, "Cannot emit $eventName: event emitter is not available")
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
emitter.invoke(eventName, data)
|
|
65
|
+
} catch (e: Exception) {
|
|
66
|
+
Log.e(TAG, "Error emitting $eventName event", e)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Whether the JSCRuntime's onOutbound sink + polyfill bridge have been wired
|
|
72
|
+
// up. We try in OnCreate, but `appContext.reactContext` is often null that
|
|
73
|
+
// early — in that case we re-attempt the install on the first AsyncFunction
|
|
74
|
+
// call that can hand us a real context. Without this every host-bound
|
|
75
|
+
// __dispatch from a per-miniapp QuickJS context (SUBSCRIBE, mic, location,
|
|
76
|
+
// display, send, etc.) would be silently dropped on Android.
|
|
77
|
+
@Volatile private var runtimeInstalled: Boolean = false
|
|
78
|
+
|
|
79
|
+
private fun installRuntimeIfPossible(reason: String): Boolean {
|
|
80
|
+
if (runtimeInstalled) return true
|
|
81
|
+
val ctx = appContext.reactContext ?: appContext.currentActivity
|
|
82
|
+
if (ctx == null) {
|
|
83
|
+
Log.w(TAG, "MentraJS: install deferred ($reason) — no context yet")
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
val runtime = JSCRuntime.shared(ctx)
|
|
87
|
+
runtime.onOutbound = { msg ->
|
|
88
|
+
sendEvent("mentrajs_message", msg.payload)
|
|
89
|
+
}
|
|
90
|
+
JSCPolyfillBridge.install(runtime)
|
|
91
|
+
runtimeInstalled = true
|
|
92
|
+
Log.i(TAG, "MentraJS: runtime installed ($reason)")
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override fun definition() = ModuleDefinition {
|
|
97
|
+
Name("Crust")
|
|
98
|
+
|
|
99
|
+
Constant("PI") { Math.PI }
|
|
100
|
+
|
|
101
|
+
Events(
|
|
102
|
+
"onChange",
|
|
103
|
+
"phone_notification",
|
|
104
|
+
"phone_notification_dismissed",
|
|
105
|
+
"captions_tester_incident",
|
|
106
|
+
"onNavManeuver",
|
|
107
|
+
"onNavRerouting",
|
|
108
|
+
"onNavArrived",
|
|
109
|
+
"onNavError",
|
|
110
|
+
"onNavLocation",
|
|
111
|
+
"onNavRoute",
|
|
112
|
+
"onNavOffRoute",
|
|
113
|
+
"onHeading",
|
|
114
|
+
// MentraJS — per-miniapp JSContext outbound message bus.
|
|
115
|
+
// MentraJSRouter subscribes via Crust.addListener.
|
|
116
|
+
"mentrajs_message",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
OnCreate {
|
|
120
|
+
eventEmitter = { eventName, data -> sendEvent(eventName, data) }
|
|
121
|
+
installRuntimeIfPossible("OnCreate")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
Function("hello") {
|
|
125
|
+
"Hello world! 👋"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
AsyncFunction("setValueAsync") { value: String ->
|
|
129
|
+
sendEvent("onChange", mapOf("value" to value))
|
|
130
|
+
}
|
|
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
|
+
|
|
143
|
+
Function("showAVRoutePicker") { _: String? ->
|
|
144
|
+
// iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
AsyncFunction("setDeferredSystemGestures") { _: List<String> ->
|
|
148
|
+
// iOS-only: maps to preferredScreenEdgesDeferringSystemGestures.
|
|
149
|
+
// Android has no equivalent — system gestures are user-configurable
|
|
150
|
+
// at the OS level, not per-app.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// MARK: - MentraOS Notification Commands
|
|
154
|
+
|
|
155
|
+
AsyncFunction("setNotificationConfig") { enabled: Boolean, blocklist: List<String> ->
|
|
156
|
+
val context =
|
|
157
|
+
appContext.reactContext
|
|
158
|
+
?: appContext.currentActivity
|
|
159
|
+
?: throw IllegalStateException("No context available")
|
|
160
|
+
NotificationListener.getInstance(context).setNotificationConfig(enabled, blocklist)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
AsyncFunction("getInstalledApps") {
|
|
164
|
+
val context =
|
|
165
|
+
appContext.reactContext
|
|
166
|
+
?: appContext.currentActivity
|
|
167
|
+
?: throw IllegalStateException("No context available")
|
|
168
|
+
NotificationListener.getInstance(context).getInstalledApps()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
AsyncFunction("getInstalledAppsForNotifications") {
|
|
172
|
+
val context =
|
|
173
|
+
appContext.reactContext
|
|
174
|
+
?: appContext.currentActivity
|
|
175
|
+
?: throw IllegalStateException("No context available")
|
|
176
|
+
NotificationListener.getInstance(context).getInstalledApps()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
AsyncFunction("hasNotificationListenerPermission") {
|
|
180
|
+
val context =
|
|
181
|
+
appContext.reactContext
|
|
182
|
+
?: appContext.currentActivity
|
|
183
|
+
?: throw IllegalStateException("No context available")
|
|
184
|
+
NotificationListener.getInstance(context).hasNotificationListenerPermission()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
AsyncFunction("openNotificationListenerSettings") {
|
|
188
|
+
val context =
|
|
189
|
+
appContext.reactContext
|
|
190
|
+
?: appContext.currentActivity
|
|
191
|
+
?: throw IllegalStateException("No context available")
|
|
192
|
+
NotificationListener.getInstance(context).openNotificationListenerSettings()
|
|
193
|
+
true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: - MentraJS Runtime
|
|
197
|
+
|
|
198
|
+
AsyncFunction("mentraJsSpawn") { packageName: String, polyfillBundle: String, miniappJs: String ->
|
|
199
|
+
val ctx =
|
|
200
|
+
appContext.reactContext
|
|
201
|
+
?: appContext.currentActivity
|
|
202
|
+
?: throw IllegalStateException("MentraJS: no context")
|
|
203
|
+
// Safety net for the OnCreate-too-early race: if the runtime wasn't
|
|
204
|
+
// wired during module creation (no reactContext yet), do it now —
|
|
205
|
+
// otherwise every host-bound __dispatch from this miniapp's JSContext
|
|
206
|
+
// would have a null onOutbound sink and silently drop frames.
|
|
207
|
+
installRuntimeIfPossible("mentraJsSpawn")
|
|
208
|
+
JSCRuntime.shared(ctx).spawn(
|
|
209
|
+
packageName = packageName,
|
|
210
|
+
polyfillBundleOverride = polyfillBundle.takeIf { it.isNotEmpty() },
|
|
211
|
+
miniappJs = miniappJs,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
AsyncFunction("mentraJsEvaluate") { packageName: String, source: String ->
|
|
216
|
+
val ctx =
|
|
217
|
+
appContext.reactContext
|
|
218
|
+
?: appContext.currentActivity
|
|
219
|
+
?: throw IllegalStateException("MentraJS: no context")
|
|
220
|
+
JSCRuntime.shared(ctx).evaluate(packageName, source)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
AsyncFunction("mentraJsKill") { packageName: String ->
|
|
224
|
+
val ctx =
|
|
225
|
+
appContext.reactContext
|
|
226
|
+
?: appContext.currentActivity
|
|
227
|
+
?: throw IllegalStateException("MentraJS: no context")
|
|
228
|
+
JSCRuntime.shared(ctx).kill(packageName)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
AsyncFunction("mentraJsDispatchToJs") { packageName: String, envelope: Map<String, Any?> ->
|
|
232
|
+
val ctx =
|
|
233
|
+
appContext.reactContext
|
|
234
|
+
?: appContext.currentActivity
|
|
235
|
+
?: throw IllegalStateException("MentraJS: no context")
|
|
236
|
+
val json = org.json.JSONObject(envelope as Map<*, *>).toString()
|
|
237
|
+
JSCRuntime.shared(ctx).dispatchToJs(packageName, json)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
AsyncFunction("mentraJsSetManifest") { packageName: String, permissions: List<String> ->
|
|
241
|
+
val ctx =
|
|
242
|
+
appContext.reactContext
|
|
243
|
+
?: appContext.currentActivity
|
|
244
|
+
?: throw IllegalStateException("MentraJS: no context")
|
|
245
|
+
JSCRuntime.shared(ctx).dispatcher.setManifest(
|
|
246
|
+
packageName,
|
|
247
|
+
InstalledMiniappManifest(permissions.toSet()),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
Function("mentraJsAlivePackages") {
|
|
252
|
+
val ctx =
|
|
253
|
+
appContext.reactContext
|
|
254
|
+
?: appContext.currentActivity
|
|
255
|
+
if (ctx == null) {
|
|
256
|
+
return@Function emptyList<String>()
|
|
257
|
+
}
|
|
258
|
+
JSCRuntime.shared(ctx).alivePackages()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
AsyncFunction("mentraJsDebugForceGC") { packageName: String ->
|
|
262
|
+
val ctx =
|
|
263
|
+
appContext.reactContext
|
|
264
|
+
?: appContext.currentActivity
|
|
265
|
+
?: return@AsyncFunction false
|
|
266
|
+
JSCRuntime.shared(ctx).debugForceGC(packageName)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
Function("mentraJsLoadPolyfillBundle") {
|
|
270
|
+
val ctx =
|
|
271
|
+
appContext.reactContext
|
|
272
|
+
?: appContext.currentActivity
|
|
273
|
+
if (ctx == null) {
|
|
274
|
+
return@Function ""
|
|
275
|
+
}
|
|
276
|
+
JSCRuntime.shared(ctx).loadPolyfillBundle()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - Build Environment
|
|
280
|
+
|
|
281
|
+
AsyncFunction("isBetaBuild") { false }
|
|
282
|
+
|
|
283
|
+
View(CrustView::class) {
|
|
284
|
+
Prop("url") { view: CrustView, url: URL -> view.webView.loadUrl(url.toString()) }
|
|
285
|
+
Events("onLoad")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// MARK: - Settings Navigation
|
|
289
|
+
|
|
290
|
+
AsyncFunction("openBluetoothSettings") {
|
|
291
|
+
val context =
|
|
292
|
+
appContext.reactContext
|
|
293
|
+
?: appContext.currentActivity
|
|
294
|
+
?: throw IllegalStateException("No context available")
|
|
295
|
+
val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
|
|
296
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
297
|
+
context.startActivity(intent)
|
|
298
|
+
true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// MARK: - Location Services Commands
|
|
302
|
+
|
|
303
|
+
// Check if location services are enabled (required for WiFi operations on Android)
|
|
304
|
+
AsyncFunction("isLocationServicesEnabled") {
|
|
305
|
+
val context =
|
|
306
|
+
appContext.reactContext
|
|
307
|
+
?: appContext.currentActivity
|
|
308
|
+
?: throw IllegalStateException("No context available")
|
|
309
|
+
val locationManager =
|
|
310
|
+
context.getSystemService(android.content.Context.LOCATION_SERVICE) as
|
|
311
|
+
android.location.LocationManager
|
|
312
|
+
// Check if either GPS or Network location provider is enabled
|
|
313
|
+
val providerEnabled =
|
|
314
|
+
locationManager.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) ||
|
|
315
|
+
locationManager.isProviderEnabled(
|
|
316
|
+
android.location.LocationManager.NETWORK_PROVIDER
|
|
317
|
+
)
|
|
318
|
+
if (!providerEnabled) {
|
|
319
|
+
// Fallback: check the system-level location toggle directly.
|
|
320
|
+
// GPS_PROVIDER/NETWORK_PROVIDER can report disabled on devices without
|
|
321
|
+
// Google Play Services or without a GPS chip, even when location is toggled on.
|
|
322
|
+
// isLocationEnabled requires API 28+; on older devices just trust the provider check.
|
|
323
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
|
324
|
+
val systemEnabled = locationManager.isLocationEnabled
|
|
325
|
+
if (systemEnabled) {
|
|
326
|
+
android.util.Log.w(
|
|
327
|
+
"CoreModule",
|
|
328
|
+
"Location providers (GPS/Network) report disabled but system location toggle is ON. Device may lack GMS or GPS hardware."
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
systemEnabled
|
|
332
|
+
} else {
|
|
333
|
+
false
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
true
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
AsyncFunction("openLocationSettings") {
|
|
341
|
+
val context =
|
|
342
|
+
appContext.reactContext
|
|
343
|
+
?: appContext.currentActivity
|
|
344
|
+
?: throw IllegalStateException("No context available")
|
|
345
|
+
val intent = android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
|
346
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
347
|
+
context.startActivity(intent)
|
|
348
|
+
true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
AsyncFunction("openAppSettings") {
|
|
352
|
+
val context =
|
|
353
|
+
appContext.reactContext
|
|
354
|
+
?: appContext.currentActivity
|
|
355
|
+
?: throw IllegalStateException("No context available")
|
|
356
|
+
val intent =
|
|
357
|
+
android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
|
358
|
+
intent.data = android.net.Uri.parse("package:${context.packageName}")
|
|
359
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
360
|
+
context.startActivity(intent)
|
|
361
|
+
true
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
AsyncFunction("openBluetoothSettings") {
|
|
365
|
+
val context =
|
|
366
|
+
appContext.reactContext
|
|
367
|
+
?: appContext.currentActivity
|
|
368
|
+
?: throw IllegalStateException("No context available")
|
|
369
|
+
val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
|
|
370
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
371
|
+
context.startActivity(intent)
|
|
372
|
+
true
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
AsyncFunction("showLocationServicesDialog") {
|
|
376
|
+
val activity = appContext.currentActivity
|
|
377
|
+
if (activity == null) {
|
|
378
|
+
val context = appContext.reactContext ?: throw IllegalStateException("No context available")
|
|
379
|
+
val intent =
|
|
380
|
+
android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
|
381
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
382
|
+
context.startActivity(intent)
|
|
383
|
+
return@AsyncFunction true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
val locationRequest =
|
|
387
|
+
com.google.android.gms.location.LocationRequest.Builder(
|
|
388
|
+
com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY,
|
|
389
|
+
10000
|
|
390
|
+
)
|
|
391
|
+
.build()
|
|
392
|
+
|
|
393
|
+
val builder =
|
|
394
|
+
com.google.android.gms.location.LocationSettingsRequest.Builder()
|
|
395
|
+
.addLocationRequest(locationRequest)
|
|
396
|
+
.setAlwaysShow(true)
|
|
397
|
+
|
|
398
|
+
val client = com.google.android.gms.location.LocationServices.getSettingsClient(activity)
|
|
399
|
+
val task = client.checkLocationSettings(builder.build())
|
|
400
|
+
|
|
401
|
+
task.addOnSuccessListener { true }
|
|
402
|
+
task.addOnFailureListener { exception ->
|
|
403
|
+
if (exception is com.google.android.gms.common.api.ResolvableApiException) {
|
|
404
|
+
try {
|
|
405
|
+
exception.startResolutionForResult(activity, 1001)
|
|
406
|
+
} catch (sendEx: android.content.IntentSender.SendIntentException) {
|
|
407
|
+
// Fallback
|
|
408
|
+
val intent =
|
|
409
|
+
android.content.Intent(
|
|
410
|
+
android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS
|
|
411
|
+
)
|
|
412
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
413
|
+
activity.startActivity(intent)
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
val intent =
|
|
417
|
+
android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
|
418
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
419
|
+
activity.startActivity(intent)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
true
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// MARK: - Image Processing Commands
|
|
426
|
+
|
|
427
|
+
AsyncFunction("processGalleryImage") {
|
|
428
|
+
inputPath: String,
|
|
429
|
+
outputPath: String,
|
|
430
|
+
options: Map<String, Any?> ->
|
|
431
|
+
try {
|
|
432
|
+
val inputFile = java.io.File(inputPath)
|
|
433
|
+
if (!inputFile.exists()) {
|
|
434
|
+
throw IllegalArgumentException("Input file does not exist: $inputPath")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
val lensCorrection = options["lensCorrection"] as? Boolean ?: true
|
|
438
|
+
val colorCorrection = options["colorCorrection"] as? Boolean ?: true
|
|
439
|
+
|
|
440
|
+
val processingTimeMs =
|
|
441
|
+
com.mentra.crust.utils.ImageProcessor.process(
|
|
442
|
+
inputPath,
|
|
443
|
+
outputPath,
|
|
444
|
+
lensCorrection,
|
|
445
|
+
colorCorrection,
|
|
446
|
+
95
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if (processingTimeMs >= 0) {
|
|
450
|
+
mapOf(
|
|
451
|
+
"success" to true,
|
|
452
|
+
"outputPath" to outputPath,
|
|
453
|
+
"processingTimeMs" to processingTimeMs
|
|
454
|
+
)
|
|
455
|
+
} else {
|
|
456
|
+
mapOf("success" to false, "error" to "Processing failed")
|
|
457
|
+
}
|
|
458
|
+
} catch (e: Exception) {
|
|
459
|
+
android.util.Log.e("CrustModule", "processGalleryImage error: ${e.message}", e)
|
|
460
|
+
mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// MARK: - HDR Merge Commands
|
|
465
|
+
|
|
466
|
+
AsyncFunction("mergeHdrBrackets") {
|
|
467
|
+
underPath: String,
|
|
468
|
+
normalPath: String,
|
|
469
|
+
overPath: String,
|
|
470
|
+
outputPath: String ->
|
|
471
|
+
try {
|
|
472
|
+
val processingTimeMs =
|
|
473
|
+
com.mentra.crust.utils.ImageProcessor.mergeHdr(
|
|
474
|
+
underPath,
|
|
475
|
+
normalPath,
|
|
476
|
+
overPath,
|
|
477
|
+
outputPath,
|
|
478
|
+
95
|
|
479
|
+
)
|
|
480
|
+
if (processingTimeMs >= 0) {
|
|
481
|
+
mapOf(
|
|
482
|
+
"success" to true,
|
|
483
|
+
"outputPath" to outputPath,
|
|
484
|
+
"processingTimeMs" to processingTimeMs
|
|
485
|
+
)
|
|
486
|
+
} else {
|
|
487
|
+
mapOf("success" to false, "error" to "HDR merge failed")
|
|
488
|
+
}
|
|
489
|
+
} catch (e: Exception) {
|
|
490
|
+
android.util.Log.e("CrustModule", "mergeHdrBrackets error: ${e.message}", e)
|
|
491
|
+
mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// MARK: - Video Stabilization Commands
|
|
496
|
+
|
|
497
|
+
AsyncFunction("stabilizeVideo") { inputPath: String, imuPath: String, outputPath: String ->
|
|
498
|
+
try {
|
|
499
|
+
val inputFile = java.io.File(inputPath)
|
|
500
|
+
val imuFile = java.io.File(imuPath)
|
|
501
|
+
if (!inputFile.exists()) {
|
|
502
|
+
throw IllegalArgumentException("Input video does not exist: $inputPath")
|
|
503
|
+
}
|
|
504
|
+
if (!imuFile.exists()) {
|
|
505
|
+
throw IllegalArgumentException("IMU sidecar does not exist: $imuPath")
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
val processingTimeMs =
|
|
509
|
+
com.mentra.crust.utils.VideoStabilizer.stabilize(inputPath, imuPath, outputPath)
|
|
510
|
+
|
|
511
|
+
if (processingTimeMs >= 0) {
|
|
512
|
+
mapOf(
|
|
513
|
+
"success" to true,
|
|
514
|
+
"outputPath" to outputPath,
|
|
515
|
+
"processingTimeMs" to processingTimeMs
|
|
516
|
+
)
|
|
517
|
+
} else {
|
|
518
|
+
mapOf("success" to false, "error" to "Stabilization failed")
|
|
519
|
+
}
|
|
520
|
+
} catch (e: Exception) {
|
|
521
|
+
android.util.Log.e("CrustModule", "stabilizeVideo error: ${e.message}", e)
|
|
522
|
+
mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// MARK: - Media Library Commands
|
|
527
|
+
|
|
528
|
+
AsyncFunction("saveToGalleryWithDate") {
|
|
529
|
+
filePath: String,
|
|
530
|
+
captureTimeMillis: Long?,
|
|
531
|
+
displayName: String?
|
|
532
|
+
->
|
|
533
|
+
val context =
|
|
534
|
+
appContext.reactContext
|
|
535
|
+
?: appContext.currentActivity
|
|
536
|
+
?: throw IllegalStateException("No context available")
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
val file = java.io.File(filePath)
|
|
540
|
+
if (!file.exists()) {
|
|
541
|
+
throw IllegalArgumentException("File does not exist: $filePath")
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
val mimeType =
|
|
545
|
+
when (file.extension.lowercase()) {
|
|
546
|
+
"jpg", "jpeg" -> "image/jpeg"
|
|
547
|
+
"png" -> "image/png"
|
|
548
|
+
"mp4" -> "video/mp4"
|
|
549
|
+
"mov" -> "video/quicktime"
|
|
550
|
+
else -> "application/octet-stream"
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
val isVideo = mimeType.startsWith("video/")
|
|
554
|
+
val collection =
|
|
555
|
+
if (isVideo) {
|
|
556
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
557
|
+
android.provider.MediaStore.Video.Media.getContentUri(
|
|
558
|
+
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
|
|
559
|
+
)
|
|
560
|
+
} else {
|
|
561
|
+
android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
565
|
+
android.provider.MediaStore.Images.Media.getContentUri(
|
|
566
|
+
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
|
|
567
|
+
)
|
|
568
|
+
} else {
|
|
569
|
+
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
val mediaDisplayName =
|
|
574
|
+
displayName?.takeIf { it.isNotBlank() }
|
|
575
|
+
?: run {
|
|
576
|
+
val parentName = file.parentFile?.name
|
|
577
|
+
if (parentName != null &&
|
|
578
|
+
(parentName.startsWith("IMG_") ||
|
|
579
|
+
parentName.startsWith("VID_")) &&
|
|
580
|
+
(file.name == "base.jpg" || file.name == "base.mp4")
|
|
581
|
+
) {
|
|
582
|
+
val ext = if (mimeType.startsWith("video/")) "mp4" else "jpg"
|
|
583
|
+
"$parentName.$ext"
|
|
584
|
+
} else {
|
|
585
|
+
file.name
|
|
586
|
+
}
|
|
587
|
+
}
|
|
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
|
+
|
|
638
|
+
val values =
|
|
639
|
+
android.content.ContentValues().apply {
|
|
640
|
+
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
|
|
641
|
+
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
|
642
|
+
put(android.provider.MediaStore.MediaColumns.SIZE, file.length())
|
|
643
|
+
|
|
644
|
+
if (captureTimeMillis != null) {
|
|
645
|
+
if (isVideo) {
|
|
646
|
+
put(android.provider.MediaStore.Video.Media.DATE_TAKEN, captureTimeMillis)
|
|
647
|
+
} else {
|
|
648
|
+
put(android.provider.MediaStore.Images.Media.DATE_TAKEN, captureTimeMillis)
|
|
649
|
+
}
|
|
650
|
+
android.util.Log.d(
|
|
651
|
+
"CrustModule",
|
|
652
|
+
"Setting DATE_TAKEN to: $captureTimeMillis (${java.util.Date(captureTimeMillis)})"
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (relativePath != null) {
|
|
657
|
+
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
|
658
|
+
put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
val uri =
|
|
663
|
+
resolver.insert(collection, values)
|
|
664
|
+
?: throw IllegalStateException("Failed to create MediaStore entry")
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
resolver.openOutputStream(uri)?.use { outputStream ->
|
|
668
|
+
file.inputStream().use { inputStream -> inputStream.copyTo(outputStream) }
|
|
669
|
+
}
|
|
670
|
+
?: throw IllegalStateException("Failed to open output stream")
|
|
671
|
+
|
|
672
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
|
673
|
+
values.clear()
|
|
674
|
+
values.put(android.provider.MediaStore.MediaColumns.IS_PENDING, 0)
|
|
675
|
+
resolver.update(uri, values, null, null)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
android.util.Log.d(
|
|
679
|
+
"CrustModule",
|
|
680
|
+
"Successfully saved to gallery with proper DATE_TAKEN: $mediaDisplayName"
|
|
681
|
+
)
|
|
682
|
+
mapOf("success" to true, "uri" to uri.toString())
|
|
683
|
+
} catch (e: Exception) {
|
|
684
|
+
resolver.delete(uri, null, null)
|
|
685
|
+
throw e
|
|
686
|
+
}
|
|
687
|
+
} catch (e: Exception) {
|
|
688
|
+
android.util.Log.e("CrustModule", "Error saving to gallery: ${e.message}", e)
|
|
689
|
+
mapOf("success" to false, "error" to e.message)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// MARK: - Navigation Commands (Google Navigation SDK)
|
|
694
|
+
|
|
695
|
+
AsyncFunction("startNavigation") { lat: Double, lng: Double, options: Map<String, Any?>? ->
|
|
696
|
+
val activity = appContext.currentActivity
|
|
697
|
+
?: return@AsyncFunction mapOf("ok" to false, "error" to "no current activity (app backgrounded?)")
|
|
698
|
+
val simulate = (options?.get("simulate") as? Boolean) ?: false
|
|
699
|
+
val speed = (options?.get("speedMultiplier") as? Number)?.toFloat() ?: 1f
|
|
700
|
+
val mode = (options?.get("mode") as? String) ?: "driving"
|
|
701
|
+
|
|
702
|
+
// Prefer the v2 `stops` array when present. Fall back to lat/lng for
|
|
703
|
+
// older callers that haven't been upgraded yet.
|
|
704
|
+
val rawStops = options?.get("stops") as? List<*>
|
|
705
|
+
val stops: List<Pair<Double, Double>> = if (rawStops != null && rawStops.isNotEmpty()) {
|
|
706
|
+
rawStops.mapNotNull { item ->
|
|
707
|
+
val map = item as? Map<*, *> ?: return@mapNotNull null
|
|
708
|
+
val sLat = (map["lat"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
709
|
+
val sLng = (map["lng"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
710
|
+
sLat to sLng
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
listOf(lat to lng)
|
|
714
|
+
}
|
|
715
|
+
if (stops.isEmpty()) {
|
|
716
|
+
return@AsyncFunction mapOf("ok" to false, "error" to "no valid stops")
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
val avoidMap = options?.get("avoid") as? Map<*, *>
|
|
720
|
+
val avoidHighways = (avoidMap?.get("highways") as? Boolean) ?: false
|
|
721
|
+
val avoidTolls = (avoidMap?.get("tolls") as? Boolean) ?: false
|
|
722
|
+
val avoidFerries = (avoidMap?.get("ferries") as? Boolean) ?: false
|
|
723
|
+
|
|
724
|
+
val callbacks = object : NavigationManager.Callbacks {
|
|
725
|
+
override fun onManeuver(payload: NavigationManager.ManeuverPayload) {
|
|
726
|
+
sendEvent(
|
|
727
|
+
"onNavManeuver",
|
|
728
|
+
mapOf(
|
|
729
|
+
"maneuverType" to payload.maneuverType,
|
|
730
|
+
"distanceMeters" to payload.distanceMeters,
|
|
731
|
+
"fromRoad" to payload.fromRoad,
|
|
732
|
+
"toRoad" to payload.toRoad,
|
|
733
|
+
"nextStepRoad" to payload.nextStepRoad,
|
|
734
|
+
"distanceToDestinationMeters" to payload.distanceToDestinationMeters,
|
|
735
|
+
"timeToDestinationSeconds" to payload.timeToDestinationSeconds,
|
|
736
|
+
"currentSpeedMps" to payload.currentSpeedMps,
|
|
737
|
+
"speedLimitMps" to payload.speedLimitMps,
|
|
738
|
+
"routeHeadingDeg" to payload.routeHeadingDeg,
|
|
739
|
+
"instruction" to payload.instruction,
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
override fun onRerouting() {
|
|
744
|
+
sendEvent("onNavRerouting", emptyMap<String, Any?>())
|
|
745
|
+
}
|
|
746
|
+
override fun onArrived() {
|
|
747
|
+
sendEvent("onNavArrived", emptyMap<String, Any?>())
|
|
748
|
+
}
|
|
749
|
+
override fun onError(message: String) {
|
|
750
|
+
sendEvent("onNavError", mapOf("message" to message))
|
|
751
|
+
}
|
|
752
|
+
override fun onLocation(payload: NavigationManager.LocationPayload) {
|
|
753
|
+
sendEvent(
|
|
754
|
+
"onNavLocation",
|
|
755
|
+
mapOf(
|
|
756
|
+
"lat" to payload.lat,
|
|
757
|
+
"lng" to payload.lng,
|
|
758
|
+
"accuracy" to payload.accuracy,
|
|
759
|
+
"timestamp" to payload.timestamp,
|
|
760
|
+
),
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
override fun onRoute(
|
|
764
|
+
points: List<NavigationManager.RoutePoint>,
|
|
765
|
+
steps: List<NavigationManager.RouteStep>?,
|
|
766
|
+
) {
|
|
767
|
+
val payload = HashMap<String, Any?>()
|
|
768
|
+
payload["points"] = points.map { mapOf("lat" to it.lat, "lng" to it.lng) }
|
|
769
|
+
if (steps != null) {
|
|
770
|
+
payload["steps"] = steps.map {
|
|
771
|
+
mapOf(
|
|
772
|
+
"lat" to it.lat,
|
|
773
|
+
"lng" to it.lng,
|
|
774
|
+
"routeIndex" to it.routeIndex,
|
|
775
|
+
"road" to it.road,
|
|
776
|
+
"maneuver" to it.maneuver,
|
|
777
|
+
"distanceMeters" to it.distanceMeters,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
sendEvent("onNavRoute", payload)
|
|
782
|
+
}
|
|
783
|
+
override fun onOffRoute(perpendicularDistanceMeters: Double) {
|
|
784
|
+
sendEvent(
|
|
785
|
+
"onNavOffRoute",
|
|
786
|
+
mapOf("offRouteDistanceMeters" to perpendicularDistanceMeters),
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
activity.runOnUiThread {
|
|
792
|
+
// 1) Verify ACCESS_FINE_LOCATION is granted to this process. The
|
|
793
|
+
// Google Nav SDK rejects getNavigator() with LOCATION_PERMISSION_MISSING
|
|
794
|
+
// even if location was granted in another module's context — it
|
|
795
|
+
// requires PackageManager.PERMISSION_GRANTED at call-time.
|
|
796
|
+
val granted = androidx.core.content.ContextCompat.checkSelfPermission(
|
|
797
|
+
activity,
|
|
798
|
+
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
|
799
|
+
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
800
|
+
|
|
801
|
+
if (!granted) {
|
|
802
|
+
// Request and abort this attempt. User taps Start again after granting.
|
|
803
|
+
androidx.core.app.ActivityCompat.requestPermissions(
|
|
804
|
+
activity,
|
|
805
|
+
arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
|
|
806
|
+
9001,
|
|
807
|
+
)
|
|
808
|
+
sendEvent(
|
|
809
|
+
"onNavError",
|
|
810
|
+
mapOf("message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again"),
|
|
811
|
+
)
|
|
812
|
+
return@runOnUiThread
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 2) NavigationManager owns the T&C flow — it shows the dialog
|
|
816
|
+
// only on the very first run (persisted), and passes
|
|
817
|
+
// `SKIPPED` to `getNavigator` afterward to suppress the
|
|
818
|
+
// "Welcome to Google Maps" toast.
|
|
819
|
+
NavigationManager.start(
|
|
820
|
+
activity,
|
|
821
|
+
NavigationManager.StartOptions(
|
|
822
|
+
stops = stops,
|
|
823
|
+
mode = mode,
|
|
824
|
+
avoidHighways = avoidHighways,
|
|
825
|
+
avoidTolls = avoidTolls,
|
|
826
|
+
avoidFerries = avoidFerries,
|
|
827
|
+
simulate = simulate,
|
|
828
|
+
speedMultiplier = speed,
|
|
829
|
+
),
|
|
830
|
+
callbacks,
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
mapOf("ok" to true)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Eager T&C dialog. Lets a miniapp surface the Google Nav SDK terms
|
|
837
|
+
// dialog at mount time so the actual startNavigation() call later is
|
|
838
|
+
// friction-free. Idempotent — resolves immediately when the user has
|
|
839
|
+
// already accepted (in-process flag, on-disk pref, or SDK state).
|
|
840
|
+
AsyncFunction("requestNavigationPermission") { promise: expo.modules.kotlin.Promise ->
|
|
841
|
+
val activity = appContext.currentActivity
|
|
842
|
+
if (activity == null) {
|
|
843
|
+
promise.resolve(mapOf("ok" to false, "accepted" to false, "error" to "no current activity"))
|
|
844
|
+
return@AsyncFunction
|
|
845
|
+
}
|
|
846
|
+
activity.runOnUiThread {
|
|
847
|
+
NavigationManager.ensureTermsAccepted(activity) { accepted ->
|
|
848
|
+
promise.resolve(mapOf("ok" to true, "accepted" to accepted))
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Dev-only: clear the cached "terms accepted" flags (SDK + on-disk
|
|
854
|
+
// pref + in-process) so the next requestNavigationPermission() call
|
|
855
|
+
// re-shows the dialog. Used by the dev-settings re-trigger button.
|
|
856
|
+
AsyncFunction("resetNavigationPermission") { promise: expo.modules.kotlin.Promise ->
|
|
857
|
+
val activity = appContext.currentActivity
|
|
858
|
+
if (activity == null) {
|
|
859
|
+
promise.resolve(mapOf("ok" to false, "error" to "no current activity"))
|
|
860
|
+
return@AsyncFunction
|
|
861
|
+
}
|
|
862
|
+
activity.runOnUiThread {
|
|
863
|
+
try {
|
|
864
|
+
NavigationManager.resetTermsAccepted(activity)
|
|
865
|
+
promise.resolve(mapOf("ok" to true))
|
|
866
|
+
} catch (e: Exception) {
|
|
867
|
+
android.util.Log.e("CrustModule", "resetNavigationPermission failed", e)
|
|
868
|
+
promise.resolve(mapOf("ok" to false, "error" to (e.message ?: "reset failed")))
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
AsyncFunction("stopNavigation") {
|
|
874
|
+
try {
|
|
875
|
+
NavigationManager.stop()
|
|
876
|
+
mapOf("ok" to true)
|
|
877
|
+
} catch (e: Exception) {
|
|
878
|
+
android.util.Log.e("CrustModule", "stopNavigation failed", e)
|
|
879
|
+
mapOf("ok" to false, "error" to (e.message ?: "stop failed"))
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Dev-only: nudge the simulated position ~offsetMeters off-route to
|
|
884
|
+
// exercise the Nav SDK's onRerouting() pipeline without having to
|
|
885
|
+
// physically walk off the planned path. No-op on real GPS fixes.
|
|
886
|
+
AsyncFunction("simulateDeviation") { offsetMeters: Double? ->
|
|
887
|
+
try {
|
|
888
|
+
NavigationManager.simulateDeviation(offsetMeters ?: 20.0)
|
|
889
|
+
mapOf("ok" to true)
|
|
890
|
+
} catch (e: Exception) {
|
|
891
|
+
android.util.Log.e("CrustModule", "simulateDeviation failed", e)
|
|
892
|
+
mapOf("ok" to false, "error" to (e.message ?: "deviate failed"))
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
AsyncFunction("setWrongSidewalkOffset") { enabled: Boolean ->
|
|
897
|
+
try {
|
|
898
|
+
NavigationManager.setWrongSidewalkOffset(enabled)
|
|
899
|
+
mapOf("ok" to true)
|
|
900
|
+
} catch (e: Exception) {
|
|
901
|
+
android.util.Log.e("CrustModule", "setWrongSidewalkOffset failed", e)
|
|
902
|
+
mapOf("ok" to false, "error" to (e.message ?: "setWrongSidewalkOffset failed"))
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
AsyncFunction("setSkipCrossings") { enabled: Boolean ->
|
|
907
|
+
try {
|
|
908
|
+
NavigationManager.setSkipCrossings(enabled)
|
|
909
|
+
mapOf("ok" to true)
|
|
910
|
+
} catch (e: Exception) {
|
|
911
|
+
android.util.Log.e("CrustModule", "setSkipCrossings failed", e)
|
|
912
|
+
mapOf("ok" to false, "error" to (e.message ?: "setSkipCrossings failed"))
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// MARK: - Heading (compass) — Android only
|
|
917
|
+
|
|
918
|
+
AsyncFunction("startHeading") {
|
|
919
|
+
val ctx = appContext.reactContext
|
|
920
|
+
?: appContext.currentActivity
|
|
921
|
+
?: return@AsyncFunction mapOf("ok" to false, "error" to "no context")
|
|
922
|
+
HeadingManager.start(ctx, object : HeadingManager.Callback {
|
|
923
|
+
override fun onHeading(degrees: Float) {
|
|
924
|
+
sendEvent("onHeading", mapOf("degrees" to degrees.toDouble()))
|
|
925
|
+
}
|
|
926
|
+
})
|
|
927
|
+
mapOf("ok" to true)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
AsyncFunction("stopHeading") {
|
|
931
|
+
HeadingManager.stop()
|
|
932
|
+
mapOf("ok" to true)
|
|
933
|
+
}
|
|
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
|
+
}
|
|
1042
|
+
}
|