@mentra/crust 0.1.0-dev.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 +35 -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 +882 -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 +246 -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 +175 -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 +26 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +54 -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 +544 -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 +186 -0
  63. package/src/CrustModule.web.ts +57 -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,882 @@
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
+ Function("showAVRoutePicker") { _: String? ->
133
+ // iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
134
+ }
135
+
136
+ AsyncFunction("setDeferredSystemGestures") { _: List<String> ->
137
+ // iOS-only: maps to preferredScreenEdgesDeferringSystemGestures.
138
+ // Android has no equivalent — system gestures are user-configurable
139
+ // at the OS level, not per-app.
140
+ }
141
+
142
+ // MARK: - MentraOS Notification Commands
143
+
144
+ AsyncFunction("setNotificationConfig") { enabled: Boolean, blocklist: List<String> ->
145
+ val context =
146
+ appContext.reactContext
147
+ ?: appContext.currentActivity
148
+ ?: throw IllegalStateException("No context available")
149
+ NotificationListener.getInstance(context).setNotificationConfig(enabled, blocklist)
150
+ }
151
+
152
+ AsyncFunction("getInstalledApps") {
153
+ val context =
154
+ appContext.reactContext
155
+ ?: appContext.currentActivity
156
+ ?: throw IllegalStateException("No context available")
157
+ NotificationListener.getInstance(context).getInstalledApps()
158
+ }
159
+
160
+ AsyncFunction("getInstalledAppsForNotifications") {
161
+ val context =
162
+ appContext.reactContext
163
+ ?: appContext.currentActivity
164
+ ?: throw IllegalStateException("No context available")
165
+ NotificationListener.getInstance(context).getInstalledApps()
166
+ }
167
+
168
+ AsyncFunction("hasNotificationListenerPermission") {
169
+ val context =
170
+ appContext.reactContext
171
+ ?: appContext.currentActivity
172
+ ?: throw IllegalStateException("No context available")
173
+ NotificationListener.getInstance(context).hasNotificationListenerPermission()
174
+ }
175
+
176
+ AsyncFunction("openNotificationListenerSettings") {
177
+ val context =
178
+ appContext.reactContext
179
+ ?: appContext.currentActivity
180
+ ?: throw IllegalStateException("No context available")
181
+ NotificationListener.getInstance(context).openNotificationListenerSettings()
182
+ true
183
+ }
184
+
185
+ // MARK: - MentraJS Runtime
186
+
187
+ AsyncFunction("mentraJsSpawn") { packageName: String, polyfillBundle: String, miniappJs: String ->
188
+ val ctx =
189
+ appContext.reactContext
190
+ ?: appContext.currentActivity
191
+ ?: throw IllegalStateException("MentraJS: no context")
192
+ // Safety net for the OnCreate-too-early race: if the runtime wasn't
193
+ // wired during module creation (no reactContext yet), do it now —
194
+ // otherwise every host-bound __dispatch from this miniapp's JSContext
195
+ // would have a null onOutbound sink and silently drop frames.
196
+ installRuntimeIfPossible("mentraJsSpawn")
197
+ JSCRuntime.shared(ctx).spawn(
198
+ packageName = packageName,
199
+ polyfillBundleOverride = polyfillBundle.takeIf { it.isNotEmpty() },
200
+ miniappJs = miniappJs,
201
+ )
202
+ }
203
+
204
+ AsyncFunction("mentraJsEvaluate") { packageName: String, source: String ->
205
+ val ctx =
206
+ appContext.reactContext
207
+ ?: appContext.currentActivity
208
+ ?: throw IllegalStateException("MentraJS: no context")
209
+ JSCRuntime.shared(ctx).evaluate(packageName, source)
210
+ }
211
+
212
+ AsyncFunction("mentraJsKill") { packageName: String ->
213
+ val ctx =
214
+ appContext.reactContext
215
+ ?: appContext.currentActivity
216
+ ?: throw IllegalStateException("MentraJS: no context")
217
+ JSCRuntime.shared(ctx).kill(packageName)
218
+ }
219
+
220
+ AsyncFunction("mentraJsDispatchToJs") { packageName: String, envelope: Map<String, Any?> ->
221
+ val ctx =
222
+ appContext.reactContext
223
+ ?: appContext.currentActivity
224
+ ?: throw IllegalStateException("MentraJS: no context")
225
+ val json = org.json.JSONObject(envelope as Map<*, *>).toString()
226
+ JSCRuntime.shared(ctx).dispatchToJs(packageName, json)
227
+ }
228
+
229
+ AsyncFunction("mentraJsSetManifest") { packageName: String, permissions: List<String> ->
230
+ val ctx =
231
+ appContext.reactContext
232
+ ?: appContext.currentActivity
233
+ ?: throw IllegalStateException("MentraJS: no context")
234
+ JSCRuntime.shared(ctx).dispatcher.setManifest(
235
+ packageName,
236
+ InstalledMiniappManifest(permissions.toSet()),
237
+ )
238
+ }
239
+
240
+ Function("mentraJsAlivePackages") {
241
+ val ctx =
242
+ appContext.reactContext
243
+ ?: appContext.currentActivity
244
+ if (ctx == null) {
245
+ return@Function emptyList<String>()
246
+ }
247
+ JSCRuntime.shared(ctx).alivePackages()
248
+ }
249
+
250
+ AsyncFunction("mentraJsDebugForceGC") { packageName: String ->
251
+ val ctx =
252
+ appContext.reactContext
253
+ ?: appContext.currentActivity
254
+ ?: return@AsyncFunction false
255
+ JSCRuntime.shared(ctx).debugForceGC(packageName)
256
+ }
257
+
258
+ Function("mentraJsLoadPolyfillBundle") {
259
+ val ctx =
260
+ appContext.reactContext
261
+ ?: appContext.currentActivity
262
+ if (ctx == null) {
263
+ return@Function ""
264
+ }
265
+ JSCRuntime.shared(ctx).loadPolyfillBundle()
266
+ }
267
+
268
+ // MARK: - Build Environment
269
+
270
+ AsyncFunction("isBetaBuild") { false }
271
+
272
+ View(CrustView::class) {
273
+ Prop("url") { view: CrustView, url: URL -> view.webView.loadUrl(url.toString()) }
274
+ Events("onLoad")
275
+ }
276
+
277
+ // MARK: - Settings Navigation
278
+
279
+ AsyncFunction("openBluetoothSettings") {
280
+ val context =
281
+ appContext.reactContext
282
+ ?: appContext.currentActivity
283
+ ?: throw IllegalStateException("No context available")
284
+ val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
285
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
286
+ context.startActivity(intent)
287
+ true
288
+ }
289
+
290
+ // MARK: - Location Services Commands
291
+
292
+ // Check if location services are enabled (required for WiFi operations on Android)
293
+ AsyncFunction("isLocationServicesEnabled") {
294
+ val context =
295
+ appContext.reactContext
296
+ ?: appContext.currentActivity
297
+ ?: throw IllegalStateException("No context available")
298
+ val locationManager =
299
+ context.getSystemService(android.content.Context.LOCATION_SERVICE) as
300
+ android.location.LocationManager
301
+ // Check if either GPS or Network location provider is enabled
302
+ val providerEnabled =
303
+ locationManager.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) ||
304
+ locationManager.isProviderEnabled(
305
+ android.location.LocationManager.NETWORK_PROVIDER
306
+ )
307
+ if (!providerEnabled) {
308
+ // Fallback: check the system-level location toggle directly.
309
+ // GPS_PROVIDER/NETWORK_PROVIDER can report disabled on devices without
310
+ // Google Play Services or without a GPS chip, even when location is toggled on.
311
+ // isLocationEnabled requires API 28+; on older devices just trust the provider check.
312
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
313
+ val systemEnabled = locationManager.isLocationEnabled
314
+ if (systemEnabled) {
315
+ android.util.Log.w(
316
+ "CoreModule",
317
+ "Location providers (GPS/Network) report disabled but system location toggle is ON. Device may lack GMS or GPS hardware."
318
+ )
319
+ }
320
+ systemEnabled
321
+ } else {
322
+ false
323
+ }
324
+ } else {
325
+ true
326
+ }
327
+ }
328
+
329
+ AsyncFunction("openLocationSettings") {
330
+ val context =
331
+ appContext.reactContext
332
+ ?: appContext.currentActivity
333
+ ?: throw IllegalStateException("No context available")
334
+ val intent = android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
335
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
336
+ context.startActivity(intent)
337
+ true
338
+ }
339
+
340
+ AsyncFunction("openAppSettings") {
341
+ val context =
342
+ appContext.reactContext
343
+ ?: appContext.currentActivity
344
+ ?: throw IllegalStateException("No context available")
345
+ val intent =
346
+ android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
347
+ intent.data = android.net.Uri.parse("package:${context.packageName}")
348
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
349
+ context.startActivity(intent)
350
+ true
351
+ }
352
+
353
+ AsyncFunction("openBluetoothSettings") {
354
+ val context =
355
+ appContext.reactContext
356
+ ?: appContext.currentActivity
357
+ ?: throw IllegalStateException("No context available")
358
+ val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
359
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
360
+ context.startActivity(intent)
361
+ true
362
+ }
363
+
364
+ AsyncFunction("showLocationServicesDialog") {
365
+ val activity = appContext.currentActivity
366
+ if (activity == null) {
367
+ val context = appContext.reactContext ?: throw IllegalStateException("No context available")
368
+ val intent =
369
+ android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
370
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
371
+ context.startActivity(intent)
372
+ return@AsyncFunction true
373
+ }
374
+
375
+ val locationRequest =
376
+ com.google.android.gms.location.LocationRequest.Builder(
377
+ com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY,
378
+ 10000
379
+ )
380
+ .build()
381
+
382
+ val builder =
383
+ com.google.android.gms.location.LocationSettingsRequest.Builder()
384
+ .addLocationRequest(locationRequest)
385
+ .setAlwaysShow(true)
386
+
387
+ val client = com.google.android.gms.location.LocationServices.getSettingsClient(activity)
388
+ val task = client.checkLocationSettings(builder.build())
389
+
390
+ task.addOnSuccessListener { true }
391
+ task.addOnFailureListener { exception ->
392
+ if (exception is com.google.android.gms.common.api.ResolvableApiException) {
393
+ try {
394
+ exception.startResolutionForResult(activity, 1001)
395
+ } catch (sendEx: android.content.IntentSender.SendIntentException) {
396
+ // Fallback
397
+ val intent =
398
+ android.content.Intent(
399
+ android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS
400
+ )
401
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
402
+ activity.startActivity(intent)
403
+ }
404
+ } else {
405
+ val intent =
406
+ android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
407
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
408
+ activity.startActivity(intent)
409
+ }
410
+ }
411
+ true
412
+ }
413
+
414
+ // MARK: - Image Processing Commands
415
+
416
+ AsyncFunction("processGalleryImage") {
417
+ inputPath: String,
418
+ outputPath: String,
419
+ options: Map<String, Any?> ->
420
+ try {
421
+ val inputFile = java.io.File(inputPath)
422
+ if (!inputFile.exists()) {
423
+ throw IllegalArgumentException("Input file does not exist: $inputPath")
424
+ }
425
+
426
+ val lensCorrection = options["lensCorrection"] as? Boolean ?: true
427
+ val colorCorrection = options["colorCorrection"] as? Boolean ?: true
428
+
429
+ val processingTimeMs =
430
+ com.mentra.crust.utils.ImageProcessor.process(
431
+ inputPath,
432
+ outputPath,
433
+ lensCorrection,
434
+ colorCorrection,
435
+ 95
436
+ )
437
+
438
+ if (processingTimeMs >= 0) {
439
+ mapOf(
440
+ "success" to true,
441
+ "outputPath" to outputPath,
442
+ "processingTimeMs" to processingTimeMs
443
+ )
444
+ } else {
445
+ mapOf("success" to false, "error" to "Processing failed")
446
+ }
447
+ } catch (e: Exception) {
448
+ android.util.Log.e("CrustModule", "processGalleryImage error: ${e.message}", e)
449
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
450
+ }
451
+ }
452
+
453
+ // MARK: - HDR Merge Commands
454
+
455
+ AsyncFunction("mergeHdrBrackets") {
456
+ underPath: String,
457
+ normalPath: String,
458
+ overPath: String,
459
+ outputPath: String ->
460
+ try {
461
+ val processingTimeMs =
462
+ com.mentra.crust.utils.ImageProcessor.mergeHdr(
463
+ underPath,
464
+ normalPath,
465
+ overPath,
466
+ outputPath,
467
+ 95
468
+ )
469
+ if (processingTimeMs >= 0) {
470
+ mapOf(
471
+ "success" to true,
472
+ "outputPath" to outputPath,
473
+ "processingTimeMs" to processingTimeMs
474
+ )
475
+ } else {
476
+ mapOf("success" to false, "error" to "HDR merge failed")
477
+ }
478
+ } catch (e: Exception) {
479
+ android.util.Log.e("CrustModule", "mergeHdrBrackets error: ${e.message}", e)
480
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
481
+ }
482
+ }
483
+
484
+ // MARK: - Video Stabilization Commands
485
+
486
+ AsyncFunction("stabilizeVideo") { inputPath: String, imuPath: String, outputPath: String ->
487
+ try {
488
+ val inputFile = java.io.File(inputPath)
489
+ val imuFile = java.io.File(imuPath)
490
+ if (!inputFile.exists()) {
491
+ throw IllegalArgumentException("Input video does not exist: $inputPath")
492
+ }
493
+ if (!imuFile.exists()) {
494
+ throw IllegalArgumentException("IMU sidecar does not exist: $imuPath")
495
+ }
496
+
497
+ val processingTimeMs =
498
+ com.mentra.crust.utils.VideoStabilizer.stabilize(inputPath, imuPath, outputPath)
499
+
500
+ if (processingTimeMs >= 0) {
501
+ mapOf(
502
+ "success" to true,
503
+ "outputPath" to outputPath,
504
+ "processingTimeMs" to processingTimeMs
505
+ )
506
+ } else {
507
+ mapOf("success" to false, "error" to "Stabilization failed")
508
+ }
509
+ } catch (e: Exception) {
510
+ android.util.Log.e("CrustModule", "stabilizeVideo error: ${e.message}", e)
511
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
512
+ }
513
+ }
514
+
515
+ // MARK: - Media Library Commands
516
+
517
+ AsyncFunction("saveToGalleryWithDate") {
518
+ filePath: String,
519
+ captureTimeMillis: Long?,
520
+ displayName: String?
521
+ ->
522
+ val context =
523
+ appContext.reactContext
524
+ ?: appContext.currentActivity
525
+ ?: throw IllegalStateException("No context available")
526
+
527
+ try {
528
+ val file = java.io.File(filePath)
529
+ if (!file.exists()) {
530
+ throw IllegalArgumentException("File does not exist: $filePath")
531
+ }
532
+
533
+ val mimeType =
534
+ when (file.extension.lowercase()) {
535
+ "jpg", "jpeg" -> "image/jpeg"
536
+ "png" -> "image/png"
537
+ "mp4" -> "video/mp4"
538
+ "mov" -> "video/quicktime"
539
+ else -> "application/octet-stream"
540
+ }
541
+
542
+ val isVideo = mimeType.startsWith("video/")
543
+ val collection =
544
+ if (isVideo) {
545
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
546
+ android.provider.MediaStore.Video.Media.getContentUri(
547
+ android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
548
+ )
549
+ } else {
550
+ android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI
551
+ }
552
+ } else {
553
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
554
+ android.provider.MediaStore.Images.Media.getContentUri(
555
+ android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
556
+ )
557
+ } else {
558
+ android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
559
+ }
560
+ }
561
+
562
+ val mediaDisplayName =
563
+ displayName?.takeIf { it.isNotBlank() }
564
+ ?: run {
565
+ val parentName = file.parentFile?.name
566
+ if (parentName != null &&
567
+ (parentName.startsWith("IMG_") ||
568
+ parentName.startsWith("VID_")) &&
569
+ (file.name == "base.jpg" || file.name == "base.mp4")
570
+ ) {
571
+ val ext = if (mimeType.startsWith("video/")) "mp4" else "jpg"
572
+ "$parentName.$ext"
573
+ } else {
574
+ file.name
575
+ }
576
+ }
577
+
578
+ val values =
579
+ android.content.ContentValues().apply {
580
+ put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
581
+ put(android.provider.MediaStore.MediaColumns.MIME_TYPE, mimeType)
582
+ put(android.provider.MediaStore.MediaColumns.SIZE, file.length())
583
+
584
+ if (captureTimeMillis != null) {
585
+ if (isVideo) {
586
+ put(android.provider.MediaStore.Video.Media.DATE_TAKEN, captureTimeMillis)
587
+ } else {
588
+ put(android.provider.MediaStore.Images.Media.DATE_TAKEN, captureTimeMillis)
589
+ }
590
+ android.util.Log.d(
591
+ "CrustModule",
592
+ "Setting DATE_TAKEN to: $captureTimeMillis (${java.util.Date(captureTimeMillis)})"
593
+ )
594
+ }
595
+
596
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
597
+ val relativePath =
598
+ if (isVideo) {
599
+ "Movies/Mentra"
600
+ } else {
601
+ "Pictures/Mentra"
602
+ }
603
+ put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
604
+ put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
605
+ }
606
+ }
607
+
608
+ val resolver = context.contentResolver
609
+ val uri =
610
+ resolver.insert(collection, values)
611
+ ?: throw IllegalStateException("Failed to create MediaStore entry")
612
+
613
+ try {
614
+ resolver.openOutputStream(uri)?.use { outputStream ->
615
+ file.inputStream().use { inputStream -> inputStream.copyTo(outputStream) }
616
+ }
617
+ ?: throw IllegalStateException("Failed to open output stream")
618
+
619
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
620
+ values.clear()
621
+ values.put(android.provider.MediaStore.MediaColumns.IS_PENDING, 0)
622
+ resolver.update(uri, values, null, null)
623
+ }
624
+
625
+ android.util.Log.d(
626
+ "CrustModule",
627
+ "Successfully saved to gallery with proper DATE_TAKEN: $mediaDisplayName"
628
+ )
629
+ mapOf("success" to true, "uri" to uri.toString())
630
+ } catch (e: Exception) {
631
+ resolver.delete(uri, null, null)
632
+ throw e
633
+ }
634
+ } catch (e: Exception) {
635
+ android.util.Log.e("CrustModule", "Error saving to gallery: ${e.message}", e)
636
+ mapOf("success" to false, "error" to e.message)
637
+ }
638
+ }
639
+
640
+ // MARK: - Navigation Commands (Google Navigation SDK)
641
+
642
+ AsyncFunction("startNavigation") { lat: Double, lng: Double, options: Map<String, Any?>? ->
643
+ val activity = appContext.currentActivity
644
+ ?: return@AsyncFunction mapOf("ok" to false, "error" to "no current activity (app backgrounded?)")
645
+ val simulate = (options?.get("simulate") as? Boolean) ?: false
646
+ val speed = (options?.get("speedMultiplier") as? Number)?.toFloat() ?: 1f
647
+ val mode = (options?.get("mode") as? String) ?: "driving"
648
+
649
+ // Prefer the v2 `stops` array when present. Fall back to lat/lng for
650
+ // older callers that haven't been upgraded yet.
651
+ val rawStops = options?.get("stops") as? List<*>
652
+ val stops: List<Pair<Double, Double>> = if (rawStops != null && rawStops.isNotEmpty()) {
653
+ rawStops.mapNotNull { item ->
654
+ val map = item as? Map<*, *> ?: return@mapNotNull null
655
+ val sLat = (map["lat"] as? Number)?.toDouble() ?: return@mapNotNull null
656
+ val sLng = (map["lng"] as? Number)?.toDouble() ?: return@mapNotNull null
657
+ sLat to sLng
658
+ }
659
+ } else {
660
+ listOf(lat to lng)
661
+ }
662
+ if (stops.isEmpty()) {
663
+ return@AsyncFunction mapOf("ok" to false, "error" to "no valid stops")
664
+ }
665
+
666
+ val avoidMap = options?.get("avoid") as? Map<*, *>
667
+ val avoidHighways = (avoidMap?.get("highways") as? Boolean) ?: false
668
+ val avoidTolls = (avoidMap?.get("tolls") as? Boolean) ?: false
669
+ val avoidFerries = (avoidMap?.get("ferries") as? Boolean) ?: false
670
+
671
+ val callbacks = object : NavigationManager.Callbacks {
672
+ override fun onManeuver(payload: NavigationManager.ManeuverPayload) {
673
+ sendEvent(
674
+ "onNavManeuver",
675
+ mapOf(
676
+ "maneuverType" to payload.maneuverType,
677
+ "distanceMeters" to payload.distanceMeters,
678
+ "fromRoad" to payload.fromRoad,
679
+ "toRoad" to payload.toRoad,
680
+ "nextStepRoad" to payload.nextStepRoad,
681
+ "distanceToDestinationMeters" to payload.distanceToDestinationMeters,
682
+ "timeToDestinationSeconds" to payload.timeToDestinationSeconds,
683
+ "currentSpeedMps" to payload.currentSpeedMps,
684
+ "speedLimitMps" to payload.speedLimitMps,
685
+ "routeHeadingDeg" to payload.routeHeadingDeg,
686
+ "instruction" to payload.instruction,
687
+ ),
688
+ )
689
+ }
690
+ override fun onRerouting() {
691
+ sendEvent("onNavRerouting", emptyMap<String, Any?>())
692
+ }
693
+ override fun onArrived() {
694
+ sendEvent("onNavArrived", emptyMap<String, Any?>())
695
+ }
696
+ override fun onError(message: String) {
697
+ sendEvent("onNavError", mapOf("message" to message))
698
+ }
699
+ override fun onLocation(payload: NavigationManager.LocationPayload) {
700
+ sendEvent(
701
+ "onNavLocation",
702
+ mapOf(
703
+ "lat" to payload.lat,
704
+ "lng" to payload.lng,
705
+ "accuracy" to payload.accuracy,
706
+ "timestamp" to payload.timestamp,
707
+ ),
708
+ )
709
+ }
710
+ override fun onRoute(
711
+ points: List<NavigationManager.RoutePoint>,
712
+ steps: List<NavigationManager.RouteStep>?,
713
+ ) {
714
+ val payload = HashMap<String, Any?>()
715
+ payload["points"] = points.map { mapOf("lat" to it.lat, "lng" to it.lng) }
716
+ if (steps != null) {
717
+ payload["steps"] = steps.map {
718
+ mapOf(
719
+ "lat" to it.lat,
720
+ "lng" to it.lng,
721
+ "routeIndex" to it.routeIndex,
722
+ "road" to it.road,
723
+ "maneuver" to it.maneuver,
724
+ "distanceMeters" to it.distanceMeters,
725
+ )
726
+ }
727
+ }
728
+ sendEvent("onNavRoute", payload)
729
+ }
730
+ override fun onOffRoute(perpendicularDistanceMeters: Double) {
731
+ sendEvent(
732
+ "onNavOffRoute",
733
+ mapOf("offRouteDistanceMeters" to perpendicularDistanceMeters),
734
+ )
735
+ }
736
+ }
737
+
738
+ activity.runOnUiThread {
739
+ // 1) Verify ACCESS_FINE_LOCATION is granted to this process. The
740
+ // Google Nav SDK rejects getNavigator() with LOCATION_PERMISSION_MISSING
741
+ // even if location was granted in another module's context — it
742
+ // requires PackageManager.PERMISSION_GRANTED at call-time.
743
+ val granted = androidx.core.content.ContextCompat.checkSelfPermission(
744
+ activity,
745
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
746
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
747
+
748
+ if (!granted) {
749
+ // Request and abort this attempt. User taps Start again after granting.
750
+ androidx.core.app.ActivityCompat.requestPermissions(
751
+ activity,
752
+ arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
753
+ 9001,
754
+ )
755
+ sendEvent(
756
+ "onNavError",
757
+ mapOf("message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again"),
758
+ )
759
+ return@runOnUiThread
760
+ }
761
+
762
+ // 2) NavigationManager owns the T&C flow — it shows the dialog
763
+ // only on the very first run (persisted), and passes
764
+ // `SKIPPED` to `getNavigator` afterward to suppress the
765
+ // "Welcome to Google Maps" toast.
766
+ NavigationManager.start(
767
+ activity,
768
+ NavigationManager.StartOptions(
769
+ stops = stops,
770
+ mode = mode,
771
+ avoidHighways = avoidHighways,
772
+ avoidTolls = avoidTolls,
773
+ avoidFerries = avoidFerries,
774
+ simulate = simulate,
775
+ speedMultiplier = speed,
776
+ ),
777
+ callbacks,
778
+ )
779
+ }
780
+ mapOf("ok" to true)
781
+ }
782
+
783
+ // Eager T&C dialog. Lets a miniapp surface the Google Nav SDK terms
784
+ // dialog at mount time so the actual startNavigation() call later is
785
+ // friction-free. Idempotent — resolves immediately when the user has
786
+ // already accepted (in-process flag, on-disk pref, or SDK state).
787
+ AsyncFunction("requestNavigationPermission") { promise: expo.modules.kotlin.Promise ->
788
+ val activity = appContext.currentActivity
789
+ if (activity == null) {
790
+ promise.resolve(mapOf("ok" to false, "accepted" to false, "error" to "no current activity"))
791
+ return@AsyncFunction
792
+ }
793
+ activity.runOnUiThread {
794
+ NavigationManager.ensureTermsAccepted(activity) { accepted ->
795
+ promise.resolve(mapOf("ok" to true, "accepted" to accepted))
796
+ }
797
+ }
798
+ }
799
+
800
+ // Dev-only: clear the cached "terms accepted" flags (SDK + on-disk
801
+ // pref + in-process) so the next requestNavigationPermission() call
802
+ // re-shows the dialog. Used by the dev-settings re-trigger button.
803
+ AsyncFunction("resetNavigationPermission") { promise: expo.modules.kotlin.Promise ->
804
+ val activity = appContext.currentActivity
805
+ if (activity == null) {
806
+ promise.resolve(mapOf("ok" to false, "error" to "no current activity"))
807
+ return@AsyncFunction
808
+ }
809
+ activity.runOnUiThread {
810
+ try {
811
+ NavigationManager.resetTermsAccepted(activity)
812
+ promise.resolve(mapOf("ok" to true))
813
+ } catch (e: Exception) {
814
+ android.util.Log.e("CrustModule", "resetNavigationPermission failed", e)
815
+ promise.resolve(mapOf("ok" to false, "error" to (e.message ?: "reset failed")))
816
+ }
817
+ }
818
+ }
819
+
820
+ AsyncFunction("stopNavigation") {
821
+ try {
822
+ NavigationManager.stop()
823
+ mapOf("ok" to true)
824
+ } catch (e: Exception) {
825
+ android.util.Log.e("CrustModule", "stopNavigation failed", e)
826
+ mapOf("ok" to false, "error" to (e.message ?: "stop failed"))
827
+ }
828
+ }
829
+
830
+ // Dev-only: nudge the simulated position ~offsetMeters off-route to
831
+ // exercise the Nav SDK's onRerouting() pipeline without having to
832
+ // physically walk off the planned path. No-op on real GPS fixes.
833
+ AsyncFunction("simulateDeviation") { offsetMeters: Double? ->
834
+ try {
835
+ NavigationManager.simulateDeviation(offsetMeters ?: 20.0)
836
+ mapOf("ok" to true)
837
+ } catch (e: Exception) {
838
+ android.util.Log.e("CrustModule", "simulateDeviation failed", e)
839
+ mapOf("ok" to false, "error" to (e.message ?: "deviate failed"))
840
+ }
841
+ }
842
+
843
+ AsyncFunction("setWrongSidewalkOffset") { enabled: Boolean ->
844
+ try {
845
+ NavigationManager.setWrongSidewalkOffset(enabled)
846
+ mapOf("ok" to true)
847
+ } catch (e: Exception) {
848
+ android.util.Log.e("CrustModule", "setWrongSidewalkOffset failed", e)
849
+ mapOf("ok" to false, "error" to (e.message ?: "setWrongSidewalkOffset failed"))
850
+ }
851
+ }
852
+
853
+ AsyncFunction("setSkipCrossings") { enabled: Boolean ->
854
+ try {
855
+ NavigationManager.setSkipCrossings(enabled)
856
+ mapOf("ok" to true)
857
+ } catch (e: Exception) {
858
+ android.util.Log.e("CrustModule", "setSkipCrossings failed", e)
859
+ mapOf("ok" to false, "error" to (e.message ?: "setSkipCrossings failed"))
860
+ }
861
+ }
862
+
863
+ // MARK: - Heading (compass) — Android only
864
+
865
+ AsyncFunction("startHeading") {
866
+ val ctx = appContext.reactContext
867
+ ?: appContext.currentActivity
868
+ ?: return@AsyncFunction mapOf("ok" to false, "error" to "no context")
869
+ HeadingManager.start(ctx, object : HeadingManager.Callback {
870
+ override fun onHeading(degrees: Float) {
871
+ sendEvent("onHeading", mapOf("degrees" to degrees.toDouble()))
872
+ }
873
+ })
874
+ mapOf("ok" to true)
875
+ }
876
+
877
+ AsyncFunction("stopHeading") {
878
+ HeadingManager.stop()
879
+ mapOf("ok" to true)
880
+ }
881
+ }
882
+ }