craft-native 0.0.187 → 0.0.191

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.
@@ -341,6 +341,18 @@ object CraftNative {
341
341
  nodeData: FloatArray,
342
342
  treeMeta: FloatArray,
343
343
  ): FloatArray?
344
+ private external fun nativeLayoutTreeV2(
345
+ width: Float,
346
+ height: Float,
347
+ nodeData: FloatArray,
348
+ treeMeta: FloatArray,
349
+ ): FloatArray?
350
+ private external fun nativeLayoutTreeV3(
351
+ width: Float,
352
+ height: Float,
353
+ nodeData: FloatArray,
354
+ treeMeta: FloatArray,
355
+ ): FloatArray?
344
356
  private external fun nativeClipboardRead(activity: Activity): String?
345
357
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
346
358
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -601,6 +613,28 @@ object CraftNative {
601
613
  }
602
614
  }
603
615
 
616
+ /** Resolve a reverse-aware tree in Zig, or null for Kotlin fallback. */
617
+ fun layoutTreeV2(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
618
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
619
+ return try {
620
+ nativeLayoutTreeV2(width, height, nodeData, treeMeta)
621
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
622
+ } catch (e: UnsatisfiedLinkError) {
623
+ null
624
+ }
625
+ }
626
+
627
+ /** Resolve a wrap-reverse-aware tree in Zig, or null for Kotlin fallback. */
628
+ fun layoutTreeV3(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
629
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
630
+ return try {
631
+ nativeLayoutTreeV3(width, height, nodeData, treeMeta)
632
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
633
+ } catch (e: UnsatisfiedLinkError) {
634
+ null
635
+ }
636
+ }
637
+
604
638
  /**
605
639
  * The clipboard's text, or null to use the Kotlin implementation.
606
640
  *
@@ -104,6 +104,7 @@ internal class CraftNativeCapabilities(
104
104
  val maxPixels: Int,
105
105
  val quality: Float,
106
106
  val completion: (CraftNativeCapabilityResult) -> Unit,
107
+ val videoLimit: Long = 0,
107
108
  )
108
109
 
109
110
  init {
@@ -166,6 +167,7 @@ internal class CraftNativeCapabilities(
166
167
  }
167
168
  "Files" -> when (method) {
168
169
  "pickImage" -> pickImage(requestToken, args, completion)
170
+ "pickVideo" -> pickVideo(requestToken, args, completion)
169
171
  "download", "write", "remove" -> networkExecutor.execute {
170
172
  complete(requestToken, files(requestToken, method, args), completion)
171
173
  }
@@ -238,21 +240,28 @@ internal class CraftNativeCapabilities(
238
240
  }
239
241
  }
240
242
  if (body != null || multipart is FileResult.Multipart) doOutput = true
241
- if (multipart is FileResult.Multipart) setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
243
+ if (multipart is FileResult.Multipart) {
244
+ setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
245
+ setFixedLengthStreamingMode(multipart.body.length())
246
+ readTimeout = 600_000
247
+ }
242
248
  }
243
249
  } catch (error: IllegalArgumentException) {
250
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
244
251
  return failure("INVALID_ARGUMENT", error.message ?: "fetch request is invalid")
245
252
  } catch (error: Exception) {
253
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
246
254
  return failure("NETWORK_ERROR", error.message ?: "Network request failed")
247
255
  }
248
256
  fetchConnections[requestToken] = connection
249
257
  if (cancelled.contains(requestToken)) {
250
258
  fetchConnections.remove(requestToken)?.disconnect()
259
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
251
260
  return failure("CANCELLED", "Network request was cancelled")
252
261
  }
253
262
  return try {
254
263
  if (body != null) connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
255
- if (multipart is FileResult.Multipart) connection.outputStream.use { it.write(multipart.body) }
264
+ if (multipart is FileResult.Multipart) connection.outputStream.use { output -> multipart.body.inputStream().use { it.copyTo(output, 64 * 1024) } }
256
265
  val status = connection.responseCode
257
266
  val input = if (status >= 400) connection.errorStream else connection.inputStream
258
267
  val output = ByteArrayOutputStream()
@@ -283,17 +292,21 @@ internal class CraftNativeCapabilities(
283
292
  failure("NETWORK_ERROR", error.message ?: "Network request failed")
284
293
  } finally {
285
294
  fetchConnections.remove(requestToken)?.disconnect()
295
+ if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
286
296
  }
287
297
  }
288
298
 
289
299
  private sealed interface FileResult {
290
- data class Multipart(val body: ByteArray, val boundary: String) : FileResult
300
+ data class Multipart(val body: File, val boundary: String) : FileResult
291
301
  data class Failure(val code: String, val message: String) : FileResult
292
302
  }
293
303
 
294
304
  private fun multipart(parts: JSONArray): FileResult {
295
305
  val boundary = "craft-${UUID.randomUUID()}"
296
- val body = ByteArrayOutputStream()
306
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve("upload.multipart")
307
+ var kept = false
308
+ try {
309
+ FileOutputStream(file).use { body ->
297
310
  fun append(value: String) { body.write(value.toByteArray(Charsets.UTF_8)) }
298
311
  for (index in 0 until parts.length()) {
299
312
  val part = parts.optJSONObject(index) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form part must be an object")
@@ -303,14 +316,11 @@ internal class CraftNativeCapabilities(
303
316
  val path = part.optString("path")
304
317
  if (path.isNotBlank()) {
305
318
  val file = ownedFile(path) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form file must be one craft.files made, and still there")
306
- val bytes = runCatching { file.readBytes() }.getOrElse {
307
- return FileResult.Failure("INVALID_ARGUMENT", "A form file could not be read")
308
- }
309
319
  val filename = safeFileName(part.optString("filename").ifBlank { file.name })
310
320
  val type = part.optString("type").ifBlank { URLConnection.guessContentTypeFromName(filename) ?: "application/octet-stream" }
311
321
  append("Content-Disposition: form-data; name=\"${quoted(name)}\"; filename=\"${quoted(filename)}\"\r\n")
312
322
  append("Content-Type: ${headerValue(type)}\r\n\r\n")
313
- body.write(bytes)
323
+ file.inputStream().use { it.copyTo(body, 64 * 1024) }
314
324
  append("\r\n")
315
325
  } else {
316
326
  append("Content-Disposition: form-data; name=\"${quoted(name)}\"\r\n\r\n")
@@ -319,7 +329,14 @@ internal class CraftNativeCapabilities(
319
329
  }
320
330
  }
321
331
  append("--$boundary--\r\n")
322
- return FileResult.Multipart(body.toByteArray(), boundary)
332
+ }
333
+ kept = true
334
+ return FileResult.Multipart(file, boundary)
335
+ } catch (error: Exception) {
336
+ return FileResult.Failure("WRITE_ERROR", error.message ?: "A form file could not be read")
337
+ } finally {
338
+ if (!kept) file.parentFile?.deleteRecursively()
339
+ }
323
340
  }
324
341
 
325
342
  private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
@@ -417,6 +434,64 @@ internal class CraftNativeCapabilities(
417
434
  return success(JSONObject().put("ok", false).put("status", status).put("body", body.toString(Charsets.UTF_8.name())))
418
435
  }
419
436
 
437
+ private fun pickVideo(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
438
+ mainHandler.post {
439
+ val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
440
+ if (pendingFilePick != null) {
441
+ complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
442
+ return@post
443
+ }
444
+ val options = args.optJSONObject(0) ?: JSONObject()
445
+ val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
446
+ pendingFilePick = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
447
+ val photos = options.optString("source", "library") != "files" && Build.VERSION.SDK_INT >= 33
448
+ val intent = Intent(if (photos) MediaStore.ACTION_PICK_IMAGES else Intent.ACTION_OPEN_DOCUMENT).apply {
449
+ type = "video/*"
450
+ if (!photos) addCategory(Intent.CATEGORY_OPENABLE)
451
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
452
+ }
453
+ try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
454
+ catch (error: Exception) {
455
+ pendingFilePick = null
456
+ complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Video picker could not be opened"), completion)
457
+ }
458
+ }
459
+ }
460
+
461
+ /** Copy with a bound even when a cloud provider does not report its size. */
462
+ private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult {
463
+ var target: File? = null
464
+ return try {
465
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
466
+ if (it.moveToFirst()) it.getString(0) else null
467
+ } ?: "video.mp4"
468
+ val types = mapOf("mp4" to "video/mp4", "m4v" to "video/x-m4v", "mov" to "video/quicktime", "webm" to "video/webm")
469
+ val type = types[name.substringAfterLast('.', "").lowercase()]
470
+ ?: return failure("INVALID_VIDEO", "Choose an MP4, MOV or WebM video.")
471
+ val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
472
+ target = destination
473
+ val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The video could not be read")
474
+ var size = 0L
475
+ input.use { source ->
476
+ destination.outputStream().use { output ->
477
+ val buffer = ByteArray(64 * 1024)
478
+ while (true) {
479
+ val count = source.read(buffer)
480
+ if (count < 0) break
481
+ size += count
482
+ require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
483
+ output.write(buffer, 0, count)
484
+ }
485
+ }
486
+ }
487
+ require(size > 0) { "Choose a nonempty video." }
488
+ success(descriptor(destination, type))
489
+ } catch (error: Exception) {
490
+ target?.parentFile?.deleteRecursively()
491
+ failure("READ_ERROR", error.message ?: "The video could not be read")
492
+ }
493
+ }
494
+
420
495
  private fun pickImage(
421
496
  requestToken: String,
422
497
  args: JSONArray,
@@ -465,6 +540,14 @@ internal class CraftNativeCapabilities(
465
540
  complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
466
541
  return true
467
542
  }
543
+ if (pending.source == "video") {
544
+ networkExecutor.execute {
545
+ val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
546
+ ?: failure("PICKER_FAILED", "The selected video could not be read")
547
+ complete(pending.requestToken, result, pending.completion)
548
+ }
549
+ return true
550
+ }
468
551
  val descriptor = if (pending.source == "camera") {
469
552
  val bitmap = data?.extras?.get("data") as? Bitmap
470
553
  bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
@@ -576,10 +576,14 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
576
576
  private val buttons = LinearLayout(context)
577
577
  private val tabBar = LinearLayout(context)
578
578
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
579
+ private val tabIconText = mutableMapOf<String, String>()
580
+ private val tabImageURLs = mutableMapOf<String, String>()
581
+ private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
579
582
  private val density = resources.displayMetrics.density
580
583
  private var selectedTab = 0
581
584
  private var swipeTracking = false
582
585
  private var swipeIntercepted = false
586
+ private var horizontalScrollDeferred = false
583
587
  private var swipeDownX = 0f
584
588
  private var swipeDownY = 0f
585
589
  private val swipeSlop = ViewConfiguration.get(context).scaledTouchSlop
@@ -590,6 +594,8 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
590
594
  var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
591
595
  var onTabSwipeMove: ((Float) -> Unit)? = null
592
596
  var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
597
+ /** Lets a native horizontal scroller own the gesture from its first down. */
598
+ var onTabSwipeShouldDefer: ((Float, Float) -> Boolean)? = null
593
599
  var tabSwipeEnabled = true
594
600
 
595
601
  init {
@@ -654,7 +660,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
654
660
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
655
661
  when (event.actionMasked) {
656
662
  MotionEvent.ACTION_DOWN -> {
657
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
663
+ val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
664
+ horizontalScrollDeferred = overHorizontalScroll
665
+ if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
666
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
658
667
  swipeIntercepted = false
659
668
  swipeDownX = event.x
660
669
  swipeDownY = event.y
@@ -668,7 +677,13 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
668
677
  return swipeIntercepted
669
678
  }
670
679
  }
671
- MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> swipeTracking = false
680
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
681
+ swipeTracking = false
682
+ if (horizontalScrollDeferred) {
683
+ horizontalScrollDeferred = false
684
+ parent?.requestDisallowInterceptTouchEvent(false)
685
+ }
686
+ }
672
687
  }
673
688
  return swipeIntercepted
674
689
  }
