@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.
Files changed (66) hide show
  1. package/README.md +41 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +1042 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +182 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +34 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +63 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +781 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +191 -0
  63. package/src/CrustModule.web.ts +66 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. 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
+ }