@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.
- package/README.md +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
- package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
- package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +153 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +186 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +39 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +69 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +8 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +3 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +5 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +784 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +804 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +674 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +751 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +21 -0
- package/plugin/build/index.js +25 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +133 -0
- package/src/Crust.types.ts +162 -0
- package/src/CrustModule.ts +194 -0
- package/src/CrustModule.web.ts +72 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +9 -0
- 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
|
+
}
|