craft-native 0.0.223 → 0.0.225

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.
@@ -549,6 +549,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
549
549
  };
550
550
  write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
551
551
  write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
552
+ write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
552
553
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
553
554
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
554
555
  write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
@@ -0,0 +1,141 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import java.io.File
4
+ import java.io.BufferedInputStream
5
+ import java.io.DataInputStream
6
+ import java.io.DataOutputStream
7
+ import java.io.IOException
8
+ import java.io.OutputStream
9
+ import java.security.MessageDigest
10
+ import java.util.Locale
11
+
12
+ internal data class CraftNativeImagePolicy(val expiresAt: Long)
13
+
14
+ internal fun craftNativeImagePolicy(
15
+ requestHeaders: Map<String, String>,
16
+ responseHeaders: Map<String, List<String>>,
17
+ now: Long,
18
+ ): CraftNativeImagePolicy? {
19
+ // Persist only the anonymous content the server explicitly calls public.
20
+ // A custom header can carry an account credential just like Authorization.
21
+ if (requestHeaders.isNotEmpty()) return null
22
+ fun values(name: String) = responseHeaders.entries
23
+ .filter { it.key.equals(name, ignoreCase = true) }.flatMap { it.value }
24
+ val variants = values("Vary").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
25
+ if (values("Set-Cookie").isNotEmpty() || variants.any { it in setOf("*", "cookie", "authorization") }) return null
26
+ val directives = values("Cache-Control").flatMap { it.split(',') }.map { it.trim().lowercase(Locale.ROOT) }
27
+ if ("public" !in directives || directives.any { it.substringBefore('=').trim() in setOf("private", "no-store", "no-cache") }) return null
28
+ val ages = directives.filter { it.substringBefore('=').trim() == "max-age" }
29
+ if (ages.isEmpty()) return null
30
+ val seconds = ages.map {
31
+ it.substringAfter('=', "").trim().removeSurrounding("\"").toLongOrNull()?.takeIf { age -> age > 0 } ?: return null
32
+ }.min().coerceAtMost(31_536_000L)
33
+ val ageHeader = values("Age").firstOrNull()
34
+ val age = if (ageHeader == null) 0L else ageHeader.trim().toLongOrNull()?.takeIf { it >= 0 } ?: return null
35
+ val remaining = seconds - age
36
+ if (remaining <= 0) return null
37
+ return runCatching { CraftNativeImagePolicy(Math.addExact(now, remaining * 1000)) }.getOrNull()
38
+ }
39
+
40
+ internal class CraftNativeImageCache(
41
+ private val root: File,
42
+ private val maxBytes: Long = 32L * 1024 * 1024,
43
+ private val maxEntryBytes: Long = 8L * 1024 * 1024,
44
+ private val maxEntries: Int = 128,
45
+ private val now: () -> Long = System::currentTimeMillis,
46
+ ) {
47
+ init {
48
+ require(maxBytes > 0 && maxEntryBytes > 0 && maxEntries > 0)
49
+ // A process killed while encoding can leave a staging file behind.
50
+ // All instances share this lock, so another writer cannot own it now.
51
+ synchronized(lock) {
52
+ runCatching {
53
+ root.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }.forEach(File::delete)
54
+ prune()
55
+ }
56
+ }
57
+ }
58
+
59
+ private fun file(address: String): File {
60
+ val digest = MessageDigest.getInstance("SHA-256").digest(address.toByteArray(Charsets.UTF_8))
61
+ val name = digest.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
62
+ return File(root, "$name.cache")
63
+ }
64
+
65
+ fun read(address: String): ByteArray? = synchronized(lock) {
66
+ runCatching {
67
+ val target = file(address)
68
+ if (!target.isFile) return@runCatching null
69
+ if (target.length() <= HEADER_BYTES || target.length() > HEADER_BYTES + minOf(maxEntryBytes, maxBytes)) {
70
+ target.delete()
71
+ return@runCatching null
72
+ }
73
+ val data = DataInputStream(BufferedInputStream(target.inputStream())).use { input ->
74
+ if (input.readInt() != MAGIC || input.readLong() <= now()) null else input.readBytes()
75
+ }
76
+ if (data == null || data.isEmpty()) { target.delete(); null }
77
+ else { target.setLastModified(now()); data }
78
+ }.getOrNull()
79
+ }
80
+
81
+ fun write(address: String, policy: CraftNativeImagePolicy, encode: (OutputStream) -> Boolean): Boolean = synchronized(lock) {
82
+ var staging: File? = null
83
+ try {
84
+ if (policy.expiresAt <= now() || (!root.isDirectory && !root.mkdirs())) return@synchronized false
85
+ val target = file(address)
86
+ staging = File.createTempFile(target.name, ".tmp", root)
87
+ staging.outputStream().buffered().use { raw ->
88
+ val header = DataOutputStream(raw)
89
+ header.writeInt(MAGIC)
90
+ header.writeLong(policy.expiresAt)
91
+ val payload = BoundedImageOutput(raw, minOf(maxEntryBytes, maxBytes))
92
+ if (!encode(payload) || payload.written == 0L) throw IOException("Incomplete cached image")
93
+ payload.flush()
94
+ }
95
+ // Freshness and pixels are committed together; a failed write
96
+ // never replaces an earlier, complete image with a partial file.
97
+ if (!staging.renameTo(target)) return@synchronized false
98
+ target.setLastModified(now())
99
+ prune()
100
+ true
101
+ }
102
+ catch (error: Exception) { false }
103
+ finally { staging?.delete() }
104
+ }
105
+
106
+ fun remove(address: String) = synchronized(lock) { runCatching { file(address).delete() }; Unit }
107
+
108
+ private fun prune() {
109
+ val entries = root.listFiles().orEmpty().filter { entryName.matches(it.name) }.sortedBy(File::lastModified)
110
+ var count = entries.size
111
+ var bytes = entries.sumOf { (it.length() - HEADER_BYTES).coerceAtLeast(0) }
112
+ for (entry in entries) {
113
+ if (count <= maxEntries && bytes <= maxBytes) break
114
+ val size = (entry.length() - HEADER_BYTES).coerceAtLeast(0)
115
+ if (entry.delete()) { count--; bytes -= size }
116
+ }
117
+ }
118
+
119
+ private class BoundedImageOutput(private val output: OutputStream, private val limit: Long) : OutputStream() {
120
+ var written = 0L
121
+ private set
122
+ override fun write(value: Int) {
123
+ if (written >= limit) throw IOException("Cached image is too large")
124
+ output.write(value)
125
+ written++
126
+ }
127
+ override fun write(bytes: ByteArray, offset: Int, length: Int) {
128
+ if (written + length > limit) throw IOException("Cached image is too large")
129
+ output.write(bytes, offset, length)
130
+ written += length
131
+ }
132
+ override fun flush() = output.flush()
133
+ }
134
+
135
+ private companion object {
136
+ const val MAGIC = 0x43494d31
137
+ const val HEADER_BYTES = 12L
138
+ val entryName = Regex("^[a-f0-9]{64}\\.cache$")
139
+ val lock = Any()
140
+ }
141
+ }
@@ -92,6 +92,7 @@ import android.widget.Switch
92
92
  import android.widget.TextView
