craft-native 0.0.168 → 0.0.169

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.
@@ -0,0 +1,561 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.ActivityNotFoundException
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.content.pm.PackageManager
9
+ import android.net.Uri
10
+ import android.os.Build
11
+ import android.os.Handler
12
+ import android.os.Looper
13
+ import android.provider.Settings
14
+ import android.speech.tts.TextToSpeech
15
+ import android.speech.tts.UtteranceProgressListener
16
+ import android.util.Log
17
+ import android.view.WindowManager
18
+ import androidx.appcompat.app.AlertDialog
19
+ import androidx.browser.customtabs.CustomTabsIntent
20
+ import androidx.core.app.ActivityCompat
21
+ import androidx.core.app.NotificationManagerCompat
22
+ import androidx.core.content.ContextCompat
23
+ import androidx.core.content.FileProvider
24
+ import androidx.core.view.WindowCompat
25
+ import org.json.JSONArray
26
+ import org.json.JSONObject
27
+ import java.io.File
28
+ import java.util.Locale
29
+ import java.util.UUID
30
+ {{NATIVE_PUSH_IMPORTS}}
31
+
32
+ /**
33
+ * The capabilities a native screen asks for beyond Craft's core set
34
+ * (CraftNativeCapabilities): the system's dialogs, the in-app browser, the
35
+ * share sheet, other apps (`Linking`), push, Health Connect (`Health`,
36
+ * CraftNativeHealth.kt), keeping the screen awake, its brightness, the
37
+ * status bar, speech, and the answer to a background refresh. The same
38
+ * module and method names as the iOS host (CraftNativeActions.swift), so a
39
+ * screen's `craft.dialog`, `craft.browser`, `craft.share`, `craft.push`,
40
+ * `craft.health`... work unchanged.
41
+ *
42
+ * `activity` is null in a background worker, where nothing a person
43
+ * answers can be shown: those calls fail with `NEEDS_ACTIVITY`.
44
+ */
45
+ internal class CraftNativeServices(
46
+ private val context: Context,
47
+ private val config: JSONObject,
48
+ private val activity: () -> Activity?,
49
+ ) {
50
+ private val main = Handler(Looper.getMainLooper())
51
+ private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
52
+ CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
53
+ } else null
54
+ private var pendingPushPermission: ((CraftNativeCapabilityResult) -> Unit)? = null
55
+ private var pendingBrowser: Pair<String?, (CraftNativeCapabilityResult) -> Unit>? = null
56
+ private var browserOpened = false
57
+ private var speech: TextToSpeech? = null
58
+ private var speechReady = false
59
+ private val speechQueue = mutableListOf<() -> Unit>()
60
+ private val speeches = mutableMapOf<String, (CraftNativeCapabilityResult) -> Unit>()
61
+
62
+ /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
63
+ var onBackgroundComplete: ((Boolean, String?) -> Unit)? = null
64
+
65
+ /** What this host adds to the capability names the screen sees. */
66
+ fun names(): List<String> = buildList {
67
+ add("dialog")
68
+ add("browser")
69
+ add("linking")
70
+ add("brightness")
71
+ add("statusBar")
72
+ add("speech")
73
+ // Videos kept on the phone for offline (CraftNativeMedia).
74
+ add("media")
75
+ if (config.optBoolean("enableShare")) add("share")
76
+ if (config.optBoolean("enablePushNotifications")) add("push")
77
+ if (health != null) {
78
+ add("health")
79
+ // Health Connect may need installing or updating, and its grants
80
+ // are managed in its own screen: `health.availability()`,
81
+ // `health.grantedTypes()` and `health.openSettings()`.
82
+ add("healthAvailability")
83
+ }
84
+ if (config.optBoolean("enableKeepAwake")) add("keepAwake")
85
+ if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) add("geolocation")
86
+ if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
87
+ }
88
+
89
+ fun handles(module: String): Boolean = module in setOf(
90
+ "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
91
+ "Location", "Media",
92
+ )
93
+
94
+ fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
95
+ val done: (CraftNativeCapabilityResult) -> Unit = { result ->
96
+ if (Looper.myLooper() == Looper.getMainLooper()) completion(result) else main.post { completion(result) }
97
+ }
98
+ try {
99
+ when (module) {
100
+ "Dialog" -> dialog(method, args.optJSONObject(0) ?: JSONObject(), done)
101
+ "Browser" -> browser(args.optString(0), args.optJSONObject(1) ?: JSONObject(), done)
102
+ "Linking" -> linking(method, args.optString(0), done)
103
+ "Share" -> share(args.optJSONObject(0) ?: JSONObject(), done)
104
+ "Push" -> push(method, done)
105
+ "Health" -> health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health Connect is not enabled for this app"))
106
+ "KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
107
+ "Brightness" -> brightness(method, args, done)
108
+ "StatusBar" -> statusBar(args.optString(0, "default"), done)
109
+ "Speech" -> speech(method, args, done)
110
+ "Location" -> location(method, args, done)
111
+ "Media" -> when (method) {
112
+ "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), done)
113
+ "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
114
+ "prune" -> {
115
+ val keep = args.optJSONArray(0) ?: JSONArray()
116
+ done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
117
+ }
118
+ "usage" -> done(success(CraftNativeMedia.usage(context)))
119
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
120
+ }
121
+ "Background" -> {
122
+ onBackgroundComplete?.invoke(args.optBoolean(0, true), args.opt(1) as? String)
123
+ done(success(true))
124
+ }
125
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported native API"))
126
+ }
127
+ } catch (error: Exception) {
128
+ Log.e("CraftNativeAndroid", "$module.$method failed", error)
129
+ done(failure("CRAFT_ERROR", error.message ?: "$module.$method failed"))
130
+ }
131
+ }
132
+
133
+ // MARK: Dialogs
134
+
135
+ private fun dialog(method: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
136
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to ask on"))
137
+ val builder = AlertDialog.Builder(host)
138
+ options.optString("title").takeIf { it.isNotBlank() }?.let(builder::setTitle)
139
+ options.optString("message").takeIf { it.isNotBlank() }?.let(builder::setMessage)
140
+ var answered = false
141
+ fun answer(result: CraftNativeCapabilityResult) {
142
+ if (answered) return
143
+ answered = true
144
+ done(result)
145
+ }
146
+ when (method) {
147
+ "alert" -> {
148
+ builder.setPositiveButton(options.optString("okLabel").ifBlank { host.getString(android.R.string.ok) }) { _, _ -> answer(success(JSONObject.NULL)) }
149
+ builder.setOnDismissListener { answer(success(JSONObject.NULL)) }
150
+ }
151
+ "confirm" -> {
152
+ builder.setPositiveButton(options.optString("confirmLabel").ifBlank { host.getString(android.R.string.ok) }) { _, _ -> answer(success(true)) }
153
+ builder.setNegativeButton(options.optString("cancelLabel").ifBlank { host.getString(android.R.string.cancel) }) { _, _ -> answer(success(false)) }
154
+ builder.setOnDismissListener { answer(success(false)) }
155
+ }
156
+ "actionSheet" -> {
157
+ val actions = options.optJSONArray("actions") ?: JSONArray()
158
+ val choices = mutableListOf<Pair<String, String>>()
159
+ var cancelTitle: String? = null
160
+ for (index in 0 until actions.length()) {
161
+ val action = actions.optJSONObject(index) ?: continue
162
+ val title = action.optString("title")
163
+ if (action.optString("style") == "cancel") cancelTitle = title
164
+ else choices.add((action.optString("id").ifBlank { title }) to title)
165
+ }
166
+ // A message and a list cannot share an AlertDialog: the message
167
+ // goes under the title instead.
168
+ options.optString("message").takeIf { it.isNotBlank() }?.let { message ->
169
+ builder.setMessage(null)
170
+ builder.setTitle(listOfNotNull(options.optString("title").takeIf { it.isNotBlank() }, message).joinToString("\n"))
171
+ }
172
+ builder.setItems(choices.map { it.second }.toTypedArray()) { _, which -> answer(success(choices[which].first)) }
173
+ builder.setNegativeButton(cancelTitle ?: host.getString(android.R.string.cancel)) { _, _ -> answer(success(JSONObject.NULL)) }
174
+ builder.setOnDismissListener { answer(success(JSONObject.NULL)) }
175
+ }
176
+ else -> return done(failure("UNKNOWN_ACTION", "Unsupported Dialog method"))
177
+ }
178
+ val shown = builder.show()
179
+ if (method == "confirm" && options.optBoolean("destructive")) {
180
+ shown.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(android.graphics.Color.parseColor("#FF3B30"))
181
+ }
182
+ }
183
+
184
+ // MARK: The in-app browser
185
+
186
+ /**
187
+ * A page in a Custom Tab over the app. `mode: 'auth'` waits for the
188
+ * address the sign-in returns to (`callbackScheme`), which reaches the
189
+ * activity as a deep link and is handed here ([deepLink]). Otherwise
190
+ * the answer comes when the person is back in the app.
191
+ */
192
+ private fun browser(url: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
193
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to open a page over"))
194
+ val uri = runCatching { Uri.parse(url) }.getOrNull()
195
+ if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https")) return done(failure("INVALID_ARGUMENT", "browser.open needs an http or https URL"))
196
+ pendingBrowser?.second?.invoke(success(JSONObject().put("cancelled", true)))
197
+ val callback = options.optString("callbackScheme").takeIf { options.optString("mode") == "auth" && it.isNotBlank() }
198
+ pendingBrowser = callback to done
199
+ browserOpened = true
200
+ try {
201
+ CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(host, uri)
202
+ } catch (_: ActivityNotFoundException) {
203
+ host.startActivity(Intent(Intent.ACTION_VIEW, uri))
204
+ }
205
+ }
206
+
207
+ /** A deep link arrived; true when it was the answer to an auth browser (and is not the app's to route). */
208
+ fun deepLink(uri: Uri): Boolean {
209
+ val pending = pendingBrowser ?: return false
210
+ val scheme = pending.first ?: return false
211
+ if (!uri.scheme.equals(scheme, ignoreCase = true)) return false
212
+ pendingBrowser = null
213
+ browserOpened = false
214
+ pending.second(success(JSONObject().put("cancelled", false).put("url", uri.toString())))
215
+ return true
216
+ }
217
+
218
+ /** The activity is in front again: a page that was open was closed. */
219
+ fun resumed() {
220
+ if (!browserOpened) return
221
+ browserOpened = false
222
+ main.postDelayed({
223
+ // A sign-in's deep link resumes the activity too; it answers first.
224
+ val pending = pendingBrowser ?: return@postDelayed
225
+ pendingBrowser = null
226
+ pending.second(success(JSONObject().put("cancelled", true)))
227
+ }, 400)
228
+ }
229
+
230
+ // MARK: Other apps
231
+
232
+ private fun linking(method: String, url: String, done: (CraftNativeCapabilityResult) -> Unit) {
233
+ val intent = intentFor(url) ?: return done(success(false))
234
+ when (method) {
235
+ "canOpenURL" -> done(success(intent.resolveActivity(context.packageManager) != null || url.startsWith("http")))
236
+ "openURL" -> {
237
+ val host = activity()
238
+ if (host == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
239
+ done(success(runCatching { (host ?: context).startActivity(intent) }.isSuccess))
240
+ }
241
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Linking method"))
242
+ }
243
+ }
244
+
245
+ /** `app-settings:` is this app's page in Settings, as on iOS; anything else is a view intent. */
246
+ private fun intentFor(url: String): Intent? {
247
+ if (url.isBlank()) return null
248
+ if (url.startsWith("app-settings:")) {
249
+ return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
250
+ }
251
+ val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null
252
+ return Intent(Intent.ACTION_VIEW, uri)
253
+ }
254
+
255
+ // MARK: Share
256
+
257
+ private fun share(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
258
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to share from"))
259
+ val text = listOfNotNull(
260
+ options.optString("text").takeIf { it.isNotBlank() },
261
+ options.optString("url").takeIf { it.isNotBlank() },
262
+ ).joinToString("\n")
263
+ val files = mutableListOf<Uri>()
264
+ val paths = options.optJSONArray("files") ?: JSONArray()
265
+ for (index in 0 until paths.length()) {
266
+ val path = paths.optString(index).removePrefix("file://")
267
+ val file = File(path)
268
+ if (!file.exists()) continue
269
+ files.add(FileProvider.getUriForFile(context, "${context.packageName}.craftfiles", file))
270
+ }
271
+ val intent = when {
272
+ files.size > 1 -> Intent(Intent.ACTION_SEND_MULTIPLE).apply {
273
+ putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(files))
274
+ type = "*/*"
275
+ }
276
+ files.size == 1 -> Intent(Intent.ACTION_SEND).apply {
277
+ putExtra(Intent.EXTRA_STREAM, files.first())
278
+ type = context.contentResolver.getType(files.first()) ?: "*/*"
279
+ }
280
+ else -> Intent(Intent.ACTION_SEND).apply { type = "text/plain" }
281
+ }
282
+ if (text.isNotBlank()) intent.putExtra(Intent.EXTRA_TEXT, text)
283
+ options.optString("title").takeIf { it.isNotBlank() }?.let { intent.putExtra(Intent.EXTRA_SUBJECT, it) }
284
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
285
+ host.startActivity(Intent.createChooser(intent, options.optString("title").ifBlank { null }))
286
+ // Android does not say whether something was shared; the sheet opened.
287
+ done(success(true))
288
+ }
289
+
290
+ // MARK: Push
291
+
292
+ private fun push(method: String, done: (CraftNativeCapabilityResult) -> Unit) {
293
+ if (!config.optBoolean("enablePushNotifications")) return done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))
294
+ when (method) {
295
+ "getPermissionStatus" -> done(success(pushStatus()))
296
+ "requestPermission" -> requestPushPermission(done)
297
+ "register" -> requestPushPermission { status ->
298
+ if (status.data != "granted") done(failure("PERMISSION_DENIED", "Notifications are not allowed"))
299
+ else registerPush(done)
300
+ }
301
+ "openSettings" -> {
302
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
303
+ val host = activity()
304
+ if (host == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
305
+ done(success(runCatching { (host ?: context).startActivity(intent) }.isSuccess))
306
+ }
307
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Push method"))
308
+ }
309
+ }
310
+
311
+ private fun pushStatus(): String {
312
+ val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
313
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return if (enabled) "granted" else "denied"
314
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return "granted"
315
+ val asked = context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).getBoolean("asked", false)
316
+ return if (asked) "denied" else "undetermined"
317
+ }
318
+
319
+ private fun requestPushPermission(done: (CraftNativeCapabilityResult) -> Unit) {
320
+ val status = pushStatus()
321
+ if (status != "undetermined" || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return done(success(status))
322
+ val host = activity() ?: return done(success(status))
323
+ pendingPushPermission?.invoke(success(status))
324
+ pendingPushPermission = done
325
+ context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).edit().putBoolean("asked", true).apply()
326
+ ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.POST_NOTIFICATIONS), PUSH_PERMISSION_REQUEST)
327
+ }
328
+
329
+ fun onRequestPermissionsResult(requestCode: Int): Boolean {
330
+ if (requestCode == LOCATION_PERMISSION_REQUEST) {
331
+ val pending = pendingLocationPermission
332
+ pendingLocationPermission = null
333
+ pending?.invoke(hasLocation())
334
+ return true
335
+ }
336
+ if (requestCode != PUSH_PERMISSION_REQUEST) return false
337
+ val pending = pendingPushPermission ?: return true
338
+ pendingPushPermission = null
339
+ pending(success(pushStatus()))
340
+ return true
341
+ }
342
+
343
+ private fun registerPush(done: (CraftNativeCapabilityResult) -> Unit) {
344
+ {{NATIVE_PUSH_REGISTER}}
345
+ }
346
+
347
+ // MARK: Location
348
+
349
+ private val locationClient by lazy { com.google.android.gms.location.LocationServices.getFusedLocationProviderClient(context) }
350
+ private var watchCallback: com.google.android.gms.location.LocationCallback? = null
351
+ private var pendingLocationPermission: ((Boolean) -> Unit)? = null
352
+
353
+ private fun fix(location: android.location.Location): JSONObject = JSONObject()
354
+ .put("latitude", location.latitude)
355
+ .put("longitude", location.longitude)
356
+ .put("altitude", location.altitude)
357
+ .put("accuracy", location.accuracy.toDouble())
358
+ .put("speed", location.speed.toDouble())
359
+ .put("heading", location.bearing.toDouble())
360
+ .put("timestamp", location.time.toDouble())
361
+
362
+ private fun hasLocation(): Boolean =
363
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
364
+ || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
365
+
366
+ /** Location, asked for once a person starts something that needs it. */
367
+ private fun withLocation(done: (CraftNativeCapabilityResult) -> Unit, then: () -> Unit) {
368
+ if (hasLocation()) return then()
369
+ val host = activity() ?: return done(failure("PERMISSION_DENIED", "Location is not allowed"))
370
+ pendingLocationPermission?.invoke(false)
371
+ pendingLocationPermission = { granted -> if (granted) then() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
372
+ ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), LOCATION_PERMISSION_REQUEST)
373
+ }
374
+
375
+ /**
376
+ * GPS for the workout player, in the iOS host's names: one fix, a watch
377
+ * (each fix a `craftLocationUpdate` event), and the route recording,
378
+ * which keeps going with the screen off in a foreground service
379
+ * (LocationRecordingService) and survives the app being closed.
380
+ */
381
+ @android.annotation.SuppressLint("MissingPermission")
382
+ private fun location(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
383
+ val store = com.craft.runtime.CraftLocationRecordingStore
384
+ when (method) {
385
+ "getCurrentPosition" -> withLocation(done) {
386
+ val options = args.optJSONObject(0) ?: JSONObject()
387
+ val priority = if (options.optBoolean("enableHighAccuracy", true)) com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY
388
+ else com.google.android.gms.location.Priority.PRIORITY_BALANCED_POWER_ACCURACY
389
+ locationClient.getCurrentLocation(priority, null)
390
+ .addOnSuccessListener { location -> done(if (location != null) success(fix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
391
+ .addOnFailureListener { error -> done(failure("POSITION_UNAVAILABLE", error.message ?: "No location fix")) }
392
+ }
393
+ "watchPosition" -> withLocation(done) {
394
+ watchCallback?.let(locationClient::removeLocationUpdates)
395
+ val callback = object : com.google.android.gms.location.LocationCallback() {
396
+ override fun onLocationResult(result: com.google.android.gms.location.LocationResult) {
397
+ for (location in result.locations) {
398
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location)))
399
+ }
400
+ }
401
+ }
402
+ watchCallback = callback
403
+ val request = com.google.android.gms.location.LocationRequest.Builder(com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY, 2_000).build()
404
+ locationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
405
+ done(success(true))
406
+ }
407
+ "clearWatch" -> {
408
+ watchCallback?.let(locationClient::removeLocationUpdates)
409
+ watchCallback = null
410
+ done(success(true))
411
+ }
412
+ "startRecording" -> withLocation(done) {
413
+ val options = args.optJSONObject(0) ?: JSONObject()
414
+ val id = options.optString("id").ifBlank { UUID.randomUUID().toString() }
415
+ store.start(context, id, System.currentTimeMillis())
416
+ store.listener = { location -> CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location))) }
417
+ val intent = Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_START)
418
+ runCatching { ContextCompat.startForegroundService(context, intent) }
419
+ .onFailure { store.stop(context) }
420
+ done(success(store.state(context)))
421
+ }
422
+ "pauseRecording", "resumeRecording" -> {
423
+ store.setPaused(context, method == "pauseRecording")
424
+ done(success(store.state(context)))
425
+ }
426
+ "stopRecording" -> {
427
+ val state = store.state(context, includeLocations = true)
428
+ store.stop(context)
429
+ store.listener = null
430
+ runCatching { context.startService(Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_STOP)) }
431
+ done(success(state.put("active", false).put("paused", false)))
432
+ }
433
+ "getRecordingState" -> done(success(store.state(context)))
434
+ "readRecording" -> done(success(store.locations(context)))
435
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Location method"))
436
+ }
437
+ }
438
+
439
+ // MARK: Health Connect
440
+
441
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =
442
+ health?.onActivityResult(requestCode, resultCode, data) == true
443
+
444
+ // MARK: The screen
445
+
446
+ private fun keepAwake(enabled: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
447
+ val host = activity() ?: return done(success(false))
448
+ if (enabled) host.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
449
+ else host.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
450
+ done(success(enabled))
451
+ }
452
+
453
+ /** This window's brightness, 0 to 1 (Android sets it for the app, not system-wide); -1 restores the system's. */
454
+ private fun brightness(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
455
+ val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing"))
456
+ val attributes = host.window.attributes
457
+ when (method) {
458
+ "get" -> {
459
+ val own = attributes.screenBrightness
460
+ val level = if (own >= 0) own.toDouble() else runCatching {
461
+ Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS) / 255.0
462
+ }.getOrDefault(0.5)
463
+ done(success(level))
464
+ }
465
+ "set" -> {
466
+ val level = args.optDouble(0, -1.0)
467
+ attributes.screenBrightness = if (level < 0) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE else level.coerceIn(0.0, 1.0).toFloat()
468
+ host.window.attributes = attributes
469
+ done(success(level))
470
+ }
471
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Brightness method"))
472
+ }
473
+ }
474
+
475
+ /** `light` is light text (on a dark screen), as on iOS. */
476
+ private fun statusBar(style: String, done: (CraftNativeCapabilityResult) -> Unit) {
477
+ val host = activity() ?: return done(success(false))
478
+ val controller = WindowCompat.getInsetsController(host.window, host.window.decorView)
479
+ val night = (host.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
480
+ controller.isAppearanceLightStatusBars = when (style) {
481
+ "light" -> false
482
+ "dark" -> true
483
+ else -> !night
484
+ }
485
+ done(success(true))
486
+ }
487
+
488
+ // MARK: Speech
489
+
490
+ private fun speech(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
491
+ when (method) {
492
+ "speak" -> {
493
+ val text = args.optString(0)
494
+ if (text.isBlank()) return done(failure("INVALID_ARGUMENT", "speech.speak needs some text to say"))
495
+ val options = args.optJSONObject(1) ?: JSONObject()
496
+ withSpeech { engine ->
497
+ options.optString("language").takeIf { it.isNotBlank() }?.let { engine.language = Locale.forLanguageTag(it) }
498
+ if (options.has("rate")) engine.setSpeechRate((options.optDouble("rate", 0.5) * 2).toFloat().coerceIn(0.1f, 4f))
499
+ val id = UUID.randomUUID().toString()
500
+ speeches[id] = done
501
+ val queue = if (options.optBoolean("interrupt", false)) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
502
+ if (engine.speak(text, queue, null, id) != TextToSpeech.SUCCESS) speeches.remove(id)?.invoke(success(false))
503
+ }
504
+ }
505
+ "stop" -> {
506
+ speech?.stop()
507
+ speeches.values.toList().forEach { it(success(false)) }
508
+ speeches.clear()
509
+ done(success(JSONObject.NULL))
510
+ }
511
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Speech method"))
512
+ }
513
+ }
514
+
515
+ private fun withSpeech(use: (TextToSpeech) -> Unit) {
516
+ val engine = speech
517
+ if (engine != null && speechReady) return use(engine)
518
+ speechQueue.add { speech?.let(use) }
519
+ if (engine != null) return
520
+ speech = TextToSpeech(context) { status ->
521
+ main.post {
522
+ speechReady = status == TextToSpeech.SUCCESS
523
+ if (!speechReady) {
524
+ speechQueue.clear()
525
+ speeches.values.toList().forEach { it(success(false)) }
526
+ speeches.clear()
527
+ return@post
528
+ }
529
+ speech?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
530
+ override fun onStart(utteranceId: String?) {}
531
+ override fun onDone(utteranceId: String?) { main.post { speeches.remove(utteranceId)?.invoke(success(true)) } }
532
+ @Deprecated("Kept for older engines")
533
+ override fun onError(utteranceId: String?) { main.post { speeches.remove(utteranceId)?.invoke(success(false)) } }
534
+ override fun onStop(utteranceId: String?, interrupted: Boolean) { main.post { speeches.remove(utteranceId)?.invoke(success(false)) } }
535
+ })
536
+ val queued = speechQueue.toList()
537
+ speechQueue.clear()
538
+ queued.forEach { it() }
539
+ }
540
+ }
541
+ }
542
+
543
+ fun close() {
544
+ watchCallback?.let(locationClient::removeLocationUpdates)
545
+ watchCallback = null
546
+ pendingLocationPermission = null
547
+ health?.close()
548
+ speech?.shutdown()
549
+ speech = null
550
+ pendingBrowser = null
551
+ pendingPushPermission = null
552
+ }
553
+
554
+ private fun success(data: Any?) = CraftNativeCapabilityResult(data = data ?: JSONObject.NULL)
555
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
556
+
557
+ companion object {
558
+ const val PUSH_PERMISSION_REQUEST = 4207
559
+ const val LOCATION_PERMISSION_REQUEST = 4208
560
+ }
561
+ }