@craft-native/android 0.0.168 → 0.0.170

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,296 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import androidx.core.content.ContextCompat
8
+ import androidx.javascriptengine.JavaScriptConsoleCallback
9
+ import androidx.javascriptengine.JavaScriptIsolate
10
+ import androidx.javascriptengine.JavaScriptSandbox
11
+ import androidx.work.Constraints
12
+ import androidx.work.CoroutineWorker
13
+ import androidx.work.ExistingPeriodicWorkPolicy
14
+ import androidx.work.ExistingWorkPolicy
15
+ import androidx.work.NetworkType
16
+ import androidx.work.OneTimeWorkRequestBuilder
17
+ import androidx.work.PeriodicWorkRequestBuilder
18
+ import androidx.work.WorkManager
19
+ import androidx.work.WorkerParameters
20
+ import androidx.work.workDataOf
21
+ import com.google.common.util.concurrent.ListenableFuture
22
+ import kotlinx.coroutines.CompletableDeferred
23
+ import kotlinx.coroutines.Dispatchers
24
+ import kotlinx.coroutines.withContext
25
+ import kotlinx.coroutines.withTimeoutOrNull
26
+ import org.json.JSONArray
27
+ import org.json.JSONObject
28
+ import java.util.UUID
29
+ import java.util.concurrent.TimeUnit
30
+
31
+ /**
32
+ * One JavaScriptSandbox for the process: the screens' isolates and a
33
+ * background refresh's all live in it, since an app may hold only one.
34
+ */
35
+ internal object CraftNativeSandbox {
36
+ private val lock = Any()
37
+ private var sandbox: JavaScriptSandbox? = null
38
+ private var connecting: ListenableFuture<JavaScriptSandbox>? = null
39
+
40
+ fun connect(context: Context, done: (JavaScriptSandbox?, Throwable?) -> Unit) {
41
+ val existing = synchronized(lock) { sandbox }
42
+ if (existing != null) return done(existing, null)
43
+ val future = synchronized(lock) {
44
+ connecting ?: JavaScriptSandbox.createConnectedInstanceAsync(context.applicationContext).also { connecting = it }
45
+ }
46
+ future.addListener({
47
+ try {
48
+ val engine = future.get()
49
+ synchronized(lock) { sandbox = engine }
50
+ done(engine, null)
51
+ } catch (error: Exception) {
52
+ synchronized(lock) { connecting = null }
53
+ done(null, error)
54
+ }
55
+ }, ContextCompat.getMainExecutor(context))
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Background refresh, as iOS's BGAppRefreshTask gives it: WorkManager wakes
61
+ * the app every `backgroundRefresh.minimumIntervalMinutes` (15 at the
62
+ * least, with a network) and the app script (`nativeApp`) hears
63
+ * `craftBackgroundRefresh` with an `id`, does its work and answers
64
+ * `craft.background.complete(ok, id)`. With the app open, the running app
65
+ * script hears it; otherwise a script is started for the refresh alone
66
+ * ([CraftNativeHeadlessApp]) and closed when it answers. A silent push
67
+ * (`craftSilentPush`) runs the same way, once.
68
+ */
69
+ internal object CraftNativeBackground {
70
+ private const val PERIODIC = "craft-native-background-refresh"
71
+ private const val ONCE = "craft-native-background-once"
72
+
73
+ /** The running app's script, when the activity has one: delivers an event and hears its answer. */
74
+ @Volatile var foreground: ((event: String, id: String, done: (Boolean) -> Unit) -> Boolean)? = null
75
+
76
+ fun schedule(context: Context, config: JSONObject) {
77
+ val refresh = config.optJSONObject("backgroundRefresh")
78
+ val work = WorkManager.getInstance(context)
79
+ if (refresh?.optBoolean("enabled") != true || config.optString("nativeApp").isBlank()) {
80
+ work.cancelUniqueWork(PERIODIC)
81
+ return
82
+ }
83
+ val minutes = refresh.optLong("minimumIntervalMinutes", 120).coerceAtLeast(15)
84
+ val request = PeriodicWorkRequestBuilder<CraftNativeRefreshWorker>(minutes, TimeUnit.MINUTES)
85
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
86
+ .setInputData(workDataOf("event" to "craftBackgroundRefresh"))
87
+ .build()
88
+ work.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
89
+ }
90
+
91
+ /** One run now (a silent push). */
92
+ fun runOnce(context: Context, event: String) {
93
+ val request = OneTimeWorkRequestBuilder<CraftNativeRefreshWorker>()
94
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
95
+ .setInputData(workDataOf("event" to event))
96
+ .build()
97
+ WorkManager.getInstance(context).enqueueUniqueWork(ONCE, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
98
+ }
99
+ }
100
+
101
+ internal class CraftNativeRefreshWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
102
+ override suspend fun doWork(): Result {
103
+ val event = inputData.getString("event") ?: "craftBackgroundRefresh"
104
+ val id = UUID.randomUUID().toString()
105
+ val answer = CompletableDeferred<Boolean>()
106
+ val running = withContext(Dispatchers.Main) {
107
+ CraftNativeBackground.foreground?.invoke(event, id) { ok -> answer.complete(ok) } == true
108
+ }
109
+ Log.i("CraftNativeAndroid", "$event $id: ${if (running) "the running app script" else "a background app script"}")
110
+ var headless: CraftNativeHeadlessApp? = null
111
+ if (!running) {
112
+ headless = withContext(Dispatchers.Main) { CraftNativeHeadlessApp(applicationContext, event, id) { ok -> answer.complete(ok) } }
113
+ withContext(Dispatchers.Main) { headless.start() }
114
+ }
115
+ // WorkManager stops a worker at ten minutes; answer before that.
116
+ val ok = withTimeoutOrNull(9 * 60 * 1000L) { answer.await() }
117
+ Log.i("CraftNativeAndroid", "$event $id answered ${ok ?: "nothing in time"}")
118
+ headless?.let { withContext(Dispatchers.Main) { it.close() } }
119
+ return if (ok == false) Result.retry() else Result.success()
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The app script with no activity: a background refresh's. It gets the
125
+ * same bridge as a screen's (CraftNativePrelude), Craft's core capabilities
126
+ * and this host's services, minus everything a person would have to see.
127
+ */
128
+ internal class CraftNativeHeadlessApp(
129
+ private val context: Context,
130
+ private val event: String,
131
+ private val id: String,
132
+ private val onDone: (Boolean) -> Unit,
133
+ ) {
134
+ private val config: JSONObject = runCatching {
135
+ context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
136
+ }.getOrDefault(JSONObject())
137
+ private val stores = CraftNativeStores.get(context, config)
138
+ private val capabilities = CraftNativeCapabilities(context, config)
139
+ private val services = CraftNativeServices(context, config) { null }
140
+ private val scope = UUID.randomUUID().toString()
141
+ private val prefix = "CRAFT_STX_$scope:"
142
+ private val main = Handler(Looper.getMainLooper())
143
+ private var isolate: JavaScriptIsolate? = null
144
+ private var closed = false
145
+ private val timers = mutableMapOf<Int, Runnable>()
146
+ private val chunks = CraftNativeChunks()
147
+
148
+ init {
149
+ capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, scope) }
150
+ services.onBackgroundComplete = { ok, answered -> if (answered == null || answered == id) finish(ok) }
151
+ }
152
+
153
+ fun start() {
154
+ val route = config.optString("nativeApp")
155
+ if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(true)
156
+ val bundle = runCatching { context.assets.open("native-screen.js").bufferedReader().use { it.readText() } }.getOrNull()
157
+ ?: return finish(true)
158
+ CraftNativeSandbox.connect(context) { engine, error ->
159
+ if (closed) return@connect
160
+ if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
161
+ Log.w(TAG, "Background refresh has no JavaScript engine", error)
162
+ return@connect finish(true)
163
+ }
164
+ val created = engine.createIsolate()
165
+ isolate = created
166
+ created.setConsoleCallback(ContextCompat.getMainExecutor(context), JavaScriptConsoleCallback { entry ->
167
+ if (closed) return@JavaScriptConsoleCallback
168
+ if (entry.message.startsWith(prefix)) chunks.accept(entry.message.removePrefix(prefix))?.let(::receive)
169
+ else Log.i(TAG, "[background] ${entry.message}")
170
+ })
171
+ CraftNativeHub.register(scope) { type, payload -> send(type, payload) }
172
+ val names = capabilities.names().apply { services.names().forEach { put(it) } }
173
+ val bridgeFields = """
174
+ platform: 'android',
175
+ mutationProtocolVersion: 1,
176
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
177
+ capabilityTimeoutMs: 30000,
178
+ capabilities: $names,
179
+ initialAppState: 'background',
180
+ colorScheme: 'light',
181
+ appURL: ${JSONObject.quote(config.optString("devServerURL"))},
182
+ appVersion: '',
183
+ appBuild: ''
184
+ """.trimIndent()
185
+ val prelude = CraftNativePrelude.script(
186
+ bridgeFields,
187
+ CraftNativeChunks.consolePost(prefix),
188
+ "globalThis.__stxNativeCallback = deliver;",
189
+ stores.preloadText(),
190
+ "globalThis.__stxNativeRoute = ${JSONObject.quote(route)};\nglobalThis.__stxNativeParams = {};",
191
+ )
192
+ val setup = created.evaluateJavaScriptAsync(prelude)
193
+ setup.addListener({
194
+ if (closed) return@addListener
195
+ try {
196
+ setup.get()
197
+ val load = created.evaluateJavaScriptAsync(bundle)
198
+ load.addListener({
199
+ try {
200
+ load.get()
201
+ send("CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject().put("id", id)))
202
+ } catch (failure: Exception) {
203
+ Log.e(TAG, "Background refresh script failed", failure)
204
+ finish(false)
205
+ }
206
+ }, ContextCompat.getMainExecutor(context))
207
+ } catch (failure: Exception) {
208
+ Log.e(TAG, "Background refresh bridge failed", failure)
209
+ finish(false)
210
+ }
211
+ }, ContextCompat.getMainExecutor(context))
212
+ }
213
+ }
214
+
215
+ private fun receive(raw: String) {
216
+ val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
217
+ val payload = message.optJSONObject("payload") ?: JSONObject()
218
+ when (message.optString("type")) {
219
+ "API_REQUEST" -> answer(message.optString("id"), payload)
220
+ "SYNC_WRITE" -> {
221
+ val key = payload.optString("key")
222
+ val value = if (payload.isNull("value")) null else payload.optString("value")
223
+ val reply: (Boolean) -> Unit = { ok ->
224
+ payload.optString("id").takeIf { it.isNotBlank() }?.let { send("SYNC_RESULT", JSONObject().put("id", it).put("ok", ok)) }
225
+ }
226
+ when (payload.optString("store")) {
227
+ "storage" -> if (key.isNotBlank()) stores.setStorage(key, value, scope)
228
+ "snapshots" -> if (payload.optBoolean("clear")) {
229
+ val keep = payload.optJSONArray("keep") ?: JSONArray()
230
+ stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
231
+ } else stores.setSnapshot(key, value, scope, reply)
232
+ }
233
+ }
234
+ "TIMER" -> {
235
+ val id = payload.optInt("id")
236
+ timers.remove(id)?.let(main::removeCallbacks)
237
+ val fire = Runnable {
238
+ timers.remove(id)
239
+ send("TIMER_FIRE", JSONObject().put("id", id))
240
+ }
241
+ timers[id] = fire
242
+ main.postDelayed(fire, payload.optLong("ms", 0).coerceAtLeast(0))
243
+ }
244
+ "TIMER_CLEAR" -> timers.remove(payload.optInt("id"))?.let(main::removeCallbacks)
245
+ // Nothing is drawn in the background: frames come at a calm pace.
246
+ "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", android.os.SystemClock.uptimeMillis().toDouble())) }, 100)
247
+ // A background refresh shows nothing: navigation waits for the app.
248
+ }
249
+ }
250
+
251
+ private fun answer(requestId: String, payload: JSONObject) {
252
+ if (requestId.isBlank()) return
253
+ val module = payload.optString("module")
254
+ val method = payload.optString("method")
255
+ val args = payload.optJSONArray("args") ?: JSONArray()
256
+ val reply: (CraftNativeCapabilityResult) -> Unit = { result ->
257
+ val response = JSONObject().put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION).put("requestId", requestId)
258
+ if (result.code == null) response.put("data", result.data ?: JSONObject.NULL)
259
+ else response.put("code", result.code).put("message", result.message ?: "Native API failed")
260
+ send(if (result.code == null) "API_RESPONSE" else "API_ERROR", response, requestId)
261
+ }
262
+ if (services.handles(module)) services.perform(module, method, args, reply)
263
+ else capabilities.perform("$scope/$requestId", payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION), module, method, args, reply)
264
+ }
265
+
266
+ private fun send(type: String, payload: JSONObject, correlationId: String? = null) {
267
+ val target = isolate ?: return
268
+ if (closed) return
269
+ val message = JSONObject().put("type", type).put("payload", payload)
270
+ if (correlationId != null) message.put("correlationId", correlationId)
271
+ runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})") }
272
+ }
273
+
274
+ private fun finish(ok: Boolean) {
275
+ main.post { onDone(ok) }
276
+ }
277
+
278
+ fun close() {
279
+ if (closed) return
280
+ closed = true
281
+ CraftNativeHub.unregister(scope)
282
+ timers.values.forEach(main::removeCallbacks)
283
+ timers.clear()
284
+ services.close()
285
+ capabilities.close()
286
+ isolate?.let { target ->
287
+ runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount()") }
288
+ main.postDelayed({ runCatching { target.close() } }, 500)
289
+ }
290
+ isolate = null
291
+ }
292
+
293
+ companion object {
294
+ private const val TAG = "CraftNativeAndroid"
295
+ }
296
+ }
@@ -58,34 +58,44 @@ internal data class CraftNativeCapabilityResult(
58
58
 
59
59
  /** Process-wide persistence and OS services shared by every native route. */
60
60
  internal class CraftNativeCapabilities(
61
- private val activity: MainActivity,
61
+ private val context: Context,
62
62
  private val config: JSONObject,
63
63
  ) {
64
- private val storage = activity.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
64
+ /** The screen's activity; null in a background refresh, where nothing can be shown. */
65
+ private val activity: MainActivity? get() = context as? MainActivity
66
+ private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
67
+ private val storage = context.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
65
68
  // The typed native renderer has no CraftNative Zig holder. Keep its
66
69
  // values encrypted at rest with AndroidX's AES-256 GCM/SIV preferences.
67
70
  private val encryptedSecureStorage by lazy {
68
- val masterKey = MasterKey.Builder(activity)
71
+ val masterKey = MasterKey.Builder(context)
69
72
  .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
70
73
  .build()
71
74
  EncryptedSharedPreferences.create(
72
- activity,
75
+ context,
73
76
  "craft_secure_prefs",
74
77
  masterKey,
75
78
  EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
76
79
  EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
77
80
  )
78
81
  }
79
- private val notificationStorage = activity.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
82
+ private val notificationStorage = context.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
80
83
  private val databaseExecutor = Executors.newSingleThreadExecutor()
81
84
  private val networkExecutor = Executors.newCachedThreadPool()
82
85
  private val cancelled = ConcurrentHashMap.newKeySet<String>()
83
86
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
84
- private val fileRoot by lazy { File(activity.cacheDir, "craft-files").apply { mkdirs() } }
87
+ private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
85
88
  private var pendingFilePick: PendingFilePick? = null
86
89
  private var database: SQLiteDatabase? = null
87
90
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
88
91
 
92
+ /**
93
+ * Hears each change to `Storage` and `SecureStorage` (store, key or null
94
+ * for a clear, the new value or null), so every isolate's synchronous
95
+ * copy follows it (CraftNativeStores).
96
+ */
97
+ var storeObserver: ((String, String?, String?) -> Unit)? = null
98
+
89
99
  private data class PendingFilePick(
90
100
  val requestToken: String,
91
101
  val source: String,
@@ -136,7 +146,7 @@ internal class CraftNativeCapabilities(
136
146
  complete(requestToken, database(method, args), completion)
137
147
  }
138
148
  }
139
- "Lifecycle" -> complete(requestToken, if (method == "getState") success(activity.nativeAppState)
149
+ "Lifecycle" -> complete(requestToken, if (method == "getState") success(activity?.nativeAppState ?: "background")
140
150
  else failure("UNKNOWN_ACTION", "Unsupported Lifecycle method"), completion)
141
151
  "DeepLinks" -> complete(requestToken, deepLinks(method), completion)
142
152
  "Notifications" -> notifications(requestToken, method, args, completion)
@@ -154,7 +164,8 @@ internal class CraftNativeCapabilities(
154
164
  }
155
165
  else -> complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Files method"), completion)
156
166
  }
157
- else -> complete(requestToken, activity.performImmediateCapability(module, method, args), completion)
167
+ else -> complete(requestToken, activity?.performImmediateCapability(module, method, args)
168
+ ?: failure("NEEDS_ACTIVITY", "$module.$method needs a screen"), completion)
158
169
  }
159
170
  }
