craft-native 0.0.90 → 0.0.92

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