craft-native 0.0.90 → 0.0.91

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 (29) hide show
  1. package/dist/android/src/index.js +285 -44
  2. package/dist/android/src/promise-runtime.d.ts +3 -0
  3. package/dist/android/templates/CraftBridge.kt.template +1951 -1193
  4. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  5. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  6. package/dist/android/templates/CraftNative.kt.template +2231 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  8. package/dist/android/templates/MainActivity.kt.template +25 -2
  9. package/dist/android/templates/proguard-rules.pro.template +4 -1
  10. package/dist/android/templates/test-bridges.html +10 -33
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/ios-advanced.d.ts +8 -5
  13. package/dist/api/live-activity-handle.d.ts +6 -0
  14. package/dist/api/mobile.d.ts +13 -5
  15. package/dist/api/window.d.ts +2 -0
  16. package/dist/cli.js +404 -128
  17. package/dist/index.cjs +65 -17
  18. package/dist/index.js +65 -17
  19. package/dist/ios/src/index.js +22 -4
  20. package/dist/ios/templates/CraftApp.swift +473 -60
  21. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  22. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  23. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  24. package/dist/ios/templates/project.yml.template +10 -4
  25. package/dist/mobile.js +36 -13
  26. package/dist/scaffold-version.d.ts +5 -0
  27. package/package.json +1 -1
  28. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  29. package/dist/android/templates/CraftWidgetProvider.kt.template +0 -246
