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.
- package/dist/android/src/index.js +285 -44
- package/dist/android/src/promise-runtime.d.ts +3 -0
- package/dist/android/templates/CraftBridge.kt.template +1951 -1193
- package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
- package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
- package/dist/android/templates/CraftNative.kt.template +2231 -0
- package/dist/android/templates/LocationRecordingService.kt.template +34 -9
- package/dist/android/templates/MainActivity.kt.template +25 -2
- package/dist/android/templates/proguard-rules.pro.template +4 -1
- package/dist/android/templates/test-bridges.html +10 -33
- package/dist/api/index.d.ts +1 -1
- package/dist/api/ios-advanced.d.ts +8 -5
- package/dist/api/live-activity-handle.d.ts +6 -0
- package/dist/api/mobile.d.ts +13 -5
- package/dist/api/window.d.ts +2 -0
- package/dist/cli.js +404 -128
- package/dist/index.cjs +65 -17
- package/dist/index.js +65 -17
- package/dist/ios/src/index.js +22 -4
- package/dist/ios/templates/CraftApp.swift +473 -60
- package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
- package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
- package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
- package/dist/ios/templates/project.yml.template +10 -4
- package/dist/mobile.js +36 -13
- package/dist/scaffold-version.d.ts +5 -0
- package/package.json +1 -1
- package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
- 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
|
+
}
|