@@ -691,6 +706,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
691
706
 
692
707
  /** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
693
708
  fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
709
+ options.optJSONObject("tabItem")?.let(::updateTab)
694
710
  val hidden = options.optBoolean("hidden", false)
695
711
  val large = options.optBoolean("largeTitle", false) && depth == 0
696
712
  val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
@@ -716,6 +732,42 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
716
732
  refreshColors()
717
733
  }
718
734
 
735
+ private fun updateTab(item: JSONObject) {
736
+ val id = item.optString("id")
737
+ val index = tabs.indexOfFirst { it.id == id }
738
+ if (index < 0) return
739
+ val cell = tabBar.getChildAt(index)
740
+ if (item.has("accessibilityLabel")) cell.contentDescription = item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() } ?: tabs[index].title
741
+ if (item.has("iconText")) {
742
+ tabIconText.remove(id)
743
+ item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
744
+ }
745
+ if (item.has("image")) {
746
+ val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
747
+ if (address != tabImageURLs[id]) {
748
+ tabImages.remove(id)
749
+ tabImageURLs.remove(id)
750
+ if (address != null) {
751
+ tabImageURLs[id] = address
752
+ Thread {
753
+ val image = runCatching {
754
+ val connection = java.net.URL(address).openConnection().apply { connectTimeout = 5000; readTimeout = 5000 }
755
+ val bitmap = connection.getInputStream().use { android.graphics.BitmapFactory.decodeStream(it) } ?: return@runCatching null
756
+ androidx.core.graphics.drawable.RoundedBitmapDrawableFactory.create(resources, android.graphics.Bitmap.createScaledBitmap(bitmap, dp(27), dp(27), true)).apply { isCircular = true }
757
+ }.getOrNull()
758
+ post {
759
+ if (tabImageURLs[id] == address && image != null) {
760
+ tabImages[id] = image
761
+ refreshColors()
762
+ }
763
+ }
764
+ }.start()
765
+ }
766
+ }
767
+ }
768
+ refreshColors()
769
+ }
770
+
719
771
  /** Light or dark changed: the bars follow the screen's background. */
720
772
  fun refreshColors() {
721
773
  val background = ContextCompat.getColor(context, R.color.background)
@@ -737,11 +789,12 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
737
789
  )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
738
790
  tabViews.forEachIndexed { index, (icon, label) ->
739
791
  val color = if (index == selectedTab) accent else muted
740
- icon.setTextColor(color)
792
+ val number = tabIconText[tabs[index].id]
793
+ icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
741
794
  label.setTextColor(color)
742
- val vector = CraftNativeIcons.drawable(context, null, tabs[index].symbol, color)
795
+ val vector = tabImages[tabs[index].id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, color) else null
743
796
  icon.foreground = vector
744
- if (vector != null) icon.text = ""
797
+ icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
745
798
  }
746
799
  for (index in 0 until buttons.childCount) (buttons.getChildAt(index) as? TextView)?.setTextColor(accent)
747
800
  }