@glassly/crust 0.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +33 -0
  6. package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
  7. package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
  13. package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
  14. package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
  15. package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
  16. package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/app.plugin.js +3 -0
  19. package/build/Crust.types.d.ts +153 -0
  20. package/build/Crust.types.d.ts.map +1 -0
  21. package/build/Crust.types.js +2 -0
  22. package/build/Crust.types.js.map +1 -0
  23. package/build/CrustModule.d.ts +186 -0
  24. package/build/CrustModule.d.ts.map +1 -0
  25. package/build/CrustModule.js +4 -0
  26. package/build/CrustModule.js.map +1 -0
  27. package/build/CrustModule.web.d.ts +39 -0
  28. package/build/CrustModule.web.d.ts.map +1 -0
  29. package/build/CrustModule.web.js +69 -0
  30. package/build/CrustModule.web.js.map +1 -0
  31. package/build/CrustView.d.ts +4 -0
  32. package/build/CrustView.d.ts.map +1 -0
  33. package/build/CrustView.js +8 -0
  34. package/build/CrustView.js.map +1 -0
  35. package/build/CrustView.web.d.ts +3 -0
  36. package/build/CrustView.web.d.ts.map +1 -0
  37. package/build/CrustView.web.js +5 -0
  38. package/build/CrustView.web.js.map +1 -0
  39. package/build/index.d.ts +4 -0
  40. package/build/index.d.ts.map +1 -0
  41. package/build/index.js +6 -0
  42. package/build/index.js.map +1 -0
  43. package/expo-module.config.json +9 -0
  44. package/ios/Crust.podspec +65 -0
  45. package/ios/CrustModule.swift +784 -0
  46. package/ios/CrustView.swift +38 -0
  47. package/ios/Resources/startup.js +804 -0
  48. package/ios/Source/JSCDispatcher.swift +226 -0
  49. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  50. package/ios/Source/JSCRuntime.swift +674 -0
  51. package/ios/Source/utils/ImageProcessor.swift +392 -0
  52. package/ios/Source/utils/SystemGestures.swift +53 -0
  53. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  54. package/ios/heading/HeadingManager.swift +74 -0
  55. package/ios/navigation/NavPayloads.swift +62 -0
  56. package/ios/navigation/NavigationManager.swift +751 -0
  57. package/package.json +69 -0
  58. package/plugin/build/index.d.ts +21 -0
  59. package/plugin/build/index.js +25 -0
  60. package/plugin/build/withAndroid.d.ts +2 -0
  61. package/plugin/build/withAndroid.js +133 -0
  62. package/src/Crust.types.ts +162 -0
  63. package/src/CrustModule.ts +194 -0
  64. package/src/CrustModule.web.ts +72 -0
  65. package/src/CrustView.tsx +10 -0
  66. package/src/CrustView.web.tsx +9 -0
  67. package/src/index.ts +5 -0