93
93
  import android.widget.MediaController
94
94
  import android.widget.VideoView
95
+ import androidx.activity.OnBackPressedCallback
95
96
  import androidx.appcompat.app.AppCompatActivity
96
97
  import androidx.appcompat.app.AppCompatDelegate
97
98
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -121,6 +122,8 @@ import com.google.android.gms.maps.model.Polyline
121
122
  import com.google.android.gms.maps.model.PolylineOptions
122
123
  import org.json.JSONArray
123
124
  import org.json.JSONObject
125
+ import java.io.File
126
+ import java.net.HttpURLConnection
124
127
  import java.net.URL
125
128
  import java.text.SimpleDateFormat
126
129
  import java.util.Calendar
@@ -3804,6 +3807,7 @@ class MainActivity : AppCompatActivity() {
3804
3807
  private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
3805
3808
  private var useMessagePorts = false
3806
3809
  private val imageExecutor = Executors.newFixedThreadPool(2)
3810
+ private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
3807
3811
  /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
3808
3812
  private val preludeExecutor = Executors.newSingleThreadExecutor()
3809
3813
  private val imageSources = mutableMapOf<ImageView, String>()
@@ -3826,6 +3830,9 @@ class MainActivity : AppCompatActivity() {
3826
3830
  override fun onCreate(savedInstanceState: Bundle?) {
3827
3831
  applyConfiguredAppearance()
3828
3832
  super.onCreate(savedInstanceState)
3833
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
3834
+ override fun handleOnBackPressed() = handleNativeBack()
3835
+ })
3829
3836
  initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
3830
3837
  initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
3831
3838
  deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
@@ -3987,8 +3994,7 @@ class MainActivity : AppCompatActivity() {
3987
3994
  }
3988
3995
 
3989
3996
  private fun onBackPressedDispatcherCompat() {
3990
- @Suppress("DEPRECATION")
3991
- onBackPressed()
3997
+ onBackPressedDispatcher.onBackPressed()
3992
3998
  }
3993
3999
 
3994
4000
  private fun haptic() {
@@ -4153,13 +4159,22 @@ class MainActivity : AppCompatActivity() {
4153
4159
  * page on the app's site in the in-app browser. Another site's address
4154
4160
  * opens in the browser too.
4155
4161
  */
4156
- private fun navigateOpen(target: String, fromLink: Boolean = false) {
4162
+ private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
4157
4163
  val path = routes.appPath(target, urlSchemes)
4158
4164
  if (path == null) {
4159
4165
  openExternal(target)
4160
4166
  return
4161
4167
  }
4162
4168
  val match = routes.match(path)
4169
+ if (replace && match != null && screens.isNotEmpty()) {
4170
+ val stack = screens
4171
+ val removed = stack.removeAt(stack.lastIndex)
4172
+ val previous = stack.lastOrNull()
4173
+ closeScreen(removed) {
4174
+ if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
4175
+ }
4176
+ return
4177
+ }
4163
4178
  val tab = routes.tabFor(path)
4164
4179
  if (tab != null) {
4165
4180
  selectTab(routes.tabs.indexOf(tab))
@@ -4767,6 +4782,12 @@ class MainActivity : AppCompatActivity() {
4767
4782
  // app script has no stack of its own; it uses NAVIGATE_OPEN.
4768
4783
  if (visibleScreen !== screen) return
4769
4784
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
4785
+ // Resolve and replace in one host operation. A pop followed by
4786
+ // open would send the second message from a closing isolate.
4787
+ if (message.optString("type") == "NAVIGATE_REPLACE" && route.startsWith("/")) {
4788
+ navigateOpen(route, replace = true)
4789
+ return
4790
+ }
4770
4791
  if (message.optString("type") == "NAVIGATE_REPLACE") {
4771
4792
  val stack = screens
4772
4793
  val removed = stack.removeAt(stack.lastIndex)
@@ -6098,7 +6119,9 @@ class MainActivity : AppCompatActivity() {
6098
6119
  null
6099
6120
  } else defaultBackground
6100
6121
  val overflow = craftNativeOptionalString(style.opt("overflow"))
6101
- view.clipToOutline = overflow == "hidden"
6122
+ // A replaced image clips its own pixels to border-radius, including
6123
+ // when its surrounding layout permits visible overflow.
6124
+ view.clipToOutline = overflow == "hidden" || (view is ImageView && (radius > 0 || hasCornerRadii))
6102
6125
  if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6103
6126
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6104
6127
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
@@ -6496,12 +6519,38 @@ class MainActivity : AppCompatActivity() {
6496
6519
  }
6497
6520
  imageJobs[view] = imageExecutor.submit {
6498
6521
  val bitmap = runCatching {
6499
- val connection = URL(uri).openConnection().apply {
6522
+ val publicRequest = headers.isEmpty() && Uri.parse(uri).userInfo.isNullOrEmpty()
6523
+ if (publicRequest) {
6524
+ val bytes = imageCache.read(uri)
6525
+ if (bytes != null) {
6526
+ val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
6527
+ if (saved != null) return@runCatching saved
6528
+ imageCache.remove(uri)
6529
+ }
6530
+ }
6531
+ val connection = (URL(uri).openConnection() as HttpURLConnection).apply {
6500
6532
  connectTimeout = 10_000
6501
6533
  readTimeout = 10_000
6534
+ // This loader owns its bounded public cache. Do not let
6535
+ // an application's global ResponseCache retain requests.
6536
+ useCaches = false
6502
6537
  headers.forEach { (name, value) -> setRequestProperty(name, value) }
6503
6538
  }
6504
- connection.getInputStream().use(BitmapFactory::decodeStream)
6539
+ try {
6540
+ val downloaded = connection.inputStream.use(BitmapFactory::decodeStream)
6541
+ if (publicRequest) {
6542
+ val responseHeaders = connection.headerFields.entries.filter { it.key != null }
6543
+ .associate { it.key!! to it.value.orEmpty() }
6544
+ val policy = if (connection.responseCode == HttpURLConnection.HTTP_OK)
6545
+ craftNativeImagePolicy(headers, responseHeaders, System.currentTimeMillis()) else null
6546
+ if (downloaded != null && policy != null) {
6547
+ // Encode only after a complete decode, then commit
6548
+ // before onLoad so a cold offline reopen is safe.
6549
+ imageCache.write(uri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
6550
+ } else imageCache.remove(uri)
6551
+ }
6552
+ downloaded
6553
+ } finally { connection.disconnect() }
6505
6554
  }.getOrNull()
6506
6555
  runOnUiThread {
6507
6556
  if (imageSources[view] != requestKey) return@runOnUiThread
@@ -7286,7 +7335,9 @@ class MainActivity : AppCompatActivity() {
7286
7335
  }
7287
7336
 
7288
7337
  @Deprecated("Uses the activity back callback for native route navigation")
7289
- override fun onBackPressed() {
7338
+ override fun onBackPressed() = handleNativeBack()
7339
+
7340
+ private fun handleNativeBack() {
7290
7341
  val screen = screens.lastOrNull() ?: return back()
7291
7342
  val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
7292
7343
  if (modal != null) {
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.223";
3427
+ var version = "0.0.225";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.223";
312
+ var version = "0.0.225";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.223";
60
+ var version = "0.0.225";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -266,6 +266,8 @@ protocol CraftHybridScreenEvents: AnyObject {
266
266
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
267
267
  /// `craft.navigation.open(path)`.
268
268
  func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
269
+ /// `craft.navigation.replace(path)`: finish the current flow atomically.
270
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
269
271
  }
270
272
 
271
273
  // MARK: - The shell
@@ -938,6 +940,10 @@ extension CraftHybrid: CraftHybridScreenEvents {
938
940
  func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
939
941
  open(path)
940
942
  }
943
+
944
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
945
+ controller?.showNative(path: path, replace: true)
946
+ }
941
947
  }
942
948
 
943
949
  /// The page's `craftHybrid` channel, only from a trusted origin's main frame.
@@ -1519,6 +1525,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1519
1525
  interruptTabSwipe(restoringSelection: true)
1520
1526
  let tabs = hybrid.tabIds
1521
1527
  if let tab = tabs.first(where: { CraftHybridRoutes.normalize($0) == match.path }) {
1528
+ // Finish the flow in its original tab before selecting another.
1529
+ if replace, let current = visibleStack, current.viewControllers.count > 1 {
1530
+ current.setViewControllers(Array(current.viewControllers.dropLast()), animated: false)
1531
+ }
1522
1532
  if tab != currentTab {
1523
1533
  selectTabAsIfTapped(tab)
1524
1534
  }
@@ -1541,7 +1551,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1541
1551
  return
1542
1552
  }
1543
1553
  let screen = entry(for: raw, isTabRoot: false, depth: nil)
1544
- if replace, nav.viewControllers.count > 1 {
1554
+ if replace, !nav.viewControllers.isEmpty {
1545
1555
  nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1546
1556
  } else {
1547
1557
  nav.pushViewController(screen, animated: true)
@@ -1900,6 +1910,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1900
1910
  open(path)
1901
1911
  }
1902
1912
 
1913
+ func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
1914
+ guard screen.navigationController?.topViewController === screen else { return }
1915
+ showNative(path: path, replace: true)
1916
+ }
1917
+
1903
1918
  private func log(_ message: String) {
1904
1919
  #if DEBUG
1905
1920
  NSLog("[craft hybrid] %@", message)
@@ -5416,6 +5416,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5416
5416
  guard !isAppScript, navigationController?.topViewController === self,
5417
5417
  let screen = payload["screen"] as? String, !screen.isEmpty,
5418
5418
  let navigation = navigationController else { return }
5419
+ if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
5420
+ hybridEvents?.nativeScreen(self, replace: screen)
5421
+ return
5422
+ }
5419
5423
  let params = payload["params"] as? [String: Any] ?? [:]
5420
5424
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
5421
5425
  next.hybridEvents = hybridEvents
@@ -7006,6 +7010,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7006
7010
  view.clipsToBounds = overflow == "hidden"
7007
7011
  || (view is UIScrollView && overflow != "visible")
7008
7012
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
7013
+ || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
7009
7014
  view.layer.masksToBounds = view.clipsToBounds
7010
7015
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
7011
7016
  (view as? CraftNativeFlowView)?.invalidateMeasurements()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.223",
3
+ "version": "0.0.225",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",