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.
@@ -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,