@@ -0,0 +1,1074 @@
1
+ package com.glassly.crust
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.util.Log
5
+ import com.glassly.crust.services.NotificationListener
6
+ import com.glassly.crust.services.NotificationProcessBridge
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+ import java.net.URL
10
+
11
+ import com.glassly.crust.navigation.NavigationManager
12
+ import com.glassly.crust.heading.HeadingManager
13
+ import com.glassly.crust.jsc.JSCRuntime
14
+ import com.glassly.crust.jsc.InstalledMiniappManifest
15
+ import com.glassly.crust.jsc.JSCPolyfillBridge
16
+
17
+ class CrustModule : Module() {
18
+ companion object {
19
+ private const val TAG = "CrustModule"
20
+
21
+ @Volatile private var eventEmitter: ((String, Map<String, Any>) -> Unit)? = null
22
+
23
+ fun emitPhoneNotification(
24
+ context: android.content.Context,
25
+ notificationKey: String,
26
+ packageName: String,
27
+ appName: String,
28
+ title: String,
29
+ text: String,
30
+ timestamp: Long,
31
+ priority: Int,
32
+ ) {
33
+ NotificationProcessBridge.emitPosted(
34
+ context,
35
+ notificationKey,
36
+ packageName,
37
+ appName,
38
+ title,
39
+ text,
40
+ timestamp,
41
+ priority,
42
+ )
43
+ }
44
+
45
+ fun emitPhoneNotificationDismissed(
46
+ context: android.content.Context,
47
+ notificationKey: String,
48
+ packageName: String,
49
+ ) {
50
+ NotificationProcessBridge.emitDismissed(context, notificationKey, packageName)
51
+ }
52
+
53
+ fun emitCaptionsTesterIncident(data: Map<String, Any>) {
54
+ emitEvent("captions_tester_incident", data)
55
+ }
56
+
57
+ private fun emitEvent(eventName: String, data: Map<String, Any>) {
58
+ val emitter = eventEmitter
59
+ if (emitter == null) {
60
+ Log.w(TAG, "Cannot emit $eventName: event emitter is not available")
61
+ return
62
+ }
63
+
64
+ try {
65
+ emitter.invoke(eventName, data)
66
+ } catch (e: Exception) {
67
+ Log.e(TAG, "Error emitting $eventName event", e)
68
+ }
69
+ }
70
+ }
71
+
72
+ // Whether the JSCRuntime's onOutbound sink + polyfill bridge have been wired
73
+ // up. We try in OnCreate, but `appContext.reactContext` is often null that
74
+ // early — in that case we re-attempt the install on the first AsyncFunction
75
+ // call that can hand us a real context. Without this every host-bound
76
+ // __dispatch from a per-miniapp QuickJS context (SUBSCRIBE, mic, location,
77
+ // display, send, etc.) would be silently dropped on Android.
78
+ @Volatile private var runtimeInstalled: Boolean = false
79
+ private var notificationEventReceiver: BroadcastReceiver? = null
80
+ private var notificationBridgeContext: android.content.Context? = null
81
+
82
+ private fun registerNotificationBridgeIfPossible(): Boolean {
83
+ if (notificationEventReceiver != null) return true
84
+ val context = appContext.reactContext ?: appContext.currentActivity ?: return false
85
+ val applicationContext = context.applicationContext
86
+ notificationEventReceiver =
87
+ NotificationProcessBridge.register(applicationContext) { eventName, data ->
88
+ emitEvent(eventName, data)
89
+ }
90
+ // Keep the exact long-lived context used to register the receiver. Expo may
91
+ // clear reactContext/currentActivity before OnDestroy, but Android still
92
+ // requires this receiver to be unregistered when the module is recreated.
93
+ notificationBridgeContext = applicationContext
94
+ return true
95
+ }
96
+
97
+ private fun installRuntimeIfPossible(reason: String): Boolean {
98
+ if (runtimeInstalled) return true
99
+ val ctx = appContext.reactContext ?: appContext.currentActivity
100
+ if (ctx == null) {
101
+ Log.w(TAG, "GlasslyJS: install deferred ($reason) — no context yet")
102
+ return false
103
+ }
104
+ val runtime = JSCRuntime.shared(ctx)
105
+ runtime.onOutbound = { msg ->
106
+ sendEvent("glasslyjs_message", msg.payload)
107
+ }
108
+ JSCPolyfillBridge.install(runtime)
109
+ runtimeInstalled = true
110
+ Log.i(TAG, "GlasslyJS: runtime installed ($reason)")
111
+ return true
112
+ }
113
+
114
+ override fun definition() = ModuleDefinition {
115
+ Name("Crust")
116
+
117
+ Constant("PI") { Math.PI }
118
+
119
+ Events(
120
+ "onChange",
121
+ "phone_notification",
122
+ "phone_notification_dismissed",
123
+ "captions_tester_incident",
124
+ "onNavManeuver",
125
+ "onNavRerouting",
126
+ "onNavArrived",
127
+ "onNavError",
128
+ "onNavLocation",
129
+ "onNavRoute",
130
+ "onNavOffRoute",
131
+ "onHeading",
132
+ // GlasslyJS — per-miniapp JSContext outbound message bus.
133
+ // JSRouter subscribes via Crust.addListener.
134
+ "glasslyjs_message",
135
+ )
136
+
137
+ OnCreate {
138
+ eventEmitter = { eventName, data -> sendEvent(eventName, data) }
139
+ registerNotificationBridgeIfPossible()
140
+ installRuntimeIfPossible("OnCreate")
141
+ }
142
+
143
+ OnDestroy {
144
+ val context = notificationBridgeContext
145
+ val receiver = notificationEventReceiver
146
+ if (context != null && receiver != null) {
147
+ NotificationProcessBridge.unregister(context, receiver)
148
+ }
149
+ notificationEventReceiver = null
150
+ notificationBridgeContext = null
151
+ eventEmitter = null
152
+ }
153
+
154
+ Function("hello") {
155
+ "Hello world! 👋"
156
+ }
157
+
158
+ AsyncFunction("setValueAsync") { value: String ->
159
+ sendEvent("onChange", mapOf("value" to value))
160
+ }
161
+
162
+ AsyncFunction("nativeHttpRequest") {
163
+ method: String, url: String, headers: Map<String, String>, body: String? ->
164
+ val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
165
+ mapOf(
166
+ "status" to result.status,
167
+ "statusText" to result.statusText,
168
+ "headers" to result.headers,
169
+ "body" to result.body,
170
+ )
171
+ }
172
+
173
+ Function("showAVRoutePicker") { _: String? ->
174
+ // iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
175
+ }
176
+
177
+ AsyncFunction("setDeferredSystemGestures") { _: List<String> ->
178
+ // iOS-only: maps to preferredScreenEdgesDeferringSystemGestures.
179
+ // Android has no equivalent — system gestures are user-configurable
180
+ // at the OS level, not per-app.
181
+ }
182
+
183
+ // MARK: - Glassly Notification Commands
184
+
185
+ AsyncFunction("setNotificationConfig") {
186
+ listenerEnabled: Boolean, blocklist: List<String> ->
187
+ registerNotificationBridgeIfPossible()
188
+ val context =
189
+ appContext.reactContext
190
+ ?: appContext.currentActivity
191
+ ?: throw IllegalStateException("No context available")
192
+ NotificationListener.setNotificationConfig(
193
+ context,
194
+ listenerEnabled,
195
+ blocklist,
196
+ )
197
+ }
198
+
199
+ AsyncFunction("getInstalledApps") {
200
+ val context =
201
+ appContext.reactContext
202
+ ?: appContext.currentActivity
203
+ ?: throw IllegalStateException("No context available")
204
+ NotificationListener.getInstalledApps(context)
205
+ }
206
+
207
+ AsyncFunction("getInstalledAppsForNotifications") {
208
+ val context =
209
+ appContext.reactContext
210
+ ?: appContext.currentActivity
211
+ ?: throw IllegalStateException("No context available")
212
+ NotificationListener.getInstalledApps(context)
213
+ }
214
+
215
+ AsyncFunction("hasNotificationListenerPermission") {
216
+ val context =
217
+ appContext.reactContext
218
+ ?: appContext.currentActivity
219
+ ?: throw IllegalStateException("No context available")
220
+ NotificationListener.hasNotificationListenerPermission(context)
221
+ }
222
+
223
+ AsyncFunction("refreshNotificationListener") {
224
+ val context =
225
+ appContext.reactContext
226
+ ?: appContext.currentActivity
227
+ ?: throw IllegalStateException("No context available")
228
+ NotificationListener.refreshComponentForPermission(context)
229
+ }
230
+
231
+ AsyncFunction("openNotificationListenerSettings") {
232
+ val context =
233
+ appContext.reactContext
234
+ ?: appContext.currentActivity
235
+ ?: throw IllegalStateException("No context available")
236
+ NotificationListener.openNotificationListenerSettings(context)
237
+ true
238
+ }
239
+
240
+ // MARK: - GlasslyJS Runtime
241
+
242
+ AsyncFunction("jsSpawn") { packageName: String, polyfillBundle: String, miniappJs: String ->
243
+ val ctx =
244
+ appContext.reactContext
245
+ ?: appContext.currentActivity
246
+ ?: throw IllegalStateException("GlasslyJS: no context")
247
+ // Safety net for the OnCreate-too-early race: if the runtime wasn't
248
+ // wired during module creation (no reactContext yet), do it now —
249
+ // otherwise every host-bound __dispatch from this miniapp's JSContext
250
+ // would have a null onOutbound sink and silently drop frames.
251
+ installRuntimeIfPossible("jsSpawn")
252
+ JSCRuntime.shared(ctx).spawn(
253
+ packageName = packageName,
254
+ polyfillBundleOverride = polyfillBundle.takeIf { it.isNotEmpty() },
255
+ miniappJs = miniappJs,
256
+ )
257
+ }
258
+
259
+ AsyncFunction("jsEvaluate") { packageName: String, source: String ->
260
+ val ctx =
261
+ appContext.reactContext
262
+ ?: appContext.currentActivity
263
+ ?: throw IllegalStateException("GlasslyJS: no context")
264
+ JSCRuntime.shared(ctx).evaluate(packageName, source)
265
+ }
266
+
267
+ AsyncFunction("jsKill") { packageName: String ->
268
+ val ctx =
269
+ appContext.reactContext
270
+ ?: appContext.currentActivity
271
+ ?: throw IllegalStateException("GlasslyJS: no context")
272
+ JSCRuntime.shared(ctx).kill(packageName)
273
+ }
274
+
275
+ AsyncFunction("jsDispatchToJs") { packageName: String, envelope: Map<String, Any?> ->
276
+ val ctx =
277
+ appContext.reactContext
278
+ ?: appContext.currentActivity
279
+ ?: throw IllegalStateException("GlasslyJS: no context")
280
+ val json = org.json.JSONObject(envelope as Map<*, *>).toString()
281
+ JSCRuntime.shared(ctx).dispatchToJs(packageName, json)
282
+ }
283
+
284
+ AsyncFunction("jsSetManifest") { packageName: String, permissions: List<String> ->
285
+ val ctx =
286
+ appContext.reactContext
287
+ ?: appContext.currentActivity
288
+ ?: throw IllegalStateException("GlasslyJS: no context")
289
+ JSCRuntime.shared(ctx).dispatcher.setManifest(
290
+ packageName,
291
+ InstalledMiniappManifest(permissions.toSet()),
292
+ )
293
+ }
294
+
295
+ Function("jsAlivePackages") {
296
+ val ctx =
297
+ appContext.reactContext
298
+ ?: appContext.currentActivity
299
+ if (ctx == null) {
300
+ return@Function emptyList<String>()
301
+ }
302
+ JSCRuntime.shared(ctx).alivePackages()
303
+ }
304
+
305
+ AsyncFunction("jsDebugForceGC") { packageName: String ->
306
+ val ctx =
307
+ appContext.reactContext
308
+ ?: appContext.currentActivity
309
+ ?: return@AsyncFunction false
310
+ JSCRuntime.shared(ctx).debugForceGC(packageName)
311
+ }
312
+
313
+ Function("jsLoadPolyfillBundle") {
314
+ val ctx =
315
+ appContext.reactContext
316
+ ?: appContext.currentActivity
317
+ if (ctx == null) {
318
+ return@Function ""
319
+ }
320
+ JSCRuntime.shared(ctx).loadPolyfillBundle()
321
+ }
322
+
323
+ // MARK: - Build Environment
324
+
325
+ AsyncFunction("isBetaBuild") { false }
326
+
327
+ View(CrustView::class) {
328
+ Prop("url") { view: CrustView, url: URL -> view.webView.loadUrl(url.toString()) }
329
+ Events("onLoad")
330
+ }
331
+
332
+ // MARK: - Settings Navigation
333
+
334
+ AsyncFunction("openBluetoothSettings") {
335
+ val context =
336
+ appContext.reactContext
337
+ ?: appContext.currentActivity
338
+ ?: throw IllegalStateException("No context available")
339
+ val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
340
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
341
+ context.startActivity(intent)
342
+ true
343
+ }
344
+
345
+ // MARK: - Location Services Commands
346
+
347
+ // Check if location services are enabled (required for WiFi operations on Android)
348
+ AsyncFunction("isLocationServicesEnabled") {
349
+ val context =
350
+ appContext.reactContext
351
+ ?: appContext.currentActivity
352
+ ?: throw IllegalStateException("No context available")
353
+ val locationManager =
354
+ context.getSystemService(android.content.Context.LOCATION_SERVICE) as
355
+ android.location.LocationManager
356
+ // Check if either GPS or Network location provider is enabled
357
+ val providerEnabled =
358
+ locationManager.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) ||
359
+ locationManager.isProviderEnabled(
360
+ android.location.LocationManager.NETWORK_PROVIDER
361
+ )
362
+ if (!providerEnabled) {
363
+ // Fallback: check the system-level location toggle directly.
364
+ // GPS_PROVIDER/NETWORK_PROVIDER can report disabled on devices without
365
+ // Google Play Services or without a GPS chip, even when location is toggled on.
366
+ // isLocationEnabled requires API 28+; on older devices just trust the provider check.
367
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
368
+ val systemEnabled = locationManager.isLocationEnabled
369
+ if (systemEnabled) {
370
+ android.util.Log.w(
371
+ "CoreModule",
372
+ "Location providers (GPS/Network) report disabled but system location toggle is ON. Device may lack GMS or GPS hardware."
373
+ )
374
+ }
375
+ systemEnabled
376
+ } else {
377
+ false
378
+ }
379
+ } else {
380
+ true
381
+ }
382
+ }
383
+
384
+ AsyncFunction("openLocationSettings") {
385
+ val context =
386
+ appContext.reactContext
387
+ ?: appContext.currentActivity
388
+ ?: throw IllegalStateException("No context available")
389
+ val intent = android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
390
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
391
+ context.startActivity(intent)
392
+ true
393
+ }
394
+
395
+ AsyncFunction("openAppSettings") {
396
+ val context =
397
+ appContext.reactContext
398
+ ?: appContext.currentActivity
399
+ ?: throw IllegalStateException("No context available")
400
+ val intent =
401
+ android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
402
+ intent.data = android.net.Uri.parse("package:${context.packageName}")
403
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
404
+ context.startActivity(intent)
405
+ true
406
+ }
407
+
408
+ AsyncFunction("openBluetoothSettings") {
409
+ val context =
410
+ appContext.reactContext
411
+ ?: appContext.currentActivity
412
+ ?: throw IllegalStateException("No context available")
413
+ val intent = android.content.Intent(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS)
414
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
415
+ context.startActivity(intent)
416
+ true
417
+ }
418
+
419
+ AsyncFunction("showLocationServicesDialog") {
420
+ val activity = appContext.currentActivity
421
+ if (activity == null) {
422
+ val context = appContext.reactContext ?: throw IllegalStateException("No context available")
423
+ val intent =
424
+ android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
425
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
426
+ context.startActivity(intent)
427
+ return@AsyncFunction true
428
+ }
429
+
430
+ val locationRequest =
431
+ com.google.android.gms.location.LocationRequest.Builder(
432
+ com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY,
433
+ 10000
434
+ )
435
+ .build()
436
+
437
+ val builder =
438
+ com.google.android.gms.location.LocationSettingsRequest.Builder()
439
+ .addLocationRequest(locationRequest)
440
+ .setAlwaysShow(true)
441
+
442
+ val client = com.google.android.gms.location.LocationServices.getSettingsClient(activity)
443
+ val task = client.checkLocationSettings(builder.build())
444
+
445
+ task.addOnSuccessListener { true }
446
+ task.addOnFailureListener { exception ->
447
+ if (exception is com.google.android.gms.common.api.ResolvableApiException) {
448
+ try {
449
+ exception.startResolutionForResult(activity, 1001)
450
+ } catch (sendEx: android.content.IntentSender.SendIntentException) {
451
+ // Fallback
452
+ val intent =
453
+ android.content.Intent(
454
+ android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS
455
+ )
456
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
457
+ activity.startActivity(intent)
458
+ }
459
+ } else {
460
+ val intent =
461
+ android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)
462
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
463
+ activity.startActivity(intent)
464
+ }
465
+ }
466
+ true
467
+ }
468
+
469
+ // MARK: - Image Processing Commands
470
+
471
+ AsyncFunction("processGalleryImage") {
472
+ inputPath: String,
473
+ outputPath: String,
474
+ options: Map<String, Any?> ->
475
+ try {
476
+ val inputFile = java.io.File(inputPath)
477
+ if (!inputFile.exists()) {
478
+ throw IllegalArgumentException("Input file does not exist: $inputPath")
479
+ }
480
+
481
+ val lensCorrection = options["lensCorrection"] as? Boolean ?: true
482
+ val colorCorrection = options["colorCorrection"] as? Boolean ?: true
483
+
484
+ val processingTimeMs =
485
+ com.glassly.crust.utils.ImageProcessor.process(
486
+ inputPath,
487
+ outputPath,
488
+ lensCorrection,
489
+ colorCorrection,
490
+ 95
491
+ )
492
+
493
+ if (processingTimeMs >= 0) {
494
+ mapOf(
495
+ "success" to true,
496
+ "outputPath" to outputPath,
497
+ "processingTimeMs" to processingTimeMs
498
+ )
499
+ } else {
500
+ mapOf("success" to false, "error" to "Processing failed")
501
+ }
502
+ } catch (e: Exception) {
503
+ android.util.Log.e("CrustModule", "processGalleryImage error: ${e.message}", e)
504
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
505
+ }
506
+ }
507
+
508
+ // MARK: - HDR Merge Commands
509
+
510
+ AsyncFunction("mergeHdrBrackets") {
511
+ underPath: String,
512
+ normalPath: String,
513
+ overPath: String,
514
+ outputPath: String ->
515
+ try {
516
+ val processingTimeMs =
517
+ com.glassly.crust.utils.ImageProcessor.mergeHdr(
518
+ underPath,
519
+ normalPath,
520
+ overPath,
521
+ outputPath,
522
+ 95
523
+ )
524
+ if (processingTimeMs >= 0) {
525
+ mapOf(
526
+ "success" to true,
527
+ "outputPath" to outputPath,
528
+ "processingTimeMs" to processingTimeMs
529
+ )
530
+ } else {
531
+ mapOf("success" to false, "error" to "HDR merge failed")
532
+ }
533
+ } catch (e: Exception) {
534
+ android.util.Log.e("CrustModule", "mergeHdrBrackets error: ${e.message}", e)
535
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
536
+ }
537
+ }
538
+
539
+ // MARK: - Video Stabilization Commands
540
+
541
+ AsyncFunction("stabilizeVideo") { inputPath: String, imuPath: String, outputPath: String ->
542
+ try {
543
+ val inputFile = java.io.File(inputPath)
544
+ val imuFile = java.io.File(imuPath)
545
+ if (!inputFile.exists()) {
546
+ throw IllegalArgumentException("Input video does not exist: $inputPath")
547
+ }
548
+ if (!imuFile.exists()) {
549
+ throw IllegalArgumentException("IMU sidecar does not exist: $imuPath")
550
+ }
551
+
552
+ val processingTimeMs =
553
+ com.glassly.crust.utils.VideoStabilizer.stabilize(inputPath, imuPath, outputPath)
554
+
555
+ if (processingTimeMs >= 0) {
556
+ mapOf(
557
+ "success" to true,
558
+ "outputPath" to outputPath,
559
+ "processingTimeMs" to processingTimeMs
560
+ )
561
+ } else {
562
+ mapOf("success" to false, "error" to "Stabilization failed")
563
+ }
564
+ } catch (e: Exception) {
565
+ android.util.Log.e("CrustModule", "stabilizeVideo error: ${e.message}", e)
566
+ mapOf("success" to false, "error" to (e.message ?: "Unknown error"))
567
+ }
568
+ }
569
+
570
+ // MARK: - Media Library Commands
571
+
572
+ AsyncFunction("saveToGalleryWithDate") {
573
+ filePath: String,
574
+ captureTimeMillis: Long?,
575
+ displayName: String?
576
+ ->
577
+ val context =
578
+ appContext.reactContext
579
+ ?: appContext.currentActivity
580
+ ?: throw IllegalStateException("No context available")
581
+
582
+ try {
583
+ val file = java.io.File(filePath)
584
+ if (!file.exists()) {
585
+ throw IllegalArgumentException("File does not exist: $filePath")
586
+ }
587
+
588
+ val mimeType =
589
+ when (file.extension.lowercase()) {
590
+ "jpg", "jpeg" -> "image/jpeg"
591
+ "png" -> "image/png"
592
+ "mp4" -> "video/mp4"
593
+ "mov" -> "video/quicktime"
594
+ else -> "application/octet-stream"
595
+ }
596
+
597
+ val isVideo = mimeType.startsWith("video/")
598
+ val collection =
599
+ if (isVideo) {
600
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
601
+ android.provider.MediaStore.Video.Media.getContentUri(
602
+ android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
603
+ )
604
+ } else {
605
+ android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI
606
+ }
607
+ } else {
608
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
609
+ android.provider.MediaStore.Images.Media.getContentUri(
610
+ android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY
611
+ )
612
+ } else {
613
+ android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
614
+ }
615
+ }
616
+
617
+ val mediaDisplayName =
618
+ displayName?.takeIf { it.isNotBlank() }
619
+ ?: run {
620
+ val parentName = file.parentFile?.name
621
+ if (parentName != null &&
622
+ (parentName.startsWith("IMG_") ||
623
+ parentName.startsWith("VID_")) &&
624
+ (file.name == "base.jpg" || file.name == "base.mp4")
625
+ ) {
626
+ val ext = if (mimeType.startsWith("video/")) "mp4" else "jpg"
627
+ "$parentName.$ext"
628
+ } else {
629
+ file.name
630
+ }
631
+ }
632
+
633
+ val relativePath =
634
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
635
+ if (isVideo) "Movies/Glassly" else "Pictures/Glassly"
636
+ } else {
637
+ null
638
+ }
639
+ val resolver = context.contentResolver
640
+ val stableDisplayName =
641
+ mediaDisplayName.takeIf {
642
+ it.startsWith("IMG_") || it.startsWith("VID_")
643
+ }
644
+ val existingUri =
645
+ stableDisplayName?.let {
646
+ findExistingGalleryAsset(
647
+ resolver,
648
+ collection,
649
+ listOf(it),
650
+ file.length(),
651
+ captureTimeMillis,
652
+ relativePath,
653
+ context.packageName,
654
+ requireUniqueMatch = false,
655
+ )
656
+ }
657
+ ?: findExistingGalleryAsset(
658
+ resolver,
659
+ collection,
660
+ listOfNotNull(
661
+ mediaDisplayName.takeIf { stableDisplayName == null },
662
+ file.name.takeIf {
663
+ it.isNotBlank() && it != stableDisplayName
664
+ },
665
+ )
666
+ .distinct(),
667
+ file.length(),
668
+ captureTimeMillis,
669
+ relativePath,
670
+ context.packageName,
671
+ requireUniqueMatch = true,
672
+ )
673
+ if (existingUri != null) {
674
+ android.util.Log.d("CrustModule", "Reusing existing gallery asset")
675
+ return@AsyncFunction mapOf(
676
+ "success" to true,
677
+ "uri" to existingUri.toString(),
678
+ "existing" to true,
679
+ )
680
+ }
681
+
682
+ val values =
683
+ android.content.ContentValues().apply {
684
+ put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
685
+ put(android.provider.MediaStore.MediaColumns.MIME_TYPE, mimeType)
686
+ put(android.provider.MediaStore.MediaColumns.SIZE, file.length())
687
+
688
+ if (captureTimeMillis != null) {
689
+ if (isVideo) {
690
+ put(android.provider.MediaStore.Video.Media.DATE_TAKEN, captureTimeMillis)
691
+ } else {
692
+ put(android.provider.MediaStore.Images.Media.DATE_TAKEN, captureTimeMillis)
693
+ }
694
+ android.util.Log.d(
695
+ "CrustModule",
696
+ "Setting DATE_TAKEN to: $captureTimeMillis (${java.util.Date(captureTimeMillis)})"
697
+ )
698
+ }
699
+
700
+ if (relativePath != null) {
701
+ put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
702
+ put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
703
+ }
704
+ }
705
+
706
+ val uri =
707
+ resolver.insert(collection, values)
708
+ ?: throw IllegalStateException("Failed to create MediaStore entry")
709
+
710
+ try {
711
+ resolver.openOutputStream(uri)?.use { outputStream ->
712
+ file.inputStream().use { inputStream -> inputStream.copyTo(outputStream) }
713
+ }
714
+ ?: throw IllegalStateException("Failed to open output stream")
715
+
716
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
717
+ values.clear()
718
+ values.put(android.provider.MediaStore.MediaColumns.IS_PENDING, 0)
719
+ resolver.update(uri, values, null, null)
720
+ }
721
+
722
+ android.util.Log.d(
723
+ "CrustModule",
724
+ "Successfully saved to gallery with proper DATE_TAKEN: $mediaDisplayName"
725
+ )
726
+ mapOf("success" to true, "uri" to uri.toString())
727
+ } catch (e: Exception) {
728
+ resolver.delete(uri, null, null)
729
+ throw e
730
+ }
731
+ } catch (e: Exception) {
732
+ android.util.Log.e("CrustModule", "Error saving to gallery: ${e.message}", e)
733
+ mapOf("success" to false, "error" to e.message)
734
+ }
735
+ }
736
+
737
+ // MARK: - Navigation Commands (Mapbox Navigation SDK)
738
+
739
+ // Runtime Mapbox token override (user key from Settings > Keys). Non-empty
740
+ // wins over the manifest meta-data token; "" restores the baked token.
741
+ AsyncFunction("setMapboxAccessToken") { token: String ->
742
+ NavigationManager.setAccessTokenOverride(token, appContext.reactContext)
743
+ mapOf("ok" to true)
744
+ }
745
+
746
+ AsyncFunction("startNavigation") { lat: Double, lng: Double, options: Map<String, Any?>? ->
747
+ val activity = appContext.currentActivity
748
+ ?: return@AsyncFunction mapOf("ok" to false, "error" to "no current activity (app backgrounded?)")
749
+ val simulate = (options?.get("simulate") as? Boolean) ?: false
750
+ val speed = (options?.get("speedMultiplier") as? Number)?.toFloat() ?: 1f
751
+ val mode = (options?.get("mode") as? String) ?: "driving"
752
+
753
+ // Prefer the v2 `stops` array when present. Fall back to lat/lng for
754
+ // older callers that haven't been upgraded yet.
755
+ val rawStops = options?.get("stops") as? List<*>
756
+ val stops: List<Pair<Double, Double>> = if (rawStops != null && rawStops.isNotEmpty()) {
757
+ rawStops.mapNotNull { item ->
758
+ val map = item as? Map<*, *> ?: return@mapNotNull null
759
+ val sLat = (map["lat"] as? Number)?.toDouble() ?: return@mapNotNull null
760
+ val sLng = (map["lng"] as? Number)?.toDouble() ?: return@mapNotNull null
761
+ sLat to sLng
762
+ }
763
+ } else {
764
+ listOf(lat to lng)
765
+ }
766
+ if (stops.isEmpty()) {
767
+ return@AsyncFunction mapOf("ok" to false, "error" to "no valid stops")
768
+ }
769
+
770
+ val avoidMap = options?.get("avoid") as? Map<*, *>
771
+ val avoidHighways = (avoidMap?.get("highways") as? Boolean) ?: false
772
+ val avoidTolls = (avoidMap?.get("tolls") as? Boolean) ?: false
773
+ val avoidFerries = (avoidMap?.get("ferries") as? Boolean) ?: false
774
+
775
+ val callbacks = object : NavigationManager.Callbacks {
776
+ override fun onManeuver(payload: NavigationManager.ManeuverPayload) {
777
+ sendEvent(
778
+ "onNavManeuver",
779
+ mapOf(
780
+ "maneuverType" to payload.maneuverType,
781
+ "distanceMeters" to payload.distanceMeters,
782
+ "fromRoad" to payload.fromRoad,
783
+ "toRoad" to payload.toRoad,
784
+ "nextStepRoad" to payload.nextStepRoad,
785
+ "distanceToDestinationMeters" to payload.distanceToDestinationMeters,
786
+ "timeToDestinationSeconds" to payload.timeToDestinationSeconds,
787
+ "currentSpeedMps" to payload.currentSpeedMps,
788
+ "speedLimitMps" to payload.speedLimitMps,
789
+ "routeHeadingDeg" to payload.routeHeadingDeg,
790
+ "instruction" to payload.instruction,
791
+ ),
792
+ )
793
+ }
794
+ override fun onRerouting() {
795
+ sendEvent("onNavRerouting", emptyMap<String, Any?>())
796
+ }
797
+ override fun onArrived() {
798
+ sendEvent("onNavArrived", emptyMap<String, Any?>())
799
+ }
800
+ override fun onError(message: String) {
801
+ sendEvent("onNavError", mapOf("message" to message))
802
+ }
803
+ override fun onLocation(payload: NavigationManager.LocationPayload) {
804
+ sendEvent(
805
+ "onNavLocation",
806
+ mapOf(
807
+ "lat" to payload.lat,
808
+ "lng" to payload.lng,
809
+ "accuracy" to payload.accuracy,
810
+ "timestamp" to payload.timestamp,
811
+ ),
812
+ )
813
+ }
814
+ override fun onRoute(
815
+ points: List<NavigationManager.RoutePoint>,
816
+ steps: List<NavigationManager.RouteStep>?,
817
+ ) {
818
+ val payload = HashMap<String, Any?>()
819
+ payload["points"] = points.map { mapOf("lat" to it.lat, "lng" to it.lng) }
820
+ if (steps != null) {
821
+ payload["steps"] = steps.map {
822
+ mapOf(
823
+ "lat" to it.lat,
824
+ "lng" to it.lng,
825
+ "routeIndex" to it.routeIndex,
826
+ "road" to it.road,
827
+ "maneuver" to it.maneuver,
828
+ "distanceMeters" to it.distanceMeters,
829
+ )
830
+ }
831
+ }
832
+ sendEvent("onNavRoute", payload)
833
+ }
834
+ override fun onOffRoute(perpendicularDistanceMeters: Double) {
835
+ sendEvent(
836
+ "onNavOffRoute",
837
+ mapOf("offRouteDistanceMeters" to perpendicularDistanceMeters),
838
+ )
839
+ }
840
+ }
841
+
842
+ activity.runOnUiThread {
843
+ // 1) Verify ACCESS_FINE_LOCATION is granted to this process at
844
+ // call-time (PackageManager.PERMISSION_GRANTED), even if location
845
+ // was granted in another module's context — the nav engine needs
846
+ // a live location source before the trip session starts.
847
+ val granted = androidx.core.content.ContextCompat.checkSelfPermission(
848
+ activity,
849
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
850
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
851
+
852
+ if (!granted) {
853
+ // Request and abort this attempt. User taps Start again after granting.
854
+ androidx.core.app.ActivityCompat.requestPermissions(
855
+ activity,
856
+ arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
857
+ 9001,
858
+ )
859
+ sendEvent(
860
+ "onNavError",
861
+ mapOf("message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again"),
862
+ )
863
+ return@runOnUiThread
864
+ }
865
+
866
+ // 2) NavigationManager owns the T&C flow — it shows the dialog
867
+ // only on the very first run (persisted), and passes
868
+ // `SKIPPED` to `getNavigator` afterward to suppress the
869
+ // "Welcome to Google Maps" toast.
870
+ NavigationManager.start(
871
+ activity,
872
+ NavigationManager.StartOptions(
873
+ stops = stops,
874
+ mode = mode,
875
+ avoidHighways = avoidHighways,
876
+ avoidTolls = avoidTolls,
877
+ avoidFerries = avoidFerries,
878
+ simulate = simulate,
879
+ speedMultiplier = speed,
880
+ ),
881
+ callbacks,
882
+ )
883
+ }
884
+ mapOf("ok" to true)
885
+ }
886
+
887
+ // Eager permission request, kept from the Google-era contract. Under
888
+ // Mapbox there is no T&C dialog (ensureTermsAccepted resolves
889
+ // immediately), but miniapps still call this at mount time so the
890
+ // actual startNavigation() call later is friction-free.
891
+ AsyncFunction("requestNavigationPermission") { promise: expo.modules.kotlin.Promise ->
892
+ val activity = appContext.currentActivity
893
+ if (activity == null) {
894
+ promise.resolve(mapOf("ok" to false, "accepted" to false, "error" to "no current activity"))
895
+ return@AsyncFunction
896
+ }
897
+ activity.runOnUiThread {
898
+ NavigationManager.ensureTermsAccepted(activity) { accepted ->
899
+ promise.resolve(mapOf("ok" to true, "accepted" to accepted))
900
+ }
901
+ }
902
+ }
903
+
904
+
905
+ AsyncFunction("stopNavigation") {
906
+ try {
907
+ NavigationManager.stop()
908
+ mapOf("ok" to true)
909
+ } catch (e: Exception) {
910
+ android.util.Log.e("CrustModule", "stopNavigation failed", e)
911
+ mapOf("ok" to false, "error" to (e.message ?: "stop failed"))
912
+ }
913
+ }
914
+
915
+ // Dev-only: nudge the simulated position ~offsetMeters off-route to
916
+ // exercise the Nav SDK's onRerouting() pipeline without having to
917
+ // physically walk off the planned path. No-op on real GPS fixes.
918
+ AsyncFunction("simulateDeviation") { offsetMeters: Double? ->
919
+ try {
920
+ NavigationManager.simulateDeviation(offsetMeters ?: 20.0)
921
+ mapOf("ok" to true)
922
+ } catch (e: Exception) {
923
+ android.util.Log.e("CrustModule", "simulateDeviation failed", e)
924
+ mapOf("ok" to false, "error" to (e.message ?: "deviate failed"))
925
+ }
926
+ }
927
+
928
+ AsyncFunction("setWrongSidewalkOffset") { enabled: Boolean ->
929
+ try {
930
+ NavigationManager.setWrongSidewalkOffset(enabled)
931
+ mapOf("ok" to true)
932
+ } catch (e: Exception) {
933
+ android.util.Log.e("CrustModule", "setWrongSidewalkOffset failed", e)
934
+ mapOf("ok" to false, "error" to (e.message ?: "setWrongSidewalkOffset failed"))
935
+ }
936
+ }
937
+
938
+ AsyncFunction("setSkipCrossings") { enabled: Boolean ->
939
+ try {
940
+ NavigationManager.setSkipCrossings(enabled)
941
+ mapOf("ok" to true)
942
+ } catch (e: Exception) {
943
+ android.util.Log.e("CrustModule", "setSkipCrossings failed", e)
944
+ mapOf("ok" to false, "error" to (e.message ?: "setSkipCrossings failed"))
945
+ }
946
+ }
947
+
948
+ // MARK: - Heading (compass) — Android only
949
+
950
+ AsyncFunction("startHeading") {
951
+ val ctx = appContext.reactContext
952
+ ?: appContext.currentActivity
953
+ ?: return@AsyncFunction mapOf("ok" to false, "error" to "no context")
954
+ HeadingManager.start(ctx, object : HeadingManager.Callback {
955
+ override fun onHeading(degrees: Float) {
956
+ sendEvent("onHeading", mapOf("degrees" to degrees.toDouble()))
957
+ }
958
+ })
959
+ mapOf("ok" to true)
960
+ }
961
+
962
+ AsyncFunction("stopHeading") {
963
+ HeadingManager.stop()
964
+ mapOf("ok" to true)
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Find a completed export from a previous attempt. A crash can happen after MediaStore commits
970
+ * but before JavaScript persists the URI receipt; the stable capture display name, size, and
971
+ * capture time, and Glassly album path let the retry return that receipt instead of inserting a
972
+ * duplicate. Scoping by album is also important before deleting interrupted pending rows: a
973
+ * same-named asset owned by another album must never be treated as ours.
974
+ */
975
+ private fun findExistingGalleryAsset(
976
+ resolver: android.content.ContentResolver,
977
+ collection: android.net.Uri,
978
+ displayNames: List<String>,
979
+ size: Long,
980
+ captureTimeMillis: Long?,
981
+ relativePath: String?,
982
+ ownerPackageName: String,
983
+ requireUniqueMatch: Boolean,
984
+ ): android.net.Uri? {
985
+ val dateColumn = android.provider.MediaStore.Images.ImageColumns.DATE_TAKEN
986
+ if (displayNames.isEmpty()) return null
987
+ val namePlaceholders = displayNames.joinToString(",") { "?" }
988
+ val selectionParts =
989
+ mutableListOf(
990
+ "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} IN ($namePlaceholders)",
991
+ )
992
+ val selectionArgs = displayNames.toMutableList()
993
+ if (captureTimeMillis != null) {
994
+ selectionParts.add("$dateColumn = ?")
995
+ selectionArgs.add(captureTimeMillis.toString())
996
+ }
997
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
998
+ relativePath != null
999
+ ) {
1000
+ val pathWithoutTrailingSlash = relativePath.trimEnd('/')
1001
+ selectionParts.add(
1002
+ "(${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ? OR " +
1003
+ "${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ?)"
1004
+ )
1005
+ // MediaProvider normally canonicalizes RELATIVE_PATH with a trailing slash. Accept the
1006
+ // caller's original representation too so exports created by older Android builds remain
1007
+ // reconcilable, while still requiring an exact Glassly album match.
1008
+ selectionArgs.add(pathWithoutTrailingSlash)
1009
+ selectionArgs.add("$pathWithoutTrailingSlash/")
1010
+ // Never reconcile or delete another application's pending MediaStore row.
1011
+ selectionParts.add("${android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?")
1012
+ selectionArgs.add(ownerPackageName)
1013
+ }
1014
+ val projection =
1015
+ mutableListOf(
1016
+ android.provider.BaseColumns._ID,
1017
+ android.provider.MediaStore.MediaColumns.SIZE,
1018
+ )
1019
+ .apply {
1020
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1021
+ add(android.provider.MediaStore.MediaColumns.IS_PENDING)
1022
+ }
1023
+ }
1024
+
1025
+ return try {
1026
+ resolver
1027
+ .query(
1028
+ collection,
1029
+ projection.toTypedArray(),
1030
+ selectionParts.joinToString(" AND "),
1031
+ selectionArgs.toTypedArray(),
1032
+ "${android.provider.BaseColumns._ID} DESC",
1033
+ )
1034
+ ?.use { cursor ->
1035
+ val candidates = mutableListOf<android.net.Uri>()
1036
+ val idColumn = cursor.getColumnIndexOrThrow(android.provider.BaseColumns._ID)
1037
+ val sizeColumn =
1038
+ cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns.SIZE)
1039
+ val pendingColumn =
1040
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1041
+ cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.IS_PENDING)
1042
+ } else {
1043
+ -1
1044
+ }
1045
+ while (cursor.moveToNext()) {
1046
+ val uri =
1047
+ android.content.ContentUris.withAppendedId(
1048
+ collection,
1049
+ cursor.getLong(idColumn),
1050
+ )
1051
+ if (pendingColumn >= 0 && cursor.getInt(pendingColumn) != 0) {
1052
+ // This app owns the row and it was never published. Remove the interrupted
1053
+ // placeholder before retrying the copy, even if it contains only a prefix.
1054
+ resolver.delete(uri, null, null)
1055
+ continue
1056
+ }
1057
+ if (cursor.getLong(sizeColumn) == size) candidates.add(uri)
1058
+ }
1059
+ if (requireUniqueMatch) {
1060
+ // A legacy `base.jpg`/`base.mp4` match is safe only when the complete
1061
+ // name/date/size/path/owner fingerprint identifies exactly one asset.
1062
+ candidates.singleOrNull()
1063
+ } else {
1064
+ // Capture-derived display names are stable. If an older retry already made
1065
+ // duplicates, reuse the newest completed row instead of creating another.
1066
+ candidates.firstOrNull()
1067
+ }
1068
+ }
1069
+ } catch (error: Exception) {
1070
+ android.util.Log.w("CrustModule", "Unable to reconcile existing gallery asset", error)
1071
+ null
1072
+ }
1073
+ }
1074
+ }