@@ -0,0 +1,2231 @@
1
+ package com.craft.runtime
2
+
3
+ import android.app.Activity
4
+ import com.google.android.gms.location.Priority
5
+ import com.google.android.gms.location.LocationServices
6
+ import com.google.android.gms.location.LocationResult
7
+ import com.google.android.gms.location.LocationRequest
8
+ import com.google.android.gms.location.LocationCallback
9
+ import com.google.android.gms.location.FusedLocationProviderClient
10
+ import com.google.android.gms.common.ConnectionResult
11
+ import com.google.android.gms.common.GoogleApiAvailability
12
+ import android.location.Location
13
+ import android.annotation.SuppressLint
14
+ import android.Manifest
15
+ import android.content.pm.PackageManager
16
+ import android.os.Bundle
17
+ import android.speech.RecognitionListener
18
+ import android.speech.RecognizerIntent
19
+ import android.speech.SpeechRecognizer
20
+ import java.util.Locale
21
+ import android.media.MediaRecorder
22
+ import java.io.File
23
+ import android.graphics.Bitmap
24
+ import android.webkit.WebView
25
+ import java.io.ByteArrayOutputStream
26
+ import com.android.billingclient.api.*
27
+ import android.graphics.BitmapFactory
28
+ import android.util.Base64
29
+ import com.google.mlkit.vision.common.InputImage
30
+ import com.google.mlkit.vision.label.ImageLabeling
31
+ import com.google.mlkit.vision.label.defaults.ImageLabelerOptions
32
+ import com.google.mlkit.vision.objects.ObjectDetection
33
+ import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
34
+ import com.google.mlkit.vision.text.TextRecognition
35
+ import com.google.mlkit.vision.text.latin.TextRecognizerOptions
36
+ import org.json.JSONArray
37
+ import androidx.biometric.BiometricManager
38
+ import androidx.biometric.BiometricPrompt
39
+ import androidx.fragment.app.FragmentActivity
40
+ import android.hardware.SensorManager
41
+ import android.hardware.SensorEventListener
42
+ import android.hardware.SensorEvent
43
+ import android.hardware.Sensor
44
+ import androidx.core.app.ActivityCompat
45
+ import android.os.Build
46
+ import android.bluetooth.le.ScanResult
47
+ import android.bluetooth.le.ScanCallback
48
+ import android.bluetooth.le.BluetoothLeScanner
49
+ import android.bluetooth.BluetoothManager
50
+ import androidx.lifecycle.ProcessLifecycleOwner
51
+ import androidx.lifecycle.LifecycleEventObserver
52
+ import android.net.NetworkRequest
53
+ import android.net.NetworkCapabilities
54
+ import android.net.Network
55
+ import android.net.ConnectivityManager
56
+ import android.content.Context
57
+ import androidx.core.content.ContextCompat
58
+ import com.google.android.play.core.review.ReviewManagerFactory
59
+ import android.content.Intent
60
+ import android.os.Handler
61
+ import android.os.Looper
62
+ import android.content.SharedPreferences
63
+ import android.database.sqlite.SQLiteDatabase
64
+ import org.json.JSONObject
65
+
66
+ /**
67
+ * The Zig runtime's native methods.
68
+ *
69
+ * This class exists for one reason: its package is fixed. `CraftBridge` is
70
+ * generated with a templated package declaration, so its name differs per app,
71
+ * and the prebuilt `libcraft.so` cannot know it — neither JNI's
72
+ * `Java_<package>_...` symbol mangling nor a `FindClass` inside `JNI_OnLoad`
73
+ * can name a class chosen at generation time. Binding to
74
+ * `com.craft.runtime.CraftNative` instead lets one library serve every app
75
+ * unchanged.
76
+ *
77
+ * Nothing in this file is substituted. A template marker appearing here would
78
+ * make the library app-specific and the registration in `android_dispatch.zig`
79
+ * would stop finding this class.
80
+ *
81
+ * ## Every method may answer "not mine"
82
+ *
83
+ * A null return means Zig does not serve that action in this build, and the
84
+ * caller falls through to `CraftBridge`'s own Kotlin. That is the same
85
+ * hand-back the iOS seam performs, and it is what makes the migration
86
+ * incremental: an app on a build where Zig serves one action behaves exactly
87
+ * like one where it serves none.
88
+ *
89
+ * ## Why the natives are not `@JvmStatic`
90
+ *
91
+ * In a Kotlin `object`, `@JvmStatic` generates a static bridge alongside the
92
+ * instance method, and which of the two carries the `native` flag is a detail
93
+ * of the compiler rather than something this can rely on. `RegisterNatives`
94
+ * binds one specific method, and a static native takes `(JNIEnv*, jclass)`
95
+ * where an instance native takes `(JNIEnv*, jobject)` — so guessing wrong is
96
+ * a signature mismatch at load. Declaring them as ordinary instance methods on
97
+ * the singleton removes the question.
98
+ */
99
+ object CraftNative {
100
+
101
+ /**
102
+ * True when `libcraft.so` loaded.
103
+ *
104
+ * The library is genuinely optional: an app can be built with no Zig
105
+ * runtime in `jniLibs`, and that is the shim-only configuration rather
106
+ * than an error. So the failure is caught and remembered — an
107
+ * `UnsatisfiedLinkError` escaping a static initialiser would take the
108
+ * whole class down and turn "no native runtime" into a crash on the first
109
+ * bridge call.
110
+ */
111
+ val isAvailable: Boolean = try {
112
+ System.loadLibrary("craft")
113
+ true
114
+ } catch (e: UnsatisfiedLinkError) {
115
+ false
116
+ } catch (e: SecurityException) {
117
+ false
118
+ }
119
+
120
+ /**
121
+ * How Zig reaches the page.
122
+ *
123
+ * Zig cannot make a `Runnable`, and `evaluateJavascript` is main-thread
124
+ * only — JNI can only produce a `Runnable` by registering natives on a
125
+ * class that already exists in the APK, which means shipping Kotlin to
126
+ * avoid writing Kotlin. So the hop stays here: Zig owns what to say, this
127
+ * owns which thread says it.
128
+ *
129
+ * Installed by `CraftBridge` once its WebView exists, and `@Volatile`
130
+ * because Zig calls `deliver` from whatever thread a device callback
131
+ * arrives on.
132
+ */
133
+ @Volatile
134
+ private var deliverer: ((String) -> Unit)? = null
135
+
136
+ private val lifecycleGeneration = java.util.concurrent.atomic.AtomicLong(0)
137
+ private var biometricPrompt: BiometricPrompt? = null
138
+
139
+ fun setDeliverer(deliver: ((String) -> Unit)?) {
140
+ deliverer = deliver
141
+ }
142
+
143
+ /** Release platform objects owned by native-backed actions. */
144
+ fun close(activity: Activity) {
145
+ lifecycleGeneration.incrementAndGet()
146
+ deliverer = null
147
+
148
+ runCatching { speechRecognizer?.cancel() }
149
+ runCatching { speechRecognizer?.destroy() }
150
+ speechRecognizer = null
151
+ runCatching { biometricPrompt?.cancelAuthentication() }
152
+ biometricPrompt = null
153
+
154
+ audioRecorder?.let { recorder ->
155
+ runCatching { recorder.stop() }
156
+ runCatching { recorder.release() }
157
+ }
158
+ audioRecorder = null
159
+ audioFile?.delete()
160
+ audioFile = null
161
+
162
+ runCatching { productBillingClient?.endConnection() }
163
+ productBillingClient = null
164
+ runCatching { restoreBillingClient?.endConnection() }
165
+ restoreBillingClient = null
166
+
167
+ networkWatch?.let { watch ->
168
+ val manager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
169
+ runCatching { manager.unregisterNetworkCallback(watch) }
170
+ }
171
+ networkWatch = null
172
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(appStateObserver)
173
+
174
+ bleScanCallback?.let { callback ->
175
+ runCatching { bleScanner?.stopScan(callback) }
176
+ }
177
+ bleScanCallback = null
178
+ bleScanner = null
179
+
180
+ motionListener?.let { listener ->
181
+ runCatching { sensorManager?.unregisterListener(listener) }
182
+ }
183
+ motionListener = null
184
+ sensorManager = null
185
+
186
+ currentLocationCallbacks.forEach { (callback, client) ->
187
+ runCatching { client.removeLocationUpdates(callback) }
188
+ }
189
+ currentLocationCallbacks.clear()
190
+ currentLocationTimeouts.values.forEach { timeout ->
191
+ currentLocationHandler.removeCallbacks(timeout)
192
+ }
193
+ currentLocationTimeouts.clear()
194
+ currentPositionSequence.incrementAndGet()
195
+ }
196
+
197
+ /**
198
+ * Run `script` in the page. Called from Zig, on any thread.
199
+ *
200
+ * Silent when no deliverer is installed — which is every moment before the
201
+ * WebView exists, and after it is torn down. A throw here would cross back
202
+ * into Zig as a pending exception on a thread that has nowhere to report
203
+ * it, and the caller already counts what it could not deliver.
204
+ */
205
+ @JvmStatic
206
+ fun deliver(script: String) {
207
+ runCatching { deliverer?.invoke(script) }
208
+ }
209
+
210
+ private external fun nativeGetDeviceInfo(activity: Activity): String?
211
+ private external fun nativeGetMemoryUsage(): String?
212
+ private external fun nativeLog(message: String): Boolean
213
+ private external fun nativeClipboardRead(activity: Activity): String?
214
+ private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
215
+ private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
216
+ private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
217
+ private external fun nativeOpenCamera(activity: Activity): Boolean
218
+ private external fun nativePickImage(activity: Activity): Boolean
219
+ private external fun nativePickFile(activity: Activity): Boolean
220
+ private external fun nativeStartVideoRecording(activity: Activity): Boolean
221
+ private external fun nativeReviewSucceeded()
222
+ private external fun nativeReviewError(message: String)
223
+ private external fun nativeRequestReview(activity: Activity): Boolean
224
+ private external fun nativeSpeechReady(activity: Activity)
225
+ private external fun nativeSpeechError(activity: Activity, error: Int)
226
+ private external fun nativeSpeechResult(activity: Activity, transcript: String, isFinal: Boolean)
227
+ private external fun nativeStartListening(activity: Activity, available: Boolean): Boolean
228
+ private external fun nativeStopListening(activity: Activity): Boolean
229
+ private external fun nativeAudioStarted()
230
+ private external fun nativeAudioStartError(message: String)
231
+ private external fun nativeAudioStopped(bytes: ByteArray)
232
+ private external fun nativeAudioStopError(message: String)
233
+ private external fun nativeStartAudioRecording(activity: Activity): Boolean
234
+ private external fun nativeStopAudioRecording(activity: Activity): Boolean
235
+ private external fun nativeResetDeepLinks(): Boolean
236
+ private external fun nativeSetInitialURL(url: String?): Boolean
237
+ private external fun nativeGetInitialURL(): Boolean
238
+ private external fun nativeDispatchDeepLink(url: String): Boolean
239
+ private external fun nativeScreenshotReady(bytes: ByteArray)
240
+ private external fun nativeScreenshotError(message: String)
241
+ private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean
242
+ private external fun nativeProductsReady(productsJson: String)
243
+ private external fun nativeProductsError(message: String)
244
+ private external fun nativeRestoreReady(purchasesJson: String)
245
+ private external fun nativeRestoreError(message: String)
246
+ private external fun nativeGetProducts(activity: Activity, productIdsJson: String): Boolean
247
+ private external fun nativeRestorePurchases(activity: Activity): Boolean
248
+ private external fun nativeMlReady(resultsJson: String)
249
+ private external fun nativeMlError(message: String)
250
+ private external fun nativeClassifyImage(imageBase64: String): Boolean
251
+ private external fun nativeDetectObjects(imageBase64: String): Boolean
252
+ private external fun nativeRecognizeText(imageBase64: String): Boolean
253
+ private external fun nativePdfOpened()
254
+ private external fun nativePdfError(message: String)
255
+ private external fun nativeOpenPDF(activity: Activity, source: String, page: Int): Boolean
256
+ private external fun nativeBiometricSucceeded()
257
+ private external fun nativeBiometricError(message: String)
258
+ private external fun nativeAuthenticate(activity: Activity, reason: String, supported: Boolean): Boolean
259
+ private external fun nativeGetNetworkStatus(activity: Activity): String?
260
+ private external fun nativeSecureSet(prefs: SharedPreferences, key: String, value: String): Boolean
261
+ private external fun nativeSecureGet(prefs: SharedPreferences, key: String): String?
262
+ private external fun nativeSecureRemove(prefs: SharedPreferences, key: String): Boolean
263
+ private external fun nativeSecureClear(prefs: SharedPreferences): Boolean
264
+ private external fun nativeHaptic(activity: Activity, style: String): Boolean
265
+ private external fun nativeVibrate(activity: Activity, patternJson: String): Boolean
266
+ private external fun nativeCancelNotification(activity: Activity, id: String): Boolean
267
+ private external fun nativeCancelAllNotifications(activity: Activity): Boolean
268
+ private external fun nativeDeleteCalendarEvent(activity: Activity, eventId: String): Boolean
269
+ private external fun nativeGetCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean
270
+ private external fun nativeCreateCalendarEvent(activity: Activity, eventJson: String): Boolean
271
+ private external fun nativeDbExecute(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
272
+ private external fun nativeDbQuery(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean
273
+ private external fun nativeSetSharedItem(activity: Activity, key: String, value: String, group: String): Boolean
274
+ private external fun nativeGetSharedItem(activity: Activity, key: String, group: String): Boolean
275
+ private external fun nativeRemoveSharedItem(activity: Activity, key: String, group: String): Boolean
276
+ private external fun nativeGetContacts(activity: Activity): Boolean
277
+ private external fun nativeAddContact(activity: Activity, contactJson: String): Boolean
278
+ private external fun nativePickContact(activity: Activity): Boolean
279
+ private external fun nativeUpdateWidget(activity: Activity, action: String, dataJson: String): Boolean
280
+ private external fun nativeReloadWidgets(activity: Activity, action: String): Boolean
281
+ private external fun nativeSetShortcuts(activity: Activity, shortcutsJson: String): Boolean
282
+ private external fun nativeClearShortcuts(activity: Activity): Boolean
283
+ private external fun nativeScheduleNotification(activity: Activity, notificationJson: String): Boolean
284
+ private external fun nativeRunTask(activity: Activity, token: Long)
285
+ private external fun nativeCancelTask(token: Long)
286
+ private external fun nativeLockOrientation(activity: Activity, orientation: String): Boolean
287
+ private external fun nativeUnlockOrientation(activity: Activity): Boolean
288
+ private external fun nativeSetKeepAwake(activity: Activity, enabled: Boolean): Boolean
289
+ private external fun nativeDownloadFile(activity: Activity, url: String, filename: String): Boolean
290
+ private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean
291
+ private external fun nativeGetLocationRecordingState(activity: Activity): String?
292
+ private external fun nativeReadLocationRecording(activity: Activity): String?
293
+ private external fun nativeStartLocationRecording(activity: Activity): String?
294
+ private external fun nativeStopLocationRecording(activity: Activity): String?
295
+ private external fun nativePauseLocationRecording(activity: Activity): String?
296
+ private external fun nativeResumeLocationRecording(activity: Activity): String?
297
+ private external fun nativeNetworkChanged(activity: Activity)
298
+ private external fun nativeStartNetworkMonitoring(activity: Activity): Boolean
299
+ private external fun nativeStopNetworkMonitoring(activity: Activity): Boolean
300
+ private external fun nativeAppStateEvent(eventName: String)
301
+ private external fun nativeStartAppStateMonitoring(activity: Activity): Boolean
302
+ private external fun nativeStopAppStateMonitoring(activity: Activity): Boolean
303
+ private external fun nativeGetAppState(): String?
304
+ private external fun nativeBluetoothDevice(address: String, name: String?, rssi: Int)
305
+ private external fun nativeStartBluetoothScan(activity: Activity): Boolean
306
+ private external fun nativeStopBluetoothScan(activity: Activity): Boolean
307
+ private external fun nativeMotionSample(isAccelerometer: Boolean, x: Float, y: Float, z: Float)
308
+ private external fun nativeStartMotionUpdates(activity: Activity, intervalMs: Int): Boolean
309
+ private external fun nativeStopMotionUpdates(activity: Activity): Boolean
310
+ private external fun nativeLocationResult(
311
+ latitude: Double,
312
+ longitude: Double,
313
+ accuracy: Double,
314
+ altitude: Double,
315
+ speed: Double,
316
+ bearing: Double,
317
+ time: Long
318
+ )
319
+ private external fun nativeLocationFailed(message: String?)
320
+ private external fun nativeGetCurrentPosition(activity: Activity): Boolean
321
+
322
+ /**
323
+ * `getDeviceInfo`, or null to use the Kotlin implementation.
324
+ *
325
+ * The catch is not belt-and-braces. The library can load while
326
+ * `JNI_OnLoad` still fails to bind — a missing class, a signature that
327
+ * does not match — and an unbound `external fun` throws
328
+ * `UnsatisfiedLinkError` at the call rather than at load. Turning that
329
+ * into null is what keeps a half-registered runtime behaving like no
330
+ * runtime at all.
331
+ */
332
+ fun getDeviceInfo(activity: Activity): String? {
333
+ if (!isAvailable) return null
334
+ return try {
335
+ nativeGetDeviceInfo(activity)
336
+ } catch (e: UnsatisfiedLinkError) {
337
+ null
338
+ }
339
+ }
340
+
341
+ /** The JVM heap as JSON, or null to use the Kotlin implementation. */
342
+ fun getMemoryUsage(): String? {
343
+ if (!isAvailable) return null
344
+ return try {
345
+ nativeGetMemoryUsage()
346
+ } catch (e: UnsatisfiedLinkError) {
347
+ null
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Write a log line, reporting whether it was written.
353
+ *
354
+ * The only native here that answers with a boolean rather than a nullable
355
+ * result, because there is no value to return and "not mine" still has to
356
+ * be sayable — a void native would leave the caller unable to tell a
357
+ * written line from a declined one, and it would log twice or not at all.
358
+ */
359
+ fun log(message: String): Boolean {
360
+ if (!isAvailable) return false
361
+ return try {
362
+ nativeLog(message)
363
+ } catch (e: UnsatisfiedLinkError) {
364
+ false
365
+ }
366
+ }
367
+
368
+ /**
369
+ * The clipboard's text, or null to use the Kotlin implementation.
370
+ *
371
+ * Null and `""` are different answers and both are normal: null means Zig
372
+ * did not serve this call, `""` means the clipboard is empty — which since
373
+ * Android 10 is what an app without focus sees every time. Collapsing them
374
+ * would make an empty clipboard fall through and be read twice.
375
+ */
376
+ fun clipboardRead(activity: Activity): String? {
377
+ if (!isAvailable) return null
378
+ return try {
379
+ nativeClipboardRead(activity)
380
+ } catch (e: UnsatisfiedLinkError) {
381
+ null
382
+ }
383
+ }
384
+
385
+ /** Returns whether Zig wrote the clipboard; false falls through. */
386
+ fun clipboardWrite(activity: Activity, text: String): Boolean {
387
+ if (!isAvailable) return false
388
+ return try {
389
+ nativeClipboardWrite(activity, text)
390
+ } catch (e: UnsatisfiedLinkError) {
391
+ false
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Returns whether Zig opened the URL.
397
+ *
398
+ * False is ambiguous here in a way it is not elsewhere: it covers both
399
+ * "Zig did not serve this" and "nothing on the device handles that
400
+ * scheme". Both lead to the same place — the Kotlin runs its own
401
+ * implementation and answers false for the second reason itself — so the
402
+ * ambiguity costs nothing and a richer return would have to be unpacked
403
+ * at every call site to reach the same outcome.
404
+ */
405
+ fun openURL(activity: Activity, url: String): Boolean {
406
+ if (!isAvailable) return false
407
+ return try {
408
+ nativeOpenUrl(activity, url)
409
+ } catch (e: UnsatisfiedLinkError) {
410
+ false
411
+ }
412
+ }
413
+
414
+ /** Returns whether Zig launched the share sheet; false falls through. */
415
+ fun share(activity: Activity, text: String, title: String): Boolean {
416
+ if (!isAvailable) return false
417
+ return try {
418
+ nativeShare(activity, text, title)
419
+ } catch (e: UnsatisfiedLinkError) {
420
+ false
421
+ }
422
+ }
423
+
424
+ /** Launch the camera; its result is still decoded by CraftBridge. */
425
+ fun openCamera(activity: Activity): Boolean {
426
+ if (!isAvailable) return false
427
+ return try {
428
+ nativeOpenCamera(activity)
429
+ } catch (e: UnsatisfiedLinkError) {
430
+ false
431
+ }
432
+ }
433
+
434
+ /** Launch the system image picker; CraftBridge owns the result callback. */
435
+ fun pickImage(activity: Activity): Boolean {
436
+ if (!isAvailable) return false
437
+ return try {
438
+ nativePickImage(activity)
439
+ } catch (e: UnsatisfiedLinkError) {
440
+ false
441
+ }
442
+ }
443
+
444
+ fun pickFile(activity: Activity): Boolean {
445
+ if (!isAvailable) return false
446
+ return try {
447
+ nativePickFile(activity)
448
+ } catch (e: UnsatisfiedLinkError) {
449
+ false
450
+ }
451
+ }
452
+
453
+ fun startVideoRecording(activity: Activity): Boolean {
454
+ if (!isAvailable) return false
455
+ return try {
456
+ nativeStartVideoRecording(activity)
457
+ } catch (e: UnsatisfiedLinkError) {
458
+ false
459
+ }
460
+ }
461
+
462
+ // ==================== In-app review ====================
463
+ //
464
+ // Play Core's Task listeners are Java objects and remain here. Every
465
+ // observable outcome crosses back into Zig; completing the launched flow
466
+ // resolves regardless of whether Google Play displayed a review card.
467
+
468
+ @JvmStatic
469
+ fun startReviewFlow(activity: Activity) {
470
+ val requestGeneration = lifecycleGeneration.get()
471
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
472
+
473
+ fun resolveReviewRequest() {
474
+ if (requestGeneration != lifecycleGeneration.get()) return
475
+ if (!settled.compareAndSet(false, true)) return
476
+ try {
477
+ nativeReviewSucceeded()
478
+ } catch (e: UnsatisfiedLinkError) {
479
+ // The library went away while Play owned the flow.
480
+ }
481
+ }
482
+
483
+ fun rejectReviewRequest(message: String) {
484
+ if (requestGeneration != lifecycleGeneration.get()) return
485
+ if (!settled.compareAndSet(false, true)) return
486
+ try {
487
+ nativeReviewError(message)
488
+ } catch (e: UnsatisfiedLinkError) {
489
+ }
490
+ }
491
+
492
+ try {
493
+ val reviewManager = ReviewManagerFactory.create(activity)
494
+ val request = reviewManager.requestReviewFlow()
495
+
496
+ request.addOnCompleteListener { task ->
497
+ if (task.isSuccessful) {
498
+ try {
499
+ reviewManager.launchReviewFlow(activity, task.result)
500
+ .addOnCompleteListener { resolveReviewRequest() }
501
+ } catch (error: Exception) {
502
+ rejectReviewRequest(error.message ?: "Review flow failed")
503
+ }
504
+ } else {
505
+ rejectReviewRequest(task.exception?.message ?: "Review flow failed")
506
+ }
507
+ }
508
+ } catch (error: Exception) {
509
+ rejectReviewRequest(error.message ?: "Review flow failed")
510
+ }
511
+ }
512
+
513
+ fun requestReview(activity: Activity): Boolean {
514
+ if (!isAvailable) return false
515
+ return try {
516
+ nativeRequestReview(activity)
517
+ } catch (e: UnsatisfiedLinkError) {
518
+ false
519
+ }
520
+ }
521
+
522
+ // ==================== Speech recognition ====================
523
+ //
524
+ // SpeechRecognizer is main-thread-only and RecognitionListener is a Java
525
+ // interface. The holder owns both; callbacks hand only stable values back
526
+ // to Zig, which owns every event and haptic decision.
527
+
528
+ private var speechRecognizer: SpeechRecognizer? = null
529
+
530
+ @JvmStatic
531
+ fun beginSpeechRecognition(activity: Activity) {
532
+ activity.runOnUiThread {
533
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
534
+ != PackageManager.PERMISSION_GRANTED) {
535
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), 100)
536
+ return@runOnUiThread
537
+ }
538
+
539
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity)
540
+ speechRecognizer?.setRecognitionListener(object : RecognitionListener {
541
+ override fun onReadyForSpeech(params: Bundle?) {
542
+ try {
543
+ nativeSpeechReady(activity)
544
+ } catch (e: UnsatisfiedLinkError) {
545
+ }
546
+ }
547
+
548
+ override fun onBeginningOfSpeech() {}
549
+ override fun onRmsChanged(rmsdB: Float) {}
550
+ override fun onBufferReceived(buffer: ByteArray?) {}
551
+ override fun onEndOfSpeech() {}
552
+
553
+ override fun onError(error: Int) {
554
+ try {
555
+ nativeSpeechError(activity, error)
556
+ } catch (e: UnsatisfiedLinkError) {
557
+ }
558
+ }
559
+
560
+ override fun onResults(results: Bundle?) {
561
+ val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
562
+ try {
563
+ nativeSpeechResult(activity, matches?.firstOrNull() ?: "", true)
564
+ } catch (e: UnsatisfiedLinkError) {
565
+ }
566
+ }
567
+
568
+ override fun onPartialResults(partialResults: Bundle?) {
569
+ val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
570
+ try {
571
+ nativeSpeechResult(activity, matches?.firstOrNull() ?: "", false)
572
+ } catch (e: UnsatisfiedLinkError) {
573
+ }
574
+ }
575
+
576
+ override fun onEvent(eventType: Int, params: Bundle?) {}
577
+ })
578
+
579
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
580
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
581
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
582
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
583
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
584
+ }
585
+ speechRecognizer?.startListening(intent)
586
+ }
587
+ }
588
+
589
+ @JvmStatic
590
+ fun endSpeechRecognition(activity: Activity) {
591
+ activity.runOnUiThread {
592
+ speechRecognizer?.stopListening()
593
+ speechRecognizer?.destroy()
594
+ speechRecognizer = null
595
+ }
596
+ }
597
+
598
+ fun startListening(activity: Activity): Boolean {
599
+ if (!isAvailable) return false
600
+ return try {
601
+ nativeStartListening(activity, SpeechRecognizer.isRecognitionAvailable(activity))
602
+ } catch (e: UnsatisfiedLinkError) {
603
+ false
604
+ }
605
+ }
606
+
607
+ fun stopListening(activity: Activity): Boolean {
608
+ if (!isAvailable) return false
609
+ return try {
610
+ nativeStopListening(activity)
611
+ } catch (e: UnsatisfiedLinkError) {
612
+ false
613
+ }
614
+ }
615
+
616
+ // ==================== Audio recording ====================
617
+ //
618
+ // The long-lived Java objects stay here. Zig owns permission handling and
619
+ // all four promise outcomes, including encoding the bytes returned on stop.
620
+
621
+ private var audioRecorder: MediaRecorder? = null
622
+ private var audioFile: File? = null
623
+
624
+ @JvmStatic
625
+ fun beginAudioRecording(activity: Activity) {
626
+ try {
627
+ audioFile = File.createTempFile("recording_", ".m4a", activity.cacheDir)
628
+ audioRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
629
+ MediaRecorder(activity)
630
+ } else {
631
+ @Suppress("DEPRECATION")
632
+ MediaRecorder()
633
+ }
634
+
635
+ audioRecorder?.apply {
636
+ setAudioSource(MediaRecorder.AudioSource.MIC)
637
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
638
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
639
+ setOutputFile(audioFile?.absolutePath)
640
+ prepare()
641
+ start()
642
+ }
643
+ nativeAudioStarted()
644
+ } catch (e: Exception) {
645
+ nativeAudioStartError(e.message ?: "null")
646
+ }
647
+ }
648
+
649
+ @JvmStatic
650
+ fun endAudioRecording() {
651
+ try {
652
+ audioRecorder?.stop()
653
+ audioRecorder?.release()
654
+ audioRecorder = null
655
+
656
+ val file = audioFile
657
+ if (file != null) {
658
+ nativeAudioStopped(file.readBytes())
659
+ } else {
660
+ nativeAudioStopError("No recording")
661
+ }
662
+ } catch (e: Exception) {
663
+ nativeAudioStopError(e.message ?: "null")
664
+ }
665
+ }
666
+
667
+ fun startAudioRecording(activity: Activity): Boolean {
668
+ if (!isAvailable) return false
669
+ return try {
670
+ nativeStartAudioRecording(activity)
671
+ } catch (e: UnsatisfiedLinkError) {
672
+ false
673
+ }
674
+ }
675
+
676
+ fun stopAudioRecording(activity: Activity): Boolean {
677
+ if (!isAvailable) return false
678
+ return try {
679
+ nativeStopAudioRecording(activity)
680
+ } catch (e: UnsatisfiedLinkError) {
681
+ false
682
+ }
683
+ }
684
+
685
+ fun resetDeepLinks(): Boolean {
686
+ if (!isAvailable) return false
687
+ return try { nativeResetDeepLinks() } catch (e: UnsatisfiedLinkError) { false }
688
+ }
689
+
690
+ fun setInitialURL(url: String?): Boolean {
691
+ if (!isAvailable) return false
692
+ return try { nativeSetInitialURL(url) } catch (e: UnsatisfiedLinkError) { false }
693
+ }
694
+
695
+ fun getInitialURL(): Boolean {
696
+ if (!isAvailable) return false
697
+ return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false }
698
+ }
699
+
700
+ fun dispatchDeepLink(url: String): Boolean {
701
+ if (!isAvailable) return false
702
+ return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false }
703
+ }
704
+
705
+ @JvmStatic
706
+ fun captureScreenshot(activity: Activity, webView: WebView) {
707
+ val requestGeneration = lifecycleGeneration.get()
708
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
709
+
710
+ fun resolveScreenshotRequest(bytes: ByteArray) {
711
+ if (requestGeneration != lifecycleGeneration.get()) return
712
+ if (!settled.compareAndSet(false, true)) return
713
+ nativeScreenshotReady(bytes)
714
+ }
715
+
716
+ fun rejectScreenshotRequest(message: String) {
717
+ if (requestGeneration != lifecycleGeneration.get()) return
718
+ if (!settled.compareAndSet(false, true)) return
719
+ nativeScreenshotError(message)
720
+ }
721
+
722
+ activity.runOnUiThread {
723
+ if (requestGeneration != lifecycleGeneration.get()) return@runOnUiThread
724
+ try {
725
+ val view = webView.rootView
726
+ val bitmap = try {
727
+ view.isDrawingCacheEnabled = true
728
+ view.buildDrawingCache()
729
+ Bitmap.createBitmap(view.drawingCache)
730
+ } finally {
731
+ view.isDrawingCacheEnabled = false
732
+ }
733
+
734
+ val outputStream = ByteArrayOutputStream()
735
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
736
+ resolveScreenshotRequest(outputStream.toByteArray())
737
+ } catch (e: Exception) {
738
+ rejectScreenshotRequest(e.message ?: "Screenshot failed")
739
+ }
740
+ }
741
+ }
742
+
743
+ fun takeScreenshot(activity: Activity, webView: WebView): Boolean {
744
+ if (!isAvailable) return false
745
+ return try { nativeTakeScreenshot(activity, webView) } catch (e: UnsatisfiedLinkError) { false }
746
+ }
747
+
748
+ // ==================== Play Billing ====================
749
+
750
+ private var productBillingClient: BillingClient? = null
751
+ private var restoreBillingClient: BillingClient? = null
752
+
753
+ @JvmStatic
754
+ fun queryProducts(activity: Activity, productIdsJson: String) {
755
+ val requestGeneration = lifecycleGeneration.get()
756
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
757
+
758
+ fun resolveProductRequest(productsJson: String) {
759
+ if (!settled.compareAndSet(false, true)) return
760
+ if (requestGeneration != lifecycleGeneration.get()) return
761
+ nativeProductsReady(productsJson)
762
+ }
763
+
764
+ fun rejectProductRequest(message: String) {
765
+ if (!settled.compareAndSet(false, true)) return
766
+ if (requestGeneration != lifecycleGeneration.get()) return
767
+ nativeProductsError(message)
768
+ }
769
+
770
+ try {
771
+ val productIds = org.json.JSONArray(productIdsJson)
772
+ val productList = mutableListOf<String>()
773
+ for (i in 0 until productIds.length()) productList.add(productIds.getString(i))
774
+
775
+ runCatching { productBillingClient?.endConnection() }
776
+ val client = BillingClient.newBuilder(activity)
777
+ .setListener { _, _ -> }
778
+ .enablePendingPurchases()
779
+ .build()
780
+ productBillingClient = client
781
+
782
+ client.startConnection(object : BillingClientStateListener {
783
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
784
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
785
+ val params = QueryProductDetailsParams.newBuilder()
786
+ .setProductList(productList.map { productId ->
787
+ QueryProductDetailsParams.Product.newBuilder()
788
+ .setProductId(productId)
789
+ .setProductType(BillingClient.ProductType.INAPP)
790
+ .build()
791
+ })
792
+ .build()
793
+
794
+ try {
795
+ client.queryProductDetailsAsync(params) { result, detailsList ->
796
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
797
+ val products = org.json.JSONArray()
798
+ detailsList.forEach { details ->
799
+ products.put(JSONObject().apply {
800
+ put("id", details.productId)
801
+ put("title", details.name)
802
+ put("displayName", details.name)
803
+ put("description", details.description)
804
+ put("price", details.oneTimePurchaseOfferDetails?.formattedPrice ?: "")
805
+ })
806
+ }
807
+ resolveProductRequest(products.toString())
808
+ } else {
809
+ rejectProductRequest("Product query failed: ${result.debugMessage}")
810
+ }
811
+ }
812
+ } catch (error: Exception) {
813
+ rejectProductRequest(error.message ?: "Product query failed")
814
+ }
815
+ } else {
816
+ rejectProductRequest("Billing setup failed: ${billingResult.debugMessage}")
817
+ }
818
+ }
819
+
820
+ override fun onBillingServiceDisconnected() {
821
+ rejectProductRequest("Billing disconnected")
822
+ }
823
+ })
824
+ } catch (e: Exception) {
825
+ rejectProductRequest(e.message ?: "Product query failed")
826
+ }
827
+ }
828
+
829
+ @JvmStatic
830
+ fun queryRestoredPurchases(activity: Activity) {
831
+ val requestGeneration = lifecycleGeneration.get()
832
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
833
+
834
+ fun resolveRestoreRequest(purchasesJson: String) {
835
+ if (!settled.compareAndSet(false, true)) return
836
+ if (requestGeneration != lifecycleGeneration.get()) return
837
+ nativeRestoreReady(purchasesJson)
838
+ }
839
+
840
+ fun rejectRestoreRequest(message: String) {
841
+ if (!settled.compareAndSet(false, true)) return
842
+ if (requestGeneration != lifecycleGeneration.get()) return
843
+ nativeRestoreError(message)
844
+ }
845
+
846
+ val connectedClient = restoreBillingClient
847
+ if (connectedClient != null && connectedClient.isReady) {
848
+ queryRestoredPurchases(connectedClient, ::resolveRestoreRequest, ::rejectRestoreRequest)
849
+ return
850
+ }
851
+
852
+ try {
853
+ val client = connectedClient ?: BillingClient.newBuilder(activity)
854
+ .setListener { _, _ -> }
855
+ .enablePendingPurchases()
856
+ .build()
857
+ .also { restoreBillingClient = it }
858
+
859
+ client.startConnection(object : BillingClientStateListener {
860
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
861
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
862
+ queryRestoredPurchases(client, ::resolveRestoreRequest, ::rejectRestoreRequest)
863
+ } else {
864
+ rejectRestoreRequest("Billing setup failed: ${billingResult.debugMessage}")
865
+ }
866
+ }
867
+
868
+ override fun onBillingServiceDisconnected() {
869
+ rejectRestoreRequest("Billing disconnected")
870
+ }
871
+ })
872
+ } catch (error: Exception) {
873
+ rejectRestoreRequest(error.message ?: "Billing connection failed")
874
+ }
875
+ }
876
+
877
+ private fun queryRestoredPurchases(
878
+ client: BillingClient,
879
+ resolve: (String) -> Unit,
880
+ reject: (String) -> Unit
881
+ ) {
882
+ try {
883
+ client.queryPurchasesAsync(
884
+ QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
885
+ ) { result, purchases ->
886
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
887
+ val restored = org.json.JSONArray()
888
+ purchases.forEach { purchase ->
889
+ restored.put(JSONObject().apply {
890
+ put("productId", purchase.products.firstOrNull())
891
+ put("orderId", purchase.orderId)
892
+ })
893
+ }
894
+ resolve(restored.toString())
895
+ } else {
896
+ reject("Restore failed: ${result.debugMessage}")
897
+ }
898
+ }
899
+ } catch (error: Exception) {
900
+ reject(error.message ?: "Restore failed")
901
+ }
902
+ }
903
+
904
+ fun getProducts(activity: Activity, productIdsJson: String): Boolean {
905
+ if (!isAvailable) return false
906
+ return try { nativeGetProducts(activity, productIdsJson) } catch (e: UnsatisfiedLinkError) { false }
907
+ }
908
+
909
+ fun restorePurchases(activity: Activity): Boolean {
910
+ if (!isAvailable) return false
911
+ return try { nativeRestorePurchases(activity) } catch (e: UnsatisfiedLinkError) { false }
912
+ }
913
+
914
+ // ==================== ML Kit ====================
915
+
916
+ private fun createMlSettlement(): Pair<(String) -> Unit, (String) -> Unit> {
917
+ val requestGeneration = lifecycleGeneration.get()
918
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
919
+ val resolve: (String) -> Unit = { resultsJson ->
920
+ if (requestGeneration == lifecycleGeneration.get() && settled.compareAndSet(false, true)) {
921
+ nativeMlReady(resultsJson)
922
+ }
923
+ }
924
+ val reject: (String) -> Unit = { message ->
925
+ if (requestGeneration == lifecycleGeneration.get() && settled.compareAndSet(false, true)) {
926
+ nativeMlError(message)
927
+ }
928
+ }
929
+ return resolve to reject
930
+ }
931
+
932
+ @JvmStatic
933
+ fun runImageClassification(imageBase64: String) {
934
+ val (resolve, reject) = createMlSettlement()
935
+ try {
936
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
937
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
938
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
939
+ val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
940
+ labeler.process(inputImage)
941
+ .addOnSuccessListener { labels ->
942
+ try {
943
+ val results = JSONArray()
944
+ for (label in labels) {
945
+ results.put(JSONObject().apply {
946
+ put("label", label.text)
947
+ put("confidence", label.confidence)
948
+ put("index", label.index)
949
+ })
950
+ }
951
+ resolve(results.toString())
952
+ } catch (error: Exception) {
953
+ reject(error.message ?: "Image classification failed")
954
+ }
955
+ }
956
+ .addOnFailureListener { error -> reject(error.message ?: "Image classification failed") }
957
+ .addOnCompleteListener { labeler.close() }
958
+ } catch (e: Exception) {
959
+ reject(e.message ?: "Image classification failed")
960
+ }
961
+ }
962
+
963
+ @JvmStatic
964
+ fun runObjectDetection(imageBase64: String) {
965
+ val (resolve, reject) = createMlSettlement()
966
+ try {
967
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
968
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
969
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
970
+ val options = ObjectDetectorOptions.Builder()
971
+ .setDetectorMode(ObjectDetectorOptions.SINGLE_IMAGE_MODE)
972
+ .enableMultipleObjects()
973
+ .enableClassification()
974
+ .build()
975
+
976
+ val detector = ObjectDetection.getClient(options)
977
+ detector.process(inputImage)
978
+ .addOnSuccessListener { detectedObjects ->
979
+ try {
980
+ val results = JSONArray()
981
+ for (obj in detectedObjects) {
982
+ val objJson = JSONObject()
983
+ objJson.put("boundingBox", JSONObject().apply {
984
+ put("x", obj.boundingBox.left)
985
+ put("y", obj.boundingBox.top)
986
+ put("width", obj.boundingBox.width())
987
+ put("height", obj.boundingBox.height())
988
+ })
989
+ val labels = JSONArray()
990
+ for (label in obj.labels) {
991
+ labels.put(JSONObject().apply {
992
+ put("label", label.text)
993
+ put("confidence", label.confidence)
994
+ put("index", label.index)
995
+ })
996
+ }
997
+ objJson.put("labels", labels)
998
+ objJson.put("trackingId", obj.trackingId)
999
+ results.put(objJson)
1000
+ }
1001
+ resolve(results.toString())
1002
+ } catch (error: Exception) {
1003
+ reject(error.message ?: "Object detection failed")
1004
+ }
1005
+ }
1006
+ .addOnFailureListener { error -> reject(error.message ?: "Object detection failed") }
1007
+ .addOnCompleteListener { detector.close() }
1008
+ } catch (e: Exception) {
1009
+ reject(e.message ?: "Object detection failed")
1010
+ }
1011
+ }
1012
+
1013
+ @JvmStatic
1014
+ fun runTextRecognition(imageBase64: String) {
1015
+ val (resolve, reject) = createMlSettlement()
1016
+ try {
1017
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
1018
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
1019
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
1020
+ val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
1021
+ recognizer.process(inputImage)
1022
+ .addOnSuccessListener { visionText ->
1023
+ try {
1024
+ val results = JSONArray()
1025
+ for (block in visionText.textBlocks) {
1026
+ for (line in block.lines) {
1027
+ val lineJson = JSONObject()
1028
+ lineJson.put("text", line.text)
1029
+ lineJson.put("confidence", line.confidence ?: 0.0)
1030
+ line.boundingBox?.let { box ->
1031
+ lineJson.put("boundingBox", JSONObject().apply {
1032
+ put("x", box.left)
1033
+ put("y", box.top)
1034
+ put("width", box.width())
1035
+ put("height", box.height())
1036
+ })
1037
+ }
1038
+ results.put(lineJson)
1039
+ }
1040
+ }
1041
+ resolve(results.toString())
1042
+ } catch (error: Exception) {
1043
+ reject(error.message ?: "Text recognition failed")
1044
+ }
1045
+ }
1046
+ .addOnFailureListener { error -> reject(error.message ?: "Text recognition failed") }
1047
+ .addOnCompleteListener { recognizer.close() }
1048
+ } catch (e: Exception) {
1049
+ reject(e.message ?: "Text recognition failed")
1050
+ }
1051
+ }
1052
+
1053
+ fun classifyImage(imageBase64: String): Boolean {
1054
+ if (!isAvailable) return false
1055
+ return try { nativeClassifyImage(imageBase64) } catch (e: UnsatisfiedLinkError) { false }
1056
+ }
1057
+
1058
+ fun detectObjects(imageBase64: String): Boolean {
1059
+ if (!isAvailable) return false
1060
+ return try { nativeDetectObjects(imageBase64) } catch (e: UnsatisfiedLinkError) { false }
1061
+ }
1062
+
1063
+ fun recognizeText(imageBase64: String): Boolean {
1064
+ if (!isAvailable) return false
1065
+ return try { nativeRecognizeText(imageBase64) } catch (e: UnsatisfiedLinkError) { false }
1066
+ }
1067
+
1068
+ // ==================== External PDF viewer ====================
1069
+
1070
+ @JvmStatic
1071
+ fun openPdfExternal(activity: Activity, source: String) {
1072
+ val requestGeneration = lifecycleGeneration.get()
1073
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
1074
+
1075
+ fun resolvePdfRequest() {
1076
+ if (requestGeneration != lifecycleGeneration.get()) return
1077
+ if (!settled.compareAndSet(false, true)) return
1078
+ nativePdfOpened()
1079
+ }
1080
+
1081
+ fun rejectPdfRequest(message: String) {
1082
+ if (requestGeneration != lifecycleGeneration.get()) return
1083
+ if (!settled.compareAndSet(false, true)) return
1084
+ nativePdfError(message)
1085
+ }
1086
+
1087
+ activity.runOnUiThread {
1088
+ if (requestGeneration != lifecycleGeneration.get()) return@runOnUiThread
1089
+ try {
1090
+ val uri = if (source.startsWith("data:")) {
1091
+ val base64Data = source.substringAfter(",")
1092
+ val bytes = Base64.decode(base64Data, Base64.DEFAULT)
1093
+ val tempFile = File.createTempFile("craft_pdf_", ".pdf", activity.cacheDir)
1094
+ tempFile.writeBytes(bytes)
1095
+ android.net.Uri.fromFile(tempFile)
1096
+ } else {
1097
+ android.net.Uri.parse(source)
1098
+ }
1099
+
1100
+ val intent = Intent(Intent.ACTION_VIEW).apply {
1101
+ setDataAndType(uri, "application/pdf")
1102
+ addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
1103
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
1104
+ }
1105
+ activity.startActivity(intent)
1106
+ resolvePdfRequest()
1107
+ } catch (e: Exception) {
1108
+ rejectPdfRequest(e.message ?: "PDF could not be opened")
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ fun openPDF(activity: Activity, source: String, page: Int): Boolean {
1114
+ if (!isAvailable) return false
1115
+ return try { nativeOpenPDF(activity, source, page) } catch (e: UnsatisfiedLinkError) { false }
1116
+ }
1117
+
1118
+ // ==================== Biometric authentication ====================
1119
+ //
1120
+ // AuthenticationCallback is an abstract Java class and the prompt must be
1121
+ // presented on the main thread. Those two Java-shaped pieces stay here;
1122
+ // the decision and both promise payloads are Zig's.
1123
+
1124
+ @JvmStatic
1125
+ fun showBiometricPrompt(activity: Activity, reason: String) {
1126
+ val requestGeneration = lifecycleGeneration.get()
1127
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
1128
+
1129
+ fun resolveBiometricRequest() {
1130
+ if (requestGeneration != lifecycleGeneration.get()) return
1131
+ if (!settled.compareAndSet(false, true)) return
1132
+ biometricPrompt = null
1133
+ try {
1134
+ nativeBiometricSucceeded()
1135
+ } catch (e: UnsatisfiedLinkError) {
1136
+ // The library went away while the prompt was open.
1137
+ }
1138
+ }
1139
+
1140
+ fun rejectBiometricRequest(message: String) {
1141
+ if (requestGeneration != lifecycleGeneration.get()) return
1142
+ if (!settled.compareAndSet(false, true)) return
1143
+ biometricPrompt = null
1144
+ try {
1145
+ nativeBiometricError(message)
1146
+ } catch (e: UnsatisfiedLinkError) {
1147
+ }
1148
+ }
1149
+
1150
+ activity.runOnUiThread {
1151
+ if (requestGeneration != lifecycleGeneration.get()) return@runOnUiThread
1152
+ try {
1153
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
1154
+ .setTitle("Authenticate")
1155
+ .setSubtitle(reason)
1156
+ .setNegativeButtonText("Cancel")
1157
+ .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
1158
+ .build()
1159
+
1160
+ runCatching { biometricPrompt?.cancelAuthentication() }
1161
+ val prompt = BiometricPrompt(
1162
+ activity as FragmentActivity,
1163
+ ContextCompat.getMainExecutor(activity),
1164
+ object : BiometricPrompt.AuthenticationCallback() {
1165
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
1166
+ resolveBiometricRequest()
1167
+ }
1168
+
1169
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
1170
+ rejectBiometricRequest(errString.toString())
1171
+ }
1172
+
1173
+ override fun onAuthenticationFailed() {
1174
+ // Do not reject: the prompt remains open for a retry.
1175
+ }
1176
+ }
1177
+ )
1178
+ biometricPrompt = prompt
1179
+ prompt.authenticate(promptInfo)
1180
+ } catch (error: Exception) {
1181
+ rejectBiometricRequest(error.message ?: "Biometric authentication failed")
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ fun authenticate(activity: Activity, reason: String): Boolean {
1187
+ if (!isAvailable) return false
1188
+ return try {
1189
+ // Kotlin can observe this type without a reflective class lookup;
1190
+ // Zig owns what the unsupported case means to the page.
1191
+ nativeAuthenticate(activity, reason, activity is FragmentActivity)
1192
+ } catch (e: UnsatisfiedLinkError) {
1193
+ false
1194
+ }
1195
+ }
1196
+
1197
+ /** The active network as JSON, or null to use the Kotlin implementation. */
1198
+ fun getNetworkStatus(activity: Activity): String? {
1199
+ if (!isAvailable) return null
1200
+ return try {
1201
+ nativeGetNetworkStatus(activity)
1202
+ } catch (e: UnsatisfiedLinkError) {
1203
+ null
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * The three answers a secure read can give.
1209
+ *
1210
+ * `secureGet` returns `String?` to the page, where null means "no such
1211
+ * key". The seam needs to say "Zig did not serve this" as well, and a
1212
+ * nullable String cannot carry both — an absent key and a declined call
1213
+ * lead to different code. There is no spare value to steal either: `""` is
1214
+ * a legitimate stored value, and so is any sentinel a caller could have
1215
+ * saved.
1216
+ */
1217
+ sealed class SecureRead {
1218
+ /** Zig did not serve the call; run the Kotlin implementation. */
1219
+ object Declined : SecureRead()
1220
+
1221
+ /** Zig served it and the key is not present. */
1222
+ object NotFound : SecureRead()
1223
+
1224
+ /** Zig served it and the key holds [value], which may be empty. */
1225
+ data class Found(val value: String) : SecureRead()
1226
+ }
1227
+
1228
+ fun secureSet(prefs: SharedPreferences, key: String, value: String): Boolean {
1229
+ if (!isAvailable) return false
1230
+ return try {
1231
+ nativeSecureSet(prefs, key, value)
1232
+ } catch (e: UnsatisfiedLinkError) {
1233
+ false
1234
+ }
1235
+ }
1236
+
1237
+ fun secureGet(prefs: SharedPreferences, key: String): SecureRead {
1238
+ if (!isAvailable) return SecureRead.Declined
1239
+ val envelope = try {
1240
+ nativeSecureGet(prefs, key)
1241
+ } catch (e: UnsatisfiedLinkError) {
1242
+ null
1243
+ } ?: return SecureRead.Declined
1244
+
1245
+ // A malformed envelope is Zig's bug, and declining is the safe answer:
1246
+ // the Kotlin then reads the store itself and the page sees the truth.
1247
+ return try {
1248
+ val json = JSONObject(envelope)
1249
+ if (json.getBoolean("found")) SecureRead.Found(json.getString("value"))
1250
+ else SecureRead.NotFound
1251
+ } catch (e: Exception) {
1252
+ SecureRead.Declined
1253
+ }
1254
+ }
1255
+
1256
+ fun secureRemove(prefs: SharedPreferences, key: String): Boolean {
1257
+ if (!isAvailable) return false
1258
+ return try {
1259
+ nativeSecureRemove(prefs, key)
1260
+ } catch (e: UnsatisfiedLinkError) {
1261
+ false
1262
+ }
1263
+ }
1264
+
1265
+ fun secureClear(prefs: SharedPreferences): Boolean {
1266
+ if (!isAvailable) return false
1267
+ return try {
1268
+ nativeSecureClear(prefs)
1269
+ } catch (e: UnsatisfiedLinkError) {
1270
+ false
1271
+ }
1272
+ }
1273
+
1274
+ /** Returns whether Zig played the haptic; false falls through. */
1275
+ fun haptic(activity: Activity, style: String): Boolean {
1276
+ if (!isAvailable) return false
1277
+ return try {
1278
+ nativeHaptic(activity, style)
1279
+ } catch (e: UnsatisfiedLinkError) {
1280
+ false
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * Returns whether Zig played the pattern.
1286
+ *
1287
+ * False also covers a pattern Zig could not parse — deliberately, so the
1288
+ * Kotlin runs, throws the JSONException it would have thrown anyway, and
1289
+ * writes its "Vibration error" line. Handling it here would reach the same
1290
+ * silence and lose the log.
1291
+ */
1292
+ fun vibrate(activity: Activity, patternJson: String): Boolean {
1293
+ if (!isAvailable) return false
1294
+ return try {
1295
+ nativeVibrate(activity, patternJson)
1296
+ } catch (e: UnsatisfiedLinkError) {
1297
+ false
1298
+ }
1299
+ }
1300
+
1301
+ /** Returns whether Zig cancelled it; false falls through. */
1302
+ fun cancelNotification(activity: Activity, id: String): Boolean {
1303
+ if (!isAvailable) return false
1304
+ return try {
1305
+ nativeCancelNotification(activity, id)
1306
+ } catch (e: UnsatisfiedLinkError) {
1307
+ false
1308
+ }
1309
+ }
1310
+
1311
+ /** Returns whether Zig cancelled them all; false falls through. */
1312
+ fun cancelAllNotifications(activity: Activity): Boolean {
1313
+ if (!isAvailable) return false
1314
+ return try {
1315
+ nativeCancelAllNotifications(activity)
1316
+ } catch (e: UnsatisfiedLinkError) {
1317
+ false
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Returns whether Zig *took* the action, not whether the delete worked.
1323
+ *
1324
+ * The first native here that answers asynchronously: the outcome reaches
1325
+ * the page through the reply channel, so true means "do not also run the
1326
+ * Kotlin" and nothing more. Running both would settle the promise twice.
1327
+ */
1328
+ fun deleteCalendarEvent(activity: Activity, eventId: String): Boolean {
1329
+ if (!isAvailable) return false
1330
+ return try {
1331
+ nativeDeleteCalendarEvent(activity, eventId)
1332
+ } catch (e: UnsatisfiedLinkError) {
1333
+ false
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Returns whether Zig took the action, not whether the read found anything.
1339
+ *
1340
+ * An empty calendar still answers — with `[]`, through the reply channel —
1341
+ * so a false here means Zig did not run, never that there was nothing to
1342
+ * report.
1343
+ */
1344
+ fun getCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean {
1345
+ if (!isAvailable) return false
1346
+ return try {
1347
+ nativeGetCalendarEvents(activity, startDateMs, endDateMs)
1348
+ } catch (e: UnsatisfiedLinkError) {
1349
+ false
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * Returns whether Zig took the action.
1355
+ *
1356
+ * A false here is routine rather than exceptional: Zig serves the shape
1357
+ * `NewCalendarEvent` declares and hands anything else back, because
1358
+ * `org.json` coerces where a strict parser refuses. The Kotlin below is
1359
+ * what answers those.
1360
+ */
1361
+ fun createCalendarEvent(activity: Activity, eventJson: String): Boolean {
1362
+ if (!isAvailable) return false
1363
+ return try {
1364
+ nativeCreateCalendarEvent(activity, eventJson)
1365
+ } catch (e: UnsatisfiedLinkError) {
1366
+ false
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * Returns whether Zig took the statement.
1372
+ *
1373
+ * The connection is passed in rather than opened: the Kotlin caches one
1374
+ * `SQLiteDatabase` so that a BEGIN in one call and a COMMIT in the next
1375
+ * reach the same connection, and a second one opened by Zig would take
1376
+ * locks against the first for no visible reason.
1377
+ */
1378
+ fun dbExecute(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean {
1379
+ if (!isAvailable) return false
1380
+ return try {
1381
+ nativeDbExecute(database, sql, paramsJson)
1382
+ } catch (e: UnsatisfiedLinkError) {
1383
+ false
1384
+ }
1385
+ }
1386
+
1387
+ /** The same, for a statement that returns rows. */
1388
+ fun dbQuery(database: SQLiteDatabase, sql: String, paramsJson: String): Boolean {
1389
+ if (!isAvailable) return false
1390
+ return try {
1391
+ nativeDbQuery(database, sql, paramsJson)
1392
+ } catch (e: UnsatisfiedLinkError) {
1393
+ false
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * The shared-preferences trio behind the page's `_craftSharedKeychain*`
1399
+ * globals. Each returns whether Zig took the action; the outcome reaches
1400
+ * the page through the reply channel.
1401
+ */
1402
+ fun setSharedItem(activity: Activity, key: String, value: String, group: String): Boolean {
1403
+ if (!isAvailable) return false
1404
+ return try {
1405
+ nativeSetSharedItem(activity, key, value, group)
1406
+ } catch (e: UnsatisfiedLinkError) {
1407
+ false
1408
+ }
1409
+ }
1410
+
1411
+ fun getSharedItem(activity: Activity, key: String, group: String): Boolean {
1412
+ if (!isAvailable) return false
1413
+ return try {
1414
+ nativeGetSharedItem(activity, key, group)
1415
+ } catch (e: UnsatisfiedLinkError) {
1416
+ false
1417
+ }
1418
+ }
1419
+
1420
+ fun removeSharedItem(activity: Activity, key: String, group: String): Boolean {
1421
+ if (!isAvailable) return false
1422
+ return try {
1423
+ nativeRemoveSharedItem(activity, key, group)
1424
+ } catch (e: UnsatisfiedLinkError) {
1425
+ false
1426
+ }
1427
+ }
1428
+
1429
+ /**
1430
+ * Returns whether Zig took the read, not whether it found anyone.
1431
+ *
1432
+ * An empty address book still answers — with `[]`, through the reply
1433
+ * channel — so a false here means Zig did not run.
1434
+ */
1435
+ fun getContacts(activity: Activity): Boolean {
1436
+ if (!isAvailable) return false
1437
+ return try {
1438
+ nativeGetContacts(activity)
1439
+ } catch (e: UnsatisfiedLinkError) {
1440
+ false
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Returns whether Zig took the write.
1446
+ *
1447
+ * A false is routine rather than exceptional: Zig serves the shape the
1448
+ * page's own SDK sends and hands anything else back, because `org.json`
1449
+ * coerces where a strict parser refuses.
1450
+ */
1451
+ fun addContact(activity: Activity, contactJson: String): Boolean {
1452
+ if (!isAvailable) return false
1453
+ return try {
1454
+ nativeAddContact(activity, contactJson)
1455
+ } catch (e: UnsatisfiedLinkError) {
1456
+ false
1457
+ }
1458
+ }
1459
+
1460
+ /** Launches the single-contact picker on the main looper. */
1461
+ fun pickContact(activity: Activity): Boolean {
1462
+ if (!isAvailable) return false
1463
+ return try {
1464
+ nativePickContact(activity)
1465
+ } catch (e: UnsatisfiedLinkError) {
1466
+ false
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * The widget pair. `action` is the caller's own broadcast constant.
1472
+ *
1473
+ * Zig could build it from `activity.packageName`, and today that is the
1474
+ * same string — but an `applicationIdSuffix` on a build type would move
1475
+ * the package name and leave the constant where it was, and the widget
1476
+ * would silently stop updating. Only one side gets to decide.
1477
+ */
1478
+ fun updateWidget(activity: Activity, action: String, dataJson: String): Boolean {
1479
+ if (!isAvailable) return false
1480
+ return try {
1481
+ nativeUpdateWidget(activity, action, dataJson)
1482
+ } catch (e: UnsatisfiedLinkError) {
1483
+ false
1484
+ }
1485
+ }
1486
+
1487
+ fun reloadWidgets(activity: Activity, action: String): Boolean {
1488
+ if (!isAvailable) return false
1489
+ return try {
1490
+ nativeReloadWidgets(activity, action)
1491
+ } catch (e: UnsatisfiedLinkError) {
1492
+ false
1493
+ }
1494
+ }
1495
+
1496
+ /**
1497
+ * The launcher's long-press menu.
1498
+ *
1499
+ * Both handle the pre-25 case themselves, and asymmetrically, because the
1500
+ * Kotlin does: setting rejects with a message and clearing resolves true.
1501
+ */
1502
+ fun setShortcuts(activity: Activity, shortcutsJson: String): Boolean {
1503
+ if (!isAvailable) return false
1504
+ return try {
1505
+ nativeSetShortcuts(activity, shortcutsJson)
1506
+ } catch (e: UnsatisfiedLinkError) {
1507
+ false
1508
+ }
1509
+ }
1510
+
1511
+ fun clearShortcuts(activity: Activity): Boolean {
1512
+ if (!isAvailable) return false
1513
+ return try {
1514
+ nativeClearShortcuts(activity)
1515
+ } catch (e: UnsatisfiedLinkError) {
1516
+ false
1517
+ }
1518
+ }
1519
+
1520
+ /**
1521
+ * Returns whether Zig posted the notification.
1522
+ *
1523
+ * A false is routine on one path in particular: a `delay` above zero needs
1524
+ * `Handler.postDelayed`, and Zig cannot make a `Runnable`. It hands those
1525
+ * back before creating the channel, so the Kotlin below does the whole
1526
+ * thing rather than half of it twice.
1527
+ */
1528
+ fun scheduleNotification(activity: Activity, notificationJson: String): Boolean {
1529
+ if (!isAvailable) return false
1530
+ return try {
1531
+ nativeScheduleNotification(activity, notificationJson)
1532
+ } catch (e: UnsatisfiedLinkError) {
1533
+ false
1534
+ }
1535
+ }
1536
+
1537
+ /**
1538
+ * How Zig reaches the main looper.
1539
+ *
1540
+ * `Window.addFlags` and `setRequestedOrientation` have to run there, and
1541
+ * the shim wraps each in `runOnUiThread { ... }` — which takes a
1542
+ * `Runnable`, a Java object implementing a Java interface. JNI cannot make
1543
+ * one: `RegisterNatives` binds methods onto a class that already exists in
1544
+ * the APK, so a native can be *called* from Java but cannot be handed to
1545
+ * Java as an object.
1546
+ *
1547
+ * So the `Runnable` is here, the work is there, and a token says which
1548
+ * work. The Activity goes across with it rather than being held by Zig
1549
+ * over the hop, which is what would need a global reference and a matching
1550
+ * release.
1551
+ *
1552
+ * Called from Zig, never from the Kotlin below.
1553
+ */
1554
+ @JvmStatic
1555
+ fun runOnMain(activity: Activity, token: Long, delayMs: Long) {
1556
+ val taskGeneration = lifecycleGeneration.get()
1557
+ val block = Runnable {
1558
+ try {
1559
+ if (taskGeneration != lifecycleGeneration.get() || activity.isFinishing || activity.isDestroyed) {
1560
+ nativeCancelTask(token)
1561
+ return@Runnable
1562
+ }
1563
+ nativeRunTask(activity, token)
1564
+ } catch (e: UnsatisfiedLinkError) {
1565
+ // The library went away between the post and the looper. There
1566
+ // is nothing to answer to here and nothing to retry.
1567
+ }
1568
+ }
1569
+ if (delayMs > 0) {
1570
+ Handler(Looper.getMainLooper()).postDelayed(block, delayMs)
1571
+ } else {
1572
+ activity.runOnUiThread(block)
1573
+ }
1574
+ }
1575
+
1576
+ /**
1577
+ * Returns whether Zig queued the work, not whether the device has turned.
1578
+ *
1579
+ * The shim returns true after `runOnUiThread` for the same reason: the
1580
+ * JavaBridge thread is never the main thread, so the block always runs
1581
+ * later than the answer.
1582
+ */
1583
+ fun lockOrientation(activity: Activity, orientation: String): Boolean {
1584
+ if (!isAvailable) return false
1585
+ return try {
1586
+ nativeLockOrientation(activity, orientation)
1587
+ } catch (e: UnsatisfiedLinkError) {
1588
+ false
1589
+ }
1590
+ }
1591
+
1592
+ fun unlockOrientation(activity: Activity): Boolean {
1593
+ if (!isAvailable) return false
1594
+ return try {
1595
+ nativeUnlockOrientation(activity)
1596
+ } catch (e: UnsatisfiedLinkError) {
1597
+ false
1598
+ }
1599
+ }
1600
+
1601
+ fun setKeepAwake(activity: Activity, enabled: Boolean): Boolean {
1602
+ if (!isAvailable) return false
1603
+ return try {
1604
+ nativeSetKeepAwake(activity, enabled)
1605
+ } catch (e: UnsatisfiedLinkError) {
1606
+ false
1607
+ }
1608
+ }
1609
+
1610
+ /** Both answer through the reply channel; true means Zig took the action. */
1611
+ fun downloadFile(activity: Activity, url: String, filename: String): Boolean {
1612
+ if (!isAvailable) return false
1613
+ return try {
1614
+ nativeDownloadFile(activity, url, filename)
1615
+ } catch (e: UnsatisfiedLinkError) {
1616
+ false
1617
+ }
1618
+ }
1619
+
1620
+ fun saveFile(activity: Activity, data: String, filename: String): Boolean {
1621
+ if (!isAvailable) return false
1622
+ return try {
1623
+ nativeSaveFile(activity, data, filename)
1624
+ } catch (e: UnsatisfiedLinkError) {
1625
+ false
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * The read side of the recording store. Null is "Zig declined", not "no
1631
+ * recording" — an absent recording is a state object saying so.
1632
+ */
1633
+ fun getLocationRecordingState(activity: Activity): String? {
1634
+ if (!isAvailable) return null
1635
+ return try {
1636
+ nativeGetLocationRecordingState(activity)
1637
+ } catch (e: UnsatisfiedLinkError) {
1638
+ null
1639
+ }
1640
+ }
1641
+
1642
+ fun readLocationRecording(activity: Activity): String? {
1643
+ if (!isAvailable) return null
1644
+ return try {
1645
+ nativeReadLocationRecording(activity)
1646
+ } catch (e: UnsatisfiedLinkError) {
1647
+ null
1648
+ }
1649
+ }
1650
+
1651
+ /**
1652
+ * The four controls. Each returns the store's state as JSON, or null when
1653
+ * Zig declined and the Kotlin below should answer instead.
1654
+ */
1655
+ fun startLocationRecording(activity: Activity): String? {
1656
+ if (!isAvailable) return null
1657
+ return try {
1658
+ nativeStartLocationRecording(activity)
1659
+ } catch (e: UnsatisfiedLinkError) {
1660
+ null
1661
+ }
1662
+ }
1663
+
1664
+ fun stopLocationRecording(activity: Activity): String? {
1665
+ if (!isAvailable) return null
1666
+ return try {
1667
+ nativeStopLocationRecording(activity)
1668
+ } catch (e: UnsatisfiedLinkError) {
1669
+ null
1670
+ }
1671
+ }
1672
+
1673
+ fun pauseLocationRecording(activity: Activity): String? {
1674
+ if (!isAvailable) return null
1675
+ return try {
1676
+ nativePauseLocationRecording(activity)
1677
+ } catch (e: UnsatisfiedLinkError) {
1678
+ null
1679
+ }
1680
+ }
1681
+
1682
+ fun resumeLocationRecording(activity: Activity): String? {
1683
+ if (!isAvailable) return null
1684
+ return try {
1685
+ nativeResumeLocationRecording(activity)
1686
+ } catch (e: UnsatisfiedLinkError) {
1687
+ null
1688
+ }
1689
+ }
1690
+
1691
+ /**
1692
+ * Start and stop the recording service.
1693
+ *
1694
+ * Called from Zig, and here rather than there because
1695
+ * `LocationRecordingService::class.java` names a class in this holder's
1696
+ * fixed runtime package. Keeping both classes together lets the prebuilt
1697
+ * native runtime call these helpers without guessing the generated app's
1698
+ * package or confusing it with an `applicationIdSuffix`.
1699
+ */
1700
+ @JvmStatic
1701
+ fun startRecordingService(activity: Activity) {
1702
+ ContextCompat.startForegroundService(
1703
+ activity,
1704
+ Intent(activity, LocationRecordingService::class.java)
1705
+ .setAction(LocationRecordingService.ACTION_START)
1706
+ )
1707
+ }
1708
+
1709
+ @JvmStatic
1710
+ fun stopRecordingService(activity: Activity) {
1711
+ activity.stopService(
1712
+ Intent(activity, LocationRecordingService::class.java)
1713
+ .setAction(LocationRecordingService.ACTION_STOP)
1714
+ )
1715
+ }
1716
+
1717
+ // ==================== Network monitoring ====================
1718
+ //
1719
+ // The first listener shim. `ConnectivityManager.NetworkCallback` is an
1720
+ // abstract Java class, and JNI cannot subclass one — `RegisterNatives`
1721
+ // binds methods onto a class that already exists in the APK, so a native
1722
+ // can be *called* from Java but cannot be a Java object of a type the
1723
+ // framework demands.
1724
+ //
1725
+ // The split is the one `runOnMain` uses: the object lives here, the work
1726
+ // lives in Zig. This class holds the callback and the registration; every
1727
+ // override forwards to `nativeNetworkChanged`, which reads the status and
1728
+ // sends the event.
1729
+ //
1730
+ // Unlike `runOnMain` there is no token, because there is only ever one
1731
+ // watch. Repeated starts keep that existing registration so the callback
1732
+ // remains reachable and each network change is delivered once.
1733
+
1734
+ private var networkWatch: ConnectivityManager.NetworkCallback? = null
1735
+
1736
+ /** Register the process-wide network callback once. */
1737
+ @JvmStatic
1738
+ fun startNetworkWatch(activity: Activity) {
1739
+ if (networkWatch != null) return
1740
+
1741
+ val manager =
1742
+ activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1743
+
1744
+ val watch = object : ConnectivityManager.NetworkCallback() {
1745
+ override fun onAvailable(network: Network) = changed(activity)
1746
+ override fun onLost(network: Network) = changed(activity)
1747
+ override fun onCapabilitiesChanged(
1748
+ network: Network,
1749
+ networkCapabilities: NetworkCapabilities
1750
+ ) = changed(activity)
1751
+ }
1752
+
1753
+ val request = NetworkRequest.Builder()
1754
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1755
+ .build()
1756
+
1757
+ manager.registerNetworkCallback(request, watch)
1758
+ networkWatch = watch
1759
+ }
1760
+
1761
+ @JvmStatic
1762
+ fun stopNetworkWatch(activity: Activity) {
1763
+ val manager =
1764
+ activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1765
+ networkWatch?.let { manager.unregisterNetworkCallback(it) }
1766
+ networkWatch = null
1767
+ }
1768
+
1769
+ /**
1770
+ * Every override lands here.
1771
+ *
1772
+ * This runs on a Binder thread, not the main one — which is fine, because
1773
+ * the reply channel attaches the thread it is called on and hops to the
1774
+ * main looper itself.
1775
+ */
1776
+ private fun changed(activity: Activity) {
1777
+ try {
1778
+ nativeNetworkChanged(activity)
1779
+ } catch (e: UnsatisfiedLinkError) {
1780
+ // The library went away. There is no promise waiting on this and
1781
+ // nothing to retry.
1782
+ }
1783
+ }
1784
+
1785
+ fun startNetworkMonitoring(activity: Activity): Boolean {
1786
+ if (!isAvailable) return false
1787
+ return try {
1788
+ nativeStartNetworkMonitoring(activity)
1789
+ } catch (e: UnsatisfiedLinkError) {
1790
+ false
1791
+ }
1792
+ }
1793
+
1794
+ fun stopNetworkMonitoring(activity: Activity): Boolean {
1795
+ if (!isAvailable) return false
1796
+ return try {
1797
+ nativeStopNetworkMonitoring(activity)
1798
+ } catch (e: UnsatisfiedLinkError) {
1799
+ false
1800
+ }
1801
+ }
1802
+
1803
+ // ==================== App state ====================
1804
+ //
1805
+ // The second listener shim. `LifecycleEventObserver` is a Java interface,
1806
+ // so the object is here; the mapping, the state and the two deliveries are
1807
+ // in Zig.
1808
+ //
1809
+ // One observer instance, added and removed — `LifecycleRegistry` keys its
1810
+ // observers by identity, so adding twice is a no-op. That is why this one
1811
+ // has no leak to reproduce, unlike the network watch.
1812
+ //
1813
+ // The native takes no Activity: everything it does is state and the reply
1814
+ // channel, and neither needs one.
1815
+
1816
+ private val appStateObserver = LifecycleEventObserver { _, event ->
1817
+ try {
1818
+ nativeAppStateEvent(event.name)
1819
+ } catch (e: UnsatisfiedLinkError) {
1820
+ // The library went away. Nothing is waiting on this.
1821
+ }
1822
+ }
1823
+
1824
+ @JvmStatic
1825
+ fun startAppStateWatch(activity: Activity) {
1826
+ activity.runOnUiThread {
1827
+ ProcessLifecycleOwner.get().lifecycle.addObserver(appStateObserver)
1828
+ }
1829
+ }
1830
+
1831
+ @JvmStatic
1832
+ fun stopAppStateWatch(activity: Activity) {
1833
+ activity.runOnUiThread {
1834
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(appStateObserver)
1835
+ }
1836
+ }
1837
+
1838
+ fun startAppStateMonitoring(activity: Activity): Boolean {
1839
+ if (!isAvailable) return false
1840
+ return try {
1841
+ nativeStartAppStateMonitoring(activity)
1842
+ } catch (e: UnsatisfiedLinkError) {
1843
+ false
1844
+ }
1845
+ }
1846
+
1847
+ fun stopAppStateMonitoring(activity: Activity): Boolean {
1848
+ if (!isAvailable) return false
1849
+ return try {
1850
+ nativeStopAppStateMonitoring(activity)
1851
+ } catch (e: UnsatisfiedLinkError) {
1852
+ false
1853
+ }
1854
+ }
1855
+
1856
+ /** Null is "Zig declined"; the state itself is never null. */
1857
+ fun getAppState(): String? {
1858
+ if (!isAvailable) return null
1859
+ return try {
1860
+ nativeGetAppState()
1861
+ } catch (e: UnsatisfiedLinkError) {
1862
+ null
1863
+ }
1864
+ }
1865
+
1866
+ // ==================== Bluetooth scanning ====================
1867
+ //
1868
+ // The third listener shim. `ScanCallback` is an abstract Java class, so
1869
+ // the object is here and every result forwards to Zig — with the device
1870
+ // name still nullable, so the "Unknown" default stays one decision in one
1871
+ // place rather than two that have to agree.
1872
+
1873
+ private var bleScanner: BluetoothLeScanner? = null
1874
+ private var bleScanCallback: ScanCallback? = null
1875
+
1876
+ /**
1877
+ * Start scanning and report whether `startScan` was actually reached.
1878
+ * The status crosses JNI so Zig can reject with the same reason as the
1879
+ * Kotlin fallback instead of resolving an unavailable radio as success.
1880
+ */
1881
+ @JvmStatic
1882
+ fun startBluetoothWatch(activity: Activity): Int {
1883
+ val manager = activity.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
1884
+ ?: return BLUETOOTH_NO_ADAPTER
1885
+ val adapter = try {
1886
+ manager.adapter
1887
+ } catch (e: SecurityException) {
1888
+ return BLUETOOTH_PERMISSION_DENIED
1889
+ } ?: return BLUETOOTH_NO_ADAPTER
1890
+ val enabled = try {
1891
+ adapter.isEnabled
1892
+ } catch (e: SecurityException) {
1893
+ return BLUETOOTH_PERMISSION_DENIED
1894
+ }
1895
+ if (!enabled) return BLUETOOTH_POWERED_OFF
1896
+ val scanner = try {
1897
+ adapter.bluetoothLeScanner
1898
+ } catch (e: SecurityException) {
1899
+ return BLUETOOTH_PERMISSION_DENIED
1900
+ } ?: return BLUETOOTH_SCANNER_UNAVAILABLE
1901
+
1902
+ val callback = object : ScanCallback() {
1903
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
1904
+ try {
1905
+ nativeBluetoothDevice(
1906
+ result.device.address,
1907
+ result.device.name,
1908
+ result.rssi
1909
+ )
1910
+ } catch (e: UnsatisfiedLinkError) {
1911
+ // The library went away. Nothing is waiting on this.
1912
+ } catch (e: SecurityException) {
1913
+ // `device.name` needs BLUETOOTH_CONNECT on API 31+, which
1914
+ // this scan does not require. The shim would throw out of
1915
+ // the callback here; dropping the result is the smaller
1916
+ // difference, and it is the only one.
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ return try {
1922
+ scanner.startScan(callback)
1923
+ bleScanner = scanner
1924
+ bleScanCallback = callback
1925
+ BLUETOOTH_STARTED
1926
+ } catch (e: SecurityException) {
1927
+ BLUETOOTH_PERMISSION_DENIED
1928
+ } catch (e: RuntimeException) {
1929
+ BLUETOOTH_FAILED
1930
+ }
1931
+ }
1932
+
1933
+ @JvmStatic
1934
+ fun stopBluetoothWatch(activity: Activity) {
1935
+ bleScanCallback?.let { bleScanner?.stopScan(it) }
1936
+ bleScanCallback = null
1937
+ }
1938
+
1939
+ /**
1940
+ * Ask for BLUETOOTH_SCAN, but only where it exists.
1941
+ *
1942
+ * The permission arrived in API 31. Below that the shim does not ask at
1943
+ * all — it rejects and leaves it — so the version check lives here, next
1944
+ * to the call that would throw without it.
1945
+ */
1946
+ @JvmStatic
1947
+ fun requestBluetoothScanPermission(activity: Activity) {
1948
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1949
+ ActivityCompat.requestPermissions(
1950
+ activity,
1951
+ arrayOf(android.Manifest.permission.BLUETOOTH_SCAN),
1952
+ REQUEST_BLUETOOTH
1953
+ )
1954
+ }
1955
+ }
1956
+
1957
+ /** `CraftBridge.REQUEST_BLUETOOTH`, which this has to match. */
1958
+ private const val REQUEST_BLUETOOTH = 1009
1959
+ private const val BLUETOOTH_STARTED = 0
1960
+ private const val BLUETOOTH_NO_ADAPTER = 1
1961
+ private const val BLUETOOTH_POWERED_OFF = 2
1962
+ private const val BLUETOOTH_PERMISSION_DENIED = 3
1963
+ private const val BLUETOOTH_SCANNER_UNAVAILABLE = 4
1964
+ private const val BLUETOOTH_FAILED = 5
1965
+
1966
+ fun startBluetoothScan(activity: Activity): Boolean {
1967
+ if (!isAvailable) return false
1968
+ return try {
1969
+ nativeStartBluetoothScan(activity)
1970
+ } catch (e: UnsatisfiedLinkError) {
1971
+ false
1972
+ }
1973
+ }
1974
+
1975
+ fun stopBluetoothScan(activity: Activity): Boolean {
1976
+ if (!isAvailable) return false
1977
+ return try {
1978
+ nativeStopBluetoothScan(activity)
1979
+ } catch (e: UnsatisfiedLinkError) {
1980
+ false
1981
+ }
1982
+ }
1983
+
1984
+ // ==================== Motion sensors ====================
1985
+ //
1986
+ // The fourth listener shim. `SensorEventListener` is a Java interface, so
1987
+ // the object is here — but only the object: which sensor a sample came
1988
+ // from is passed across as a boolean and everything else, including the
1989
+ // last-value bookkeeping and the delay the interval maps to, is Zig's.
1990
+ //
1991
+ // `registerListener` without a Handler delivers on the main looper, so
1992
+ // every sample arrives on one thread and the state on the other side
1993
+ // needs no lock.
1994
+
1995
+ private var sensorManager: SensorManager? = null
1996
+ private var motionListener: SensorEventListener? = null
1997
+
1998
+ @JvmStatic
1999
+ fun startMotionWatch(activity: Activity, delay: Int) {
2000
+ val manager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
2001
+ sensorManager = manager
2002
+
2003
+ val accelerometer = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
2004
+ val gyroscope = manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
2005
+
2006
+ val listener = object : SensorEventListener {
2007
+ override fun onSensorChanged(event: SensorEvent) {
2008
+ try {
2009
+ nativeMotionSample(
2010
+ event.sensor.type == Sensor.TYPE_ACCELEROMETER,
2011
+ event.values[0],
2012
+ event.values[1],
2013
+ event.values[2]
2014
+ )
2015
+ } catch (e: UnsatisfiedLinkError) {
2016
+ // The library went away. Nothing is waiting on this.
2017
+ }
2018
+ }
2019
+
2020
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
2021
+ }
2022
+
2023
+ accelerometer?.let { manager.registerListener(listener, it, delay) }
2024
+ gyroscope?.let { manager.registerListener(listener, it, delay) }
2025
+ motionListener = listener
2026
+ }
2027
+
2028
+ @JvmStatic
2029
+ fun stopMotionWatch(activity: Activity) {
2030
+ motionListener?.let { sensorManager?.unregisterListener(it) }
2031
+ motionListener = null
2032
+ }
2033
+
2034
+ fun startMotionUpdates(activity: Activity, intervalMs: Int): Boolean {
2035
+ if (!isAvailable) return false
2036
+ return try {
2037
+ nativeStartMotionUpdates(activity, intervalMs)
2038
+ } catch (e: UnsatisfiedLinkError) {
2039
+ false
2040
+ }
2041
+ }
2042
+
2043
+ fun stopMotionUpdates(activity: Activity): Boolean {
2044
+ if (!isAvailable) return false
2045
+ return try {
2046
+ nativeStopMotionUpdates(activity)
2047
+ } catch (e: UnsatisfiedLinkError) {
2048
+ false
2049
+ }
2050
+ }
2051
+
2052
+ // ==================== Current position ====================
2053
+ //
2054
+ // The fifth listener shim, and the first with two Java interfaces in one
2055
+ // flow: `OnSuccessListener` and `OnFailureListener` on the Play Services
2056
+ // task, plus a `LocationCallback` for the fresh-location fallback.
2057
+ //
2058
+ // Every value crosses as a Double, including the three the framework types
2059
+ // as Float. That is not a convenience: `CraftBridge` writes them with
2060
+ // `put(name, value)`, and the overload Kotlin picks there is
2061
+ // `put(String, double)` — primitive widening beats boxing — so they print
2062
+ // as doubles. Passing them as Float and boxing them on the other side
2063
+ // would print `0.1` where the shim prints `0.10000000149011612`.
2064
+
2065
+ @JvmStatic
2066
+ fun requestLocationPermissions(activity: Activity) {
2067
+ ActivityCompat.requestPermissions(
2068
+ activity,
2069
+ arrayOf(
2070
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
2071
+ android.Manifest.permission.ACCESS_COARSE_LOCATION
2072
+ ),
2073
+ REQUEST_LOCATION
2074
+ )
2075
+ }
2076
+
2077
+ /** `CraftBridge.REQUEST_LOCATION`, which this has to match. */
2078
+ private const val REQUEST_LOCATION = 1003
2079
+ private val currentLocationCallbacks = mutableMapOf<LocationCallback, FusedLocationProviderClient>()
2080
+ private val currentLocationHandler = Handler(Looper.getMainLooper())
2081
+ private val currentLocationTimeouts = mutableMapOf<LocationCallback, Runnable>()
2082
+ private val currentPositionSequence = java.util.concurrent.atomic.AtomicLong(0)
2083
+ private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
2084
+
2085
+ @JvmStatic
2086
+ @SuppressLint("MissingPermission")
2087
+ fun requestCurrentPosition(activity: Activity) {
2088
+ val requestId = currentPositionSequence.incrementAndGet()
2089
+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
2090
+
2091
+ fun resolveLocationRequest(location: Location) {
2092
+ if (requestId != currentPositionSequence.get()) return
2093
+ if (!settled.compareAndSet(false, true)) return
2094
+ deliverLocation(location)
2095
+ }
2096
+
2097
+ fun rejectLocationRequest(message: String?) {
2098
+ if (requestId != currentPositionSequence.get()) return
2099
+ if (!settled.compareAndSet(false, true)) return
2100
+ failLocation(message)
2101
+ }
2102
+
2103
+ currentLocationCallbacks.forEach { (callback, client) ->
2104
+ runCatching { client.removeLocationUpdates(callback) }
2105
+ }
2106
+ currentLocationCallbacks.clear()
2107
+ currentLocationTimeouts.values.forEach { timeout ->
2108
+ currentLocationHandler.removeCallbacks(timeout)
2109
+ }
2110
+ currentLocationTimeouts.clear()
2111
+
2112
+ if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(activity) != ConnectionResult.SUCCESS) {
2113
+ rejectLocationRequest("Google Play Services is unavailable")
2114
+ return
2115
+ }
2116
+ try {
2117
+ val client = LocationServices.getFusedLocationProviderClient(activity)
2118
+ client.lastLocation
2119
+ .addOnSuccessListener { location: Location? ->
2120
+ if (requestId != currentPositionSequence.get()) return@addOnSuccessListener
2121
+ if (location != null) {
2122
+ resolveLocationRequest(location)
2123
+ } else {
2124
+ requestFreshPosition(
2125
+ activity,
2126
+ client,
2127
+ requestId,
2128
+ ::resolveLocationRequest,
2129
+ ::rejectLocationRequest
2130
+ )
2131
+ }
2132
+ }
2133
+ .addOnFailureListener { error -> rejectLocationRequest(error.message) }
2134
+ } catch (error: Exception) {
2135
+ rejectLocationRequest(error.message)
2136
+ }
2137
+ }
2138
+
2139
+ /**
2140
+ * The fallback when there is no cached fix.
2141
+ *
2142
+ * One update, then the callback removes itself — as `CraftBridge`'s does.
2143
+ * The page owns the provider-silence timeout; the request sequence here
2144
+ * ensures a callback arriving after that timeout cannot settle its successor.
2145
+ */
2146
+ @SuppressLint("MissingPermission")
2147
+ private fun requestFreshPosition(
2148
+ activity: Activity,
2149
+ client: FusedLocationProviderClient,
2150
+ requestId: Long,
2151
+ resolve: (Location) -> Unit,
2152
+ reject: (String?) -> Unit
2153
+ ) {
2154
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
2155
+ .setWaitForAccurateLocation(false)
2156
+ .setMinUpdateIntervalMillis(500)
2157
+ .setMaxUpdateDelayMillis(1000)
2158
+ .setMaxUpdates(1)
2159
+ .build()
2160
+
2161
+ val callback = object : LocationCallback() {
2162
+ override fun onLocationResult(result: LocationResult) {
2163
+ clearCurrentLocationCallback(client, this)
2164
+ if (requestId != currentPositionSequence.get()) return
2165
+ result.lastLocation?.let(resolve)
2166
+ ?: reject("Location provider returned no position")
2167
+ }
2168
+ }
2169
+ currentLocationCallbacks[callback] = client
2170
+ val timeout = Runnable {
2171
+ if (!currentLocationCallbacks.containsKey(callback)) return@Runnable
2172
+ clearCurrentLocationCallback(client, callback)
2173
+ reject("Location request timed out; provider returned no position")
2174
+ }
2175
+ currentLocationTimeouts[callback] = timeout
2176
+ currentLocationHandler.postDelayed(timeout, LOCATION_REQUEST_TIMEOUT_MS)
2177
+ try {
2178
+ client.requestLocationUpdates(request, callback, activity.mainLooper)
2179
+ .addOnFailureListener { error ->
2180
+ clearCurrentLocationCallback(client, callback)
2181
+ reject(error.message)
2182
+ }
2183
+ } catch (error: Exception) {
2184
+ clearCurrentLocationCallback(client, callback)
2185
+ reject(error.message)
2186
+ }
2187
+ }
2188
+
2189
+ private fun clearCurrentLocationCallback(
2190
+ client: FusedLocationProviderClient,
2191
+ callback: LocationCallback
2192
+ ) {
2193
+ runCatching { client.removeLocationUpdates(callback) }
2194
+ currentLocationCallbacks.remove(callback)
2195
+ currentLocationTimeouts.remove(callback)?.let { timeout ->
2196
+ currentLocationHandler.removeCallbacks(timeout)
2197
+ }
2198
+ }
2199
+
2200
+ private fun deliverLocation(location: Location) {
2201
+ try {
2202
+ nativeLocationResult(
2203
+ location.latitude,
2204
+ location.longitude,
2205
+ location.accuracy.toDouble(),
2206
+ location.altitude,
2207
+ location.speed.toDouble(),
2208
+ location.bearing.toDouble(),
2209
+ location.time
2210
+ )
2211
+ } catch (e: UnsatisfiedLinkError) {
2212
+ // The library went away mid-flight. Nothing to answer to.
2213
+ }
2214
+ }
2215
+
2216
+ private fun failLocation(message: String?) {
2217
+ try {
2218
+ nativeLocationFailed(message)
2219
+ } catch (e: UnsatisfiedLinkError) {
2220
+ }
2221
+ }
2222
+
2223
+ fun getCurrentPosition(activity: Activity): Boolean {
2224
+ if (!isAvailable) return false
2225
+ return try {
2226
+ nativeGetCurrentPosition(activity)
2227
+ } catch (e: UnsatisfiedLinkError) {
2228
+ false
2229
+ }
2230
+ }
2231
+ }