160
171
 
@@ -403,16 +414,17 @@ internal class CraftNativeCapabilities(
403
414
  args: JSONArray,
404
415
  completion: (CraftNativeCapabilityResult) -> Unit,
405
416
  ) {
406
- activity.runOnUiThread {
417
+ mainHandler.post {
418
+ val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
407
419
  if (pendingFilePick != null) {
408
420
  complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
409
- return@runOnUiThread
421
+ return@post
410
422
  }
411
423
  val options = args.optJSONObject(0) ?: JSONObject()
412
424
  val source = options.optString("source", "library")
413
425
  if (source != "library" && source != "camera") {
414
426
  complete(requestToken, failure("INVALID_ARGUMENT", "files.pickImage source is 'library' or 'camera'"), completion)
415
- return@runOnUiThread
427
+ return@post
416
428
  }
417
429
  val maxPixels = options.optInt("maxPixels", CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS)
418
430
  .coerceIn(64, CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS)
@@ -420,7 +432,7 @@ internal class CraftNativeCapabilities(
420
432
  .coerceIn(0.1, 1.0).toFloat()
421
433
  if (source == "camera" && !config.optBoolean("enableCamera")) {
422
434
  complete(requestToken, failure("CAPABILITY_DISABLED", "Camera is disabled"), completion)
423
- return@runOnUiThread
435
+ return@post
424
436
  }
425
437
  pendingFilePick = PendingFilePick(requestToken, source, maxPixels, quality, completion)
426
438
  val intent = if (source == "camera") Intent(MediaStore.ACTION_IMAGE_CAPTURE)
@@ -429,7 +441,7 @@ internal class CraftNativeCapabilities(
429
441
  type = "image/*"
430
442
  }
431
443
  try {
432
- activity.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
444
+ host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
433
445
  } catch (error: Exception) {
434
446
  pendingFilePick = null
435
447
  complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Image picker could not be opened"), completion)
@@ -457,16 +469,16 @@ internal class CraftNativeCapabilities(
457
469
  }
458
470
 
459
471
  private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
460
- val name = activity.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
472
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
461
473
  if (it.moveToFirst()) it.getString(0) else null
462
474
  } ?: "photo"
463
475
  val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
464
- activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } ?: return null
476
+ context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } ?: return null
465
477
  if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
466
478
  var sample = 1
467
479
  while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
468
480
  val options = BitmapFactory.Options().apply { inSampleSize = sample }
469
- val bitmap = activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
481
+ val bitmap = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
470
482
  return writeImage(bitmap, maxPixels, quality, jpegName(name))
471
483
  }
472
484
 
@@ -536,7 +548,9 @@ internal class CraftNativeCapabilities(
536
548
  result: CraftNativeCapabilityResult,
537
549
  completion: (CraftNativeCapabilityResult) -> Unit,
538
550
  ) {
539
- activity.runOnUiThread {
551
+ if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
552
+ if (!cancelled.remove(requestToken)) completion(result)
553
+ } else mainHandler.post {
540
554
  if (!cancelled.remove(requestToken)) completion(result)
541
555
  }
542
556
  }
@@ -544,6 +558,7 @@ internal class CraftNativeCapabilities(
544
558
  private fun storage(method: String, args: JSONArray): CraftNativeCapabilityResult {
545
559
  if (method == "clear") {
546
560
  storage.edit().clear().commit()
561
+ storeObserver?.invoke("storage", null, null)
547
562
  return success(true)
548
563
  }
549
564
  if (method == "keys") return success(JSONArray(storage.all.keys.sorted()))
@@ -558,10 +573,13 @@ internal class CraftNativeCapabilities(
558
573
  if (args.length() != 2) return failure("INVALID_ARGUMENT", "Storage.set needs a key and value")
559
574
  val value = jsonString(args.opt(1))
560
575
  ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
561
- if (storage.edit().putString(key, value).commit()) success(true)
576
+ if (storage.edit().putString(key, value).commit()) {
577
+ storeObserver?.invoke("storage", key, value)
578
+ success(true)
579
+ }
562
580
  else failure("STORAGE_ERROR", "Storage could not persist the value")
563
581
  }
564
- "remove" -> success(storage.edit().remove(key).commit())
582
+ "remove" -> success(storage.edit().remove(key).commit().also { storeObserver?.invoke("storage", key, null) })
565
583
  else -> failure("UNKNOWN_ACTION", "Unsupported Storage method")
566
584
  }
567
585
  }
@@ -569,7 +587,7 @@ internal class CraftNativeCapabilities(
569
587
  private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult {
570
588
  if (method == "clear") {
571
589
  return try {
572
- success(encryptedSecureStorage.edit().clear().commit())
590
+ success(encryptedSecureStorage.edit().clear().commit().also { storeObserver?.invoke("secure", null, null) })
573
591
  } catch (error: Exception) {
574
592
  failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage could not be cleared")
575
593
  }
@@ -579,11 +597,14 @@ internal class CraftNativeCapabilities(
579
597
  when (method) {
580
598
  "set" -> {
581
599
  if (args.length() != 2 || args.opt(1) !is String) return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
582
- if (encryptedSecureStorage.edit().putString(key, args.getString(1)).commit()) success(true)
600
+ if (encryptedSecureStorage.edit().putString(key, args.getString(1)).commit()) {
601
+ storeObserver?.invoke("secure", key, args.getString(1))
602
+ success(true)
603
+ }
583
604
  else failure("SECURE_STORAGE_ERROR", "Secure storage could not be written")
584
605
  }
585
606
  "get" -> success(encryptedSecureStorage.getString(key, null) ?: JSONObject.NULL)
586
- "remove" -> success(encryptedSecureStorage.edit().remove(key).commit())
607
+ "remove" -> success(encryptedSecureStorage.edit().remove(key).commit().also { storeObserver?.invoke("secure", key, null) })
587
608
  else -> failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
588
609
  }
589
610
  } catch (error: Exception) {
@@ -601,28 +622,29 @@ internal class CraftNativeCapabilities(
601
622
  complete(requestToken, failure("CAPABILITY_DISABLED", "Biometric authentication is disabled"), completion)
602
623
  return
603
624
  }
604
- val manager = BiometricManager.from(activity)
625
+ val manager = BiometricManager.from(context)
605
626
  when (method) {
606
627
  "isAvailable" -> complete(requestToken, success(manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS), completion)
607
628
  "getBiometricType" -> {
608
629
  val available = manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
609
630
  val type = when {
610
631
  !available -> "none"
611
- activity.packageManager.hasSystemFeature("android.hardware.biometrics.face") -> "faceId"
632
+ context.packageManager.hasSystemFeature("android.hardware.biometrics.face") -> "faceId"
612
633
  else -> "fingerprint"
613
634
  }
614
635
  complete(requestToken, success(type), completion)
615
636
  }
616
637
  "authenticate" -> {
617
- if (activity !is FragmentActivity) {
638
+ val host = activity
639
+ if (host !is FragmentActivity) {
618
640
  complete(requestToken, failure("AUTHENTICATION_FAILED", "Activity does not support biometric prompts"), completion)
619
641
  return
620
642
  }
621
643
  val reason = args.optString(0).ifBlank { "Authenticate to continue" }
622
- activity.runOnUiThread {
623
- if (cancelled.contains(requestToken)) return@runOnUiThread
644
+ mainHandler.post {
645
+ if (cancelled.contains(requestToken)) return@post
624
646
  try {
625
- val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object : BiometricPrompt.AuthenticationCallback() {
647
+ val prompt = BiometricPrompt(host, ContextCompat.getMainExecutor(host), object : BiometricPrompt.AuthenticationCallback() {
626
648
  override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
627
649
  biometricPrompts.remove(requestToken)
628
650
  complete(requestToken, success(true), completion)
@@ -650,7 +672,7 @@ internal class CraftNativeCapabilities(
650
672
 
651
673
  private fun database(method: String, args: JSONArray): CraftNativeCapabilityResult {
652
674
  val db = database ?: runCatching {
653
- activity.openOrCreateDatabase("craft-native.sqlite", Context.MODE_PRIVATE, null)
675
+ context.openOrCreateDatabase("craft-native.sqlite", Context.MODE_PRIVATE, null)
654
676
  }.getOrElse { return failure("DATABASE_ERROR", it.message ?: "Could not open the local database") }
655
677
  .also { database = it }
656
678
  return try {
@@ -764,7 +786,7 @@ internal class CraftNativeCapabilities(
764
786
  private fun deepLinks(method: String): CraftNativeCapabilityResult {
765
787
  if (!config.optBoolean("enableDeepLinks")) return failure("CAPABILITY_DISABLED", "Deep links are disabled")
766
788
  if (method != "getInitialURL") return failure("UNKNOWN_ACTION", "Unsupported DeepLinks method")
767
- return success(activity.consumeInitialDeepLink()?.let(activity::deepLinkData) ?: JSONObject.NULL)
789
+ return success(activity?.let { host -> host.consumeInitialDeepLink()?.let(host::deepLinkData) } ?: JSONObject.NULL)
768
790
  }
769
791
 
770
792
  private fun notifications(
@@ -786,7 +808,7 @@ internal class CraftNativeCapabilities(
786
808
  }
787
809
  "cancelAll" -> {
788
810
  notificationStorage.all.keys.forEach(::cancelNotification)
789
- (activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancelAll()
811
+ (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancelAll()
790
812
  complete(requestToken, success(true), completion)
791
813
  }
792
814
  "pending" -> complete(requestToken, success(JSONArray(notificationStorage.all.values.mapNotNull {
@@ -800,7 +822,7 @@ internal class CraftNativeCapabilities(
800
822
  val title = value?.optString("title").orEmpty()
801
823
  if (title.isEmpty()) return failure("INVALID_ARGUMENT", "Notifications.schedule needs a title")
802
824
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
803
- ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
825
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
804
826
  return failure("PERMISSION_DENIED", "Notification permission denied")
805
827
  }
806
828
  val id = validKey(value?.opt("id")) ?: UUID.randomUUID().toString()
@@ -820,7 +842,7 @@ internal class CraftNativeCapabilities(
820
842
  private fun scheduleStored(value: JSONObject) {
821
843
  val id = value.optString("id")
822
844
  if (id.isEmpty()) return
823
- val alarm = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
845
+ val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
824
846
  alarm.setAndAllowWhileIdle(
825
847
  AlarmManager.RTC_WAKEUP,
826
848
  value.optLong("fireAt").coerceAtLeast(System.currentTimeMillis()),
@@ -829,18 +851,18 @@ internal class CraftNativeCapabilities(
829
851
  }
830
852
 
831
853
  private fun cancelNotification(id: String) {
832
- val alarm = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
854
+ val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
833
855
  val intent = notificationIntent(id)
834
856
  alarm.cancel(intent)
835
857
  intent.cancel()
836
858
  notificationStorage.edit().remove(id).commit()
837
- (activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(id, 0)
859
+ (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(id, 0)
838
860
  }
839
861
 
840
862
  private fun notificationIntent(id: String): PendingIntent = PendingIntent.getBroadcast(
841
- activity,
863
+ context,
842
864
  0,
843
- Intent(activity, CraftNativeNotificationReceiver::class.java)
865
+ Intent(context, CraftNativeNotificationReceiver::class.java)
844
866
  .setData(Uri.Builder().scheme("craft-native").authority("notification").appendPath(id).build())
845
867
  .putExtra("id", id),
846
868
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,