craft-native 0.0.167 → 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.
- package/dist/android/src/index.d.ts +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1013 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +22 -9
- package/package.json +1 -1
|
@@ -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
|
|
61
|
+
private val context: Context,
|
|
62
62
|
private val config: JSONObject,
|
|
63
63
|
) {
|
|
64
|
-
|
|
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(
|
|
71
|
+
val masterKey = MasterKey.Builder(context)
|
|
69
72
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
70
73
|
.build()
|
|
71
74
|
EncryptedSharedPreferences.create(
|
|
72
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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())
|
|
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())
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
if (cancelled.contains(requestToken)) return@
|
|
644
|
+
mainHandler.post {
|
|
645
|
+
if (cancelled.contains(requestToken)) return@post
|
|
624
646
|
try {
|
|
625
|
-
val prompt = BiometricPrompt(
|
|
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
|
-
|
|
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(
|
|
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
|
-
(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
(
|
|
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
|
-
|
|
863
|
+
context,
|
|
842
864
|
0,
|
|
843
|
-
Intent(
|
|
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,
|