craft-native 0.0.163 → 0.0.166

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.
@@ -1,10 +1,12 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
+ import android.animation.ValueAnimator
3
4
  import android.content.ClipData
4
5
  import android.content.ClipboardManager
5
6
  import android.content.Context
6
7
  import android.content.res.ColorStateList
7
8
  import android.content.res.Configuration
9
+ import android.database.ContentObserver
8
10
  import android.content.Intent
9
11
  import android.media.AudioAttributes
10
12
  import android.media.AudioFormat
@@ -14,9 +16,13 @@ import android.graphics.Canvas
14
16
  import android.graphics.Color
15
17
  import android.graphics.DashPathEffect
16
18
  import android.graphics.Matrix
19
+ import android.graphics.Outline
17
20
  import android.graphics.Paint
18
21
  import android.graphics.Path
22
+ import android.graphics.RectF
19
23
  import android.graphics.Typeface
24
+ import android.graphics.ColorFilter
25
+ import android.graphics.PixelFormat
20
26
  import android.graphics.drawable.Drawable
21
27
  import android.graphics.drawable.GradientDrawable
22
28
  import android.net.Uri
@@ -26,10 +32,14 @@ import android.os.Handler
26
32
  import android.os.Looper
27
33
  import android.os.VibrationEffect
28
34
  import android.os.Vibrator
35
+ import android.provider.Settings
29
36
  import android.text.Editable
30
37
  import android.text.InputFilter
31
38
  import android.text.TextUtils
32
39
  import android.text.TextWatcher
40
+ import android.text.Spannable
41
+ import android.text.SpannableString
42
+ import android.text.style.ReplacementSpan
33
43
  import android.util.Log
34
44
  import android.util.Base64
35
45
  import android.util.TypedValue
@@ -41,6 +51,7 @@ import android.text.InputType
41
51
  import android.text.Layout
42
52
  import android.view.View
43
53
  import android.view.ViewGroup
54
+ import android.view.ViewOutlineProvider
44
55
  import android.view.ViewTreeObserver
45
56
  import android.widget.AdapterView
46
57
  import android.widget.ArrayAdapter
@@ -85,9 +96,11 @@ import java.util.Locale
85
96
  import java.util.concurrent.Executors
86
97
  import java.util.concurrent.Future
87
98
  import java.util.UUID
99
+ import java.util.WeakHashMap
88
100
  import kotlin.math.max
89
101
  import kotlin.math.min
90
102
  import kotlin.math.PI
103
+ import kotlin.math.ceil
91
104
  import kotlin.math.roundToInt
92
105
  import kotlin.math.roundToLong
93
106
  import kotlin.math.sin
@@ -177,6 +190,32 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
177
190
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
178
191
  }
179
192
 
193
+ /** Android View has no skew setters before API 29; draw the shared skew around the view's center. */
194
+ private object CraftNativeSkewTransforms {
195
+ private val values = WeakHashMap<View, Pair<Float, Float>>()
196
+
197
+ fun set(view: View, x: Float, y: Float) {
198
+ if (x == 0f && y == 0f) values.remove(view) else values[view] = x to y
199
+ view.invalidate()
200
+ }
201
+
202
+ fun clear(view: View) {
203
+ values.remove(view)
204
+ view.invalidate()
205
+ }
206
+
207
+ fun matrix(view: View): Matrix? {
208
+ val (x, y) = values[view] ?: return null
209
+ val pivotX = view.width / 2f
210
+ val pivotY = view.height / 2f
211
+ return Matrix().apply {
212
+ setTranslate(-pivotX, -pivotY)
213
+ postSkew(x, y)
214
+ postTranslate(pivotX, pivotY)
215
+ }
216
+ }
217
+ }
218
+
180
219
  /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
181
220
  private class CraftNativeSvgView(context: Context) : View(context) {
182
221
  var drawing: JSONObject = JSONObject()
@@ -202,13 +241,16 @@ private class CraftNativeSvgView(context: Context) : View(context) {
202
241
  props.has(name) && !props.isNull(name) -> props.optString(name)
203
242
  else -> null
204
243
  }
244
+ // Keep the cascade explicit: computed style wins over a raw prop
245
+ // when both provide the same SVG paint value.
205
246
  val weight = value("fontWeight")
247
+ val ownOpacity = value("opacity")?.toFloatOrNull() ?: 1f
206
248
  return copy(
207
249
  fill = value("fill") ?: fill,
208
250
  stroke = value("stroke") ?: stroke,
209
251
  strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
210
252
  color = value("color") ?: color,
211
- opacity = (value("opacity")?.toFloatOrNull() ?: 1f) * opacity,
253
+ opacity = ownOpacity * opacity,
212
254
  typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
213
255
  )
214
256
  }
@@ -393,23 +435,35 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
393
435
  private var alignItems = "stretch"
394
436
  private var alignContent = "flex-start"
395
437
  private var justifyContent = "flex-start"
396
- private var rowGap = 0
397
- private var columnGap = 0
438
+ private var rowGap = 0f
439
+ private var columnGap = 0f
398
440
  private var grid = false
399
441
  private var gridColumns = 1
400
442
  private var gridAutoRows: CraftNativeLength? = null
401
443
  private var reverse = false
444
+ private var wrapReverse = false
445
+
446
+ override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
447
+ val skew = CraftNativeSkewTransforms.matrix(child) ?: return super.drawChild(canvas, child, drawingTime)
448
+ val save = canvas.save()
449
+ canvas.concat(skew)
450
+ val drawn = super.drawChild(canvas, child, drawingTime)
451
+ canvas.restoreToCount(save)
452
+ return drawn
453
+ }
402
454
 
403
455
  fun configure(style: JSONObject, density: Float) {
404
456
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
405
457
  reverse = style.optString("flexDirection").endsWith("-reverse")
406
- wrap = style.optString("flexWrap") == "wrap" || style.optBoolean("flexWrap", false)
458
+ val flexWrap = style.optString("flexWrap")
459
+ wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style.optBoolean("flexWrap", false)
460
+ wrapReverse = flexWrap == "wrap-reverse"
407
461
  alignItems = style.optString("alignItems", "stretch")
408
462
  alignContent = style.optString("alignContent", "flex-start")
409
463
  justifyContent = style.optString("justifyContent", "flex-start")
410
- val gap = (style.optDouble("gap", 0.0) * density).toInt()
411
- rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
412
- columnGap = (style.optDouble("columnGap", gap.toDouble() / density.toDouble()) * density).toInt()
464
+ val gap = style.optDouble("gap", 0.0) * density
465
+ rowGap = style.optDouble("rowGap", gap / density.toDouble()).toFloat() * density
466
+ columnGap = style.optDouble("columnGap", gap / density.toDouble()).toFloat() * density
413
467
  grid = style.optString("display") == "grid"
414
468
  gridColumns = craftNativeGridColumnCount(
415
469
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
@@ -438,8 +492,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
438
492
  }
439
493
 
440
494
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
441
- private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Int = 0, var cross: Int = 0)
442
- private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Int, val rowHeights: List<Int>)
495
+ private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Float = 0f, var cross: Float = 0f)
496
+ private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Float, val rowHeights: List<Float>)
443
497
 
444
498
  private fun entries(width: Int, height: Int): Pair<List<Line>, List<Entry>> {
445
499
  val availableMain = if (orientation == HORIZONTAL) max(0, width - paddingLeft - paddingRight)
@@ -487,8 +541,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
487
541
  }
488
542
  var line = Line()
489
543
  pending.forEachIndexed { index, entry ->
490
- val childMain = if (orientation == HORIZONTAL) entry.width + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
491
- else entry.height + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
544
+ val childMain = if (orientation == HORIZONTAL) entry.width.toFloat() + margins[index * 2] + margins[index * 2 + 1]
545
+ else entry.height.toFloat() + margins[index * 2] + margins[index * 2 + 1]
492
546
  val lp = entry.view.layoutParams as? MarginLayoutParams
493
547
  val crossMargins = if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
494
548
  else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
@@ -502,17 +556,22 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
502
556
  }
503
557
  line.entries += entry
504
558
  line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
505
- line.cross = max(line.cross, childCross)
559
+ line.cross = max(line.cross, childCross.toFloat())
506
560
  }
507
561
  if (line.entries.isNotEmpty()) lines += line
508
562
  }
563
+ // Reconciliation reverses the child order so the main axis runs in
564
+ // the requested direction. With wrapping, that also reverses the
565
+ // line order; flexbox keeps wrap lines in cross-axis order, so restore
566
+ // the groups while retaining the reversed entries inside each line.
567
+ if (reverse && wrap && lines.size > 1) lines.reverse()
509
568
  return lines to absolute
510
569
  }
511
570
 
512
571
  private fun gridLayout(width: Int, height: Int): GridLayout {
513
572
  val availableWidth = max(0, width - paddingLeft - paddingRight)
514
573
  val gap = columnGap
515
- val cellWidth = max(0, (availableWidth - max(0, gridColumns - 1) * gap) / max(1, gridColumns))
574
+ val cellWidth = max(0f, (availableWidth.toFloat() - max(0, gridColumns - 1) * gap) / max(1, gridColumns).toFloat())
516
575
  val rows = mutableListOf<MutableList<Entry>>()
517
576
  val absolute = mutableListOf<Entry>()
518
577
  for (index in 0 until childCount) {
@@ -532,7 +591,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
532
591
  absolute += Entry(child, style, modalWidth, modalHeight)
533
592
  continue
534
593
  }
535
- val childWidth = measureChildWidth(child, style, cellWidth, horizontalMargins)
594
+ val childWidth = measureChildWidth(child, style, cellWidth.roundToInt(), horizontalMargins)
536
595
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
537
596
  val entry = Entry(child, style, childWidth, childHeight)
538
597
  if (style.position == "absolute") absolute += entry
@@ -542,10 +601,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
542
601
  }
543
602
  }
544
603
  val rowHeights = rows.map { row ->
545
- max(gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0, row.maxOfOrNull { entry ->
604
+ max((gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0).toFloat(), row.maxOfOrNull { entry ->
546
605
  val lp = entry.view.layoutParams as? MarginLayoutParams
547
- entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
548
- } ?: 0)
606
+ (entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)).toFloat()
607
+ } ?: 0f)
549
608
  }
550
609
  return GridLayout(rows, absolute, cellWidth, rowHeights)
551
610
  }
@@ -573,9 +632,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
573
632
  flowIndex += 1
574
633
  }
575
634
  val cellWidth = columnWidths.maxOrNull() ?: 0
576
- val width = paddingLeft + paddingRight + cellWidth * columns + max(0, columns - 1) * columnGap
635
+ val width = paddingLeft + paddingRight + cellWidth * columns + (max(0, columns - 1) * columnGap).roundToInt()
577
636
  val rowGapTotal = max(0, rowHeights.size - 1) * rowGap
578
- val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal
637
+ val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal.roundToInt()
579
638
  return width to measuredHeight
580
639
  }
581
640
 
@@ -658,15 +717,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
658
717
  }
659
718
 
660
719
  /** Keep the old-runtime shim on the same bounded-flex water-fill rule as the Zig bridge. */
661
- private fun fallbackFlexWidths(line: Line, available: Int, gap: Int): IntArray {
720
+ private fun fallbackFlexWidths(line: Line, available: Int, gap: Float): IntArray {
662
721
  val horizontal = orientation == HORIZONTAL
663
722
  val hypothetical = line.entries.sumOf { entry ->
664
723
  val lp = entry.view.layoutParams as? MarginLayoutParams
665
724
  val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
666
725
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
667
- (if (horizontal) entry.width else entry.height) + margins
668
- } + max(0, line.entries.size - 1) * gap
669
- val free = available - hypothetical
726
+ ((if (horizontal) entry.width else entry.height) + margins).toDouble()
727
+ } + max(0, line.entries.size - 1) * gap.toDouble()
728
+ val free = available.toDouble() - hypothetical
670
729
  val growTotal = line.entries.sumOf { it.style.flexGrow }
671
730
  val shrinkTotal = line.entries.sumOf {
672
731
  (it.style.flexShrink ?: defaultFlexShrink(it.view)) * (if (horizontal) it.width else it.height)
@@ -695,7 +754,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
695
754
  }
696
755
  }
697
756
  if (factor <= 0) return@repeat
698
- val remaining = available - fixed - activeBasis
757
+ val remaining = available.toDouble() - fixed - activeBasis
699
758
  var froze = false
700
759
  for ((index, entry) in line.entries.withIndex()) {
701
760
  if (frozen[index]) continue
@@ -733,16 +792,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
733
792
  return
734
793
  }
735
794
  val layout = gridLayout(width, height)
736
- val desiredWidth = paddingLeft + paddingRight + layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap
737
- val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum() + max(0, layout.rowHeights.size - 1) * rowGap
795
+ val desiredWidth = paddingLeft + paddingRight + (layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap).roundToInt()
796
+ val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum().roundToInt() + (max(0, layout.rowHeights.size - 1) * rowGap).roundToInt()
738
797
  setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
739
798
  return
740
799
  }
741
800
  val (lines, absolute) = entries(width, height)
742
- val main = lines.maxOfOrNull { it.main } ?: 0
743
- val cross = lines.sumOf { it.cross } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap)
744
- val desiredWidth = if (orientation == HORIZONTAL) main else cross
745
- val desiredHeight = if (orientation == HORIZONTAL) cross else main
801
+ val main = lines.maxOfOrNull { it.main } ?: 0f
802
+ val cross = lines.sumOf { it.cross.toDouble() } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap).toDouble()
803
+ val desiredWidth = if (orientation == HORIZONTAL) main.roundToInt() else cross.roundToInt()
804
+ val desiredHeight = if (orientation == HORIZONTAL) cross.roundToInt() else main.roundToInt()
746
805
  val absWidth = absolute.maxOfOrNull { it.width } ?: 0
747
806
  val absHeight = absolute.maxOfOrNull { it.height } ?: 0
748
807
  setMeasuredDimension(
@@ -760,12 +819,12 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
760
819
  // Older runtimes return null for the optional symbol; the local
761
820
  // layout below remains the compatibility path in that case.
762
821
  val sharedFrames = CraftNative.grid(
763
- contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap.toFloat(), rowGap.toFloat(),
822
+ contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
764
823
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
765
- flow.map { it.style.width?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
824
+ flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
766
825
  flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
767
- flow.map { it.style.minWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
768
- flow.map { it.style.maxWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
826
+ flow.map { it.style.minWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
827
+ flow.map { it.style.maxWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
769
828
  flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
770
829
  flow.map { it.style.maxHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
771
830
  flow.flatMap {
@@ -790,8 +849,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
790
849
  }
791
850
  } else {
792
851
  val totalRows = layout.rowHeights.sum()
793
- val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap
794
- val freeRows = max(0, contentHeight - totalRows - rowGaps).toFloat()
852
+ val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap.toFloat()
853
+ val freeRows = max(0f, contentHeight.toFloat() - totalRows - rowGaps)
795
854
  val rowCount = max(1, layout.rowHeights.size)
796
855
  var rowLeading = 0f
797
856
  var rowBetween = rowGap.toFloat()
@@ -820,15 +879,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
820
879
  val marginRight = lp?.rightMargin ?: 0
821
880
  val marginTop = lp?.topMargin ?: 0
822
881
  val marginBottom = lp?.bottomMargin ?: 0
823
- val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
882
+ val availableWidth = max(0f, layout.cellWidth - marginLeft - marginRight)
824
883
  val availableHeight = max(0, (rowHeight - marginTop - marginBottom).roundToInt())
825
- val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
884
+ val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth.roundToInt() else min(availableWidth.roundToInt(), entry.width)
826
885
  val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
827
- val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
828
- "center" -> (availableWidth - childWidth) / 2
886
+ val x = (paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
887
+ "center" -> (availableWidth - childWidth) / 2f
829
888
  "flex-end" -> availableWidth - childWidth
830
- else -> 0
831
- }
889
+ else -> 0f
890
+ }).roundToInt()
832
891
  val y = (rowTop + marginTop + when (align) {
833
892
  "center" -> (availableHeight - childHeight) / 2
834
893
  "flex-end" -> availableHeight - childHeight
@@ -857,42 +916,45 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
857
916
  val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
858
917
  val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
859
918
  val availableCross = if (orientation == HORIZONTAL) contentHeight else contentWidth
860
- val totalCross = lines.sumOf { it.cross } + max(0, lines.size - 1) * crossGap
861
- val crossFree = max(0, availableCross - totalCross)
862
- var crossLeading = 0
919
+ val totalCross = lines.sumOf { it.cross.toDouble() }.toFloat() + max(0, lines.size - 1) * crossGap
920
+ val crossFree = max(0f, availableCross.toFloat() - totalCross)
921
+ var crossLeading = 0f
863
922
  var crossBetween = crossGap
864
923
  val sharedCross = if (wrap && lines.size > 1) {
865
924
  CraftNative.flexLineDistribution(
866
- availableCross.toFloat(), crossGap.toFloat(), alignContentCode(), lines.map { it.cross.toFloat() }.toFloatArray()
925
+ availableCross.toFloat(), crossGap, alignContentCode(), lines.map { it.cross }.toFloatArray()
867
926
  )
868
927
  } else null
869
- sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size.roundToInt() }
928
+ sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size }
870
929
  if (wrap && lines.size > 1) {
871
930
  if (sharedCross == null) when (alignContent) {
872
- "center" -> crossLeading = crossFree / 2
931
+ "center" -> crossLeading = crossFree / 2f
873
932
  "flex-end" -> crossLeading = crossFree
874
- "space-between" -> crossBetween = crossFree / (lines.size - 1) + crossGap
933
+ "space-between" -> crossBetween = crossFree / (lines.size - 1).toFloat() + crossGap
875
934
  "space-around" -> {
876
- crossBetween = crossFree / lines.size + crossGap
877
- crossLeading = crossFree / (lines.size * 2)
935
+ crossBetween = crossFree / lines.size.toFloat() + crossGap
936
+ crossLeading = crossFree / (lines.size * 2).toFloat()
878
937
  }
879
938
  "space-evenly" -> {
880
- crossBetween = crossFree / (lines.size + 1) + crossGap
881
- crossLeading = crossFree / (lines.size + 1)
882
- }
883
- "stretch" -> {
884
- val extra = crossFree / lines.size
885
- lines.forEach { it.cross += extra }
939
+ crossBetween = crossFree / (lines.size + 1).toFloat() + crossGap
940
+ crossLeading = crossFree / (lines.size + 1).toFloat()
886
941
  }
887
942
  }
888
943
  }
889
- var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
944
+ val stretchExtra = if (sharedCross == null && wrap && lines.size > 1 && alignContent == "stretch")
945
+ crossFree / lines.size.toFloat()
946
+ else 0f
947
+ var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat() + crossLeading
948
+ val crossOrigin = (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat()
949
+ val crossExtent = availableCross.toFloat()
890
950
  for ((lineIndex, line) in lines.withIndex()) {
891
- val lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
951
+ val lineCross = line.cross.toFloat() + stretchExtra
952
+ val lineOffset = sharedCross?.first?.get(lineIndex)?.let { crossOrigin + it } ?: crossOffset
953
+ val physicalLineOffset = if (wrapReverse && wrap) crossOrigin + crossExtent - (lineOffset - crossOrigin) - lineCross else lineOffset
892
954
  val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
893
955
  val sharedPrimary = CraftNative.flexRow(
894
956
  availableMain.toFloat(),
895
- gap.toFloat(),
957
+ gap,
896
958
  justifyCode(),
897
959
  line.entries.map { entry -> if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat() }.toFloatArray(),
898
960
  line.entries.map { it.style.flexGrow.toFloat() }.toFloatArray(),
@@ -917,8 +979,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
917
979
  val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
918
980
  val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
919
981
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
920
- sharedPrimary[index].roundToInt() + margins
921
- } + max(0, line.entries.size - 1) * gap
982
+ sharedPrimary[index].toDouble() + margins
983
+ } + max(0, line.entries.size - 1) * gap.toDouble()
922
984
  } else {
923
985
  line.entries.indices.sumOf { index ->
924
986
  val constrained = fallbackWidths[index]
@@ -926,48 +988,64 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
926
988
  val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
927
989
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
928
990
  constrained + margins
929
- } + max(0, line.entries.size - 1) * gap
991
+ }.toDouble() + max(0, line.entries.size - 1) * gap.toDouble()
930
992
  }
931
- val free = max(0, availableMain - distributedMain)
993
+ val free = max(0f, availableMain.toFloat() - distributedMain.toFloat())
932
994
  val (leading, between) = distribution(free, line.entries.size)
933
995
  var mainOffset = (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + leading
996
+ val baseline = if (orientation == HORIZONTAL) {
997
+ line.entries.filter { (it.style.alignSelf ?: alignItems) == "baseline" }.maxOfOrNull { entry ->
998
+ val lp = entry.view.layoutParams as? MarginLayoutParams
999
+ (lp?.topMargin ?: 0) + baselineOffset(entry.view, entry.height)
1000
+ }
1001
+ } else null
934
1002
  for ((index, entry) in line.entries.withIndex()) {
935
1003
  val lp = entry.view.layoutParams as? MarginLayoutParams
936
1004
  val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
937
1005
  val marginEnd = if (orientation == HORIZONTAL) lp?.rightMargin ?: 0 else lp?.bottomMargin ?: 0
938
1006
  val crossSize = if ((entry.style.alignSelf ?: alignItems) == "stretch" && (if (orientation == HORIZONTAL) entry.style.height else entry.style.width) == null)
939
- max(0, line.cross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)))
1007
+ max(0, (lineCross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0))).roundToInt())
940
1008
  else if (orientation == HORIZONTAL) entry.height else entry.width
941
1009
  val align = entry.style.alignSelf ?: alignItems
942
- val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
943
- val crossTrailing = if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0
944
- val alignment = when (align) {
945
- "center" -> 1
946
- "flex-end" -> 2
947
- else -> 0
948
- }
949
- val crossPosition = CraftNative.crossAxisPosition(
950
- line.cross.toFloat(), crossSize.toFloat(), crossLeading.toFloat(), crossTrailing.toFloat(), alignment
951
- )?.roundToInt() ?: when (alignment) {
952
- 1 -> crossLeading + (line.cross - crossLeading - crossTrailing - crossSize) / 2
953
- 2 -> line.cross - crossTrailing - crossSize
954
- else -> crossLeading
1010
+ val crossLeading = (if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0).toFloat()
1011
+ val crossTrailing = (if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0).toFloat()
1012
+ val crossPosition = if (align == "baseline" && baseline != null) {
1013
+ val childBaseline = baselineOffset(entry.view, crossSize).toFloat()
1014
+ CraftNative.crossAxisPosition(
1015
+ baseline.toFloat(), childBaseline, crossLeading, crossTrailing, 3
1016
+ ) ?: max(crossLeading, baseline.toFloat() - childBaseline)
1017
+ } else {
1018
+ val alignment = when (align) {
1019
+ "center" -> 1
1020
+ "flex-end" -> 2
1021
+ else -> 0
1022
+ }
1023
+ CraftNative.crossAxisPosition(
1024
+ lineCross, crossSize.toFloat(), crossLeading, crossTrailing, alignment
1025
+ ) ?: when (alignment) {
1026
+ 1 -> crossLeading + (lineCross - crossLeading - crossTrailing - crossSize) / 2f
1027
+ 2 -> lineCross - crossTrailing - crossSize
1028
+ else -> crossLeading
1029
+ }
955
1030
  }
1031
+ val physicalCrossPosition = if (wrapReverse && wrap) lineCross - crossPosition - crossSize else crossPosition
956
1032
  // Shared positions cover every non-reverse justification; the
957
1033
  // local main-axis rule remains the fallback for old runtimes
958
1034
  // that return widths only.
959
- val sharedPosition = if (!reverse) sharedPositions?.get(index)?.roundToInt() else null
1035
+ // The shared result stores widths in the first half and
1036
+ // main-axis positions in the second half.
1037
+ val sharedPosition = if (!reverse) sharedPositions?.get(line.entries.size + index) else null
960
1038
  val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + it }
961
1039
  ?: mainOffset + marginStart
962
- val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else lineOffset + crossPosition
963
- val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain.roundToInt()
1040
+ val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else (physicalLineOffset + physicalCrossPosition).roundToInt()
1041
+ val y = if (orientation == HORIZONTAL) (physicalLineOffset + physicalCrossPosition).roundToInt() else positionedMain.roundToInt()
964
1042
  val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
965
1043
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
966
1044
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
967
1045
  entry.view.layout(x, y, x + w, y + h)
968
1046
  if (sharedPosition == null) mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
969
1047
  }
970
- if (sharedCross == null) crossOffset += line.cross + crossBetween
1048
+ if (sharedCross == null) crossOffset += lineCross + crossBetween
971
1049
  }
972
1050
  for (entry in absolute) {
973
1051
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
@@ -980,8 +1058,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
980
1058
  onLayoutChanged?.invoke()
981
1059
  }
982
1060
 
983
- private fun distribution(free: Int, count: Int): Pair<Float, Float> {
984
- val available = free.toFloat()
1061
+ private fun distribution(free: Float, count: Int): Pair<Float, Float> {
1062
+ val available = free
985
1063
  return when (justifyContent) {
986
1064
  "center" -> available / 2f to 0f
987
1065
  "flex-end" -> available to 0f
@@ -991,6 +1069,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
991
1069
  else -> 0f to 0f
992
1070
  }
993
1071
  }
1072
+
1073
+ /** First text baseline in pixels; non-text views align by their bottom. */
1074
+ private fun baselineOffset(view: View, cross: Int): Int = view.baseline.takeIf { it >= 0 }?.coerceIn(0, cross) ?: cross
994
1075
  }
995
1076
 
996
1077
  /** An image-backed flex container with ordinary native children over the image. */
@@ -1013,6 +1094,12 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
1013
1094
  private var behavior = "padding"
1014
1095
  private var keyboardVerticalOffset = 0
1015
1096
  private var keyboardInset = 0
1097
+ private var animationScaleObserverRegistered = false
1098
+ private val animationScaleObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
1099
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
1100
+ publishAppearance()
1101
+ }
1102
+ }
1016
1103
  private var basePadding = intArrayOf(0, 0, 0, 0)
1017
1104
 
1018
1105
  init {
@@ -1421,6 +1508,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1421
1508
  private var datePicker: DatePicker? = null
1422
1509
  private var timePicker: TimePicker? = null
1423
1510
  private var minuteInterval = 1
1511
+ private var countdownSecond = 0
1424
1512
  private var suppressChanges = true
1425
1513
  var onValueChanged: (() -> Unit)? = null
1426
1514
 
@@ -1472,6 +1560,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1472
1560
  pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1473
1561
  pickerDate(props.opt("maximumDate"))?.let { datePicker?.maxDate = it.timeInMillis }
1474
1562
  if (requestedMode == PickerMode.COUNTDOWN) {
1563
+ countdownSecond = 0
1475
1564
  countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
1476
1565
  } else {
1477
1566
  pickerDate(props.opt("value"))?.let { applyDate(it) }
@@ -1522,11 +1611,12 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1522
1611
  }
1523
1612
 
1524
1613
  private fun applyCountdown(seconds: Int) {
1614
+ countdownSecond = seconds % 60
1525
1615
  timePicker?.hour = (seconds / 3600).coerceIn(0, 23)
1526
1616
  timePicker?.minute = (seconds % 3600 / 60).coerceIn(0, 59)
1527
1617
  }
1528
1618
 
1529
- private fun currentCountdownSeconds(): Int = timePicker?.let { it.hour * 3600 + it.minute * 60 } ?: 0
1619
+ private fun currentCountdownSeconds(): Int = timePicker?.let { it.hour * 3600 + it.minute * 60 + countdownSecond } ?: 0
1530
1620
 
1531
1621
  private fun formatCountdown(seconds: Int): String = "%02d:%02d:%02d".format(
1532
1622
  Locale.US,
@@ -1572,11 +1662,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1572
1662
  val text = value?.toString()?.trim().orEmpty()
1573
1663
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1574
1664
  text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
1575
- val parts = text.split(':').mapNotNull { it.toDoubleOrNull() }
1665
+ val parts = text.split(':')
1576
1666
  if (parts.size != 2 && parts.size != 3) return null
1577
- val hours = parts[0]
1578
- val minutes = parts[1]
1579
- val seconds = if (parts.size == 3) parts[2] else 0.0
1667
+ val hours = parts[0].toDoubleOrNull() ?: return null
1668
+ val minutes = parts[1].toDoubleOrNull() ?: return null
1669
+ val seconds = if (parts.size == 3) parts[2].toDoubleOrNull() ?: return null else 0.0
1580
1670
  if (hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
1581
1671
  return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1582
1672
  }
@@ -1792,6 +1882,101 @@ private class CraftNativeAudio {
1792
1882
  }
1793
1883
  }
1794
1884
 
1885
+ private class CraftTextDecorationSpan(
1886
+ private val line: String,
1887
+ private val style: String,
1888
+ private val decorationColor: Int?,
1889
+ ) : ReplacementSpan() {
1890
+ override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
1891
+ return ceil(paint.measureText(text, start, end).toDouble()).toInt()
1892
+ }
1893
+
1894
+ override fun draw(
1895
+ canvas: Canvas,
1896
+ text: CharSequence,
1897
+ start: Int,
1898
+ end: Int,
1899
+ x: Float,
1900
+ top: Int,
1901
+ y: Int,
1902
+ bottom: Int,
1903
+ paint: Paint,
1904
+ ) {
1905
+ val originalColor = paint.color
1906
+ val originalStyle = paint.style
1907
+ val originalStrokeWidth = paint.strokeWidth
1908
+ val originalPathEffect = paint.pathEffect
1909
+ val originalStrokeCap = paint.strokeCap
1910
+ paint.style = Paint.Style.FILL
1911
+ paint.pathEffect = null
1912
+ canvas.drawText(text, start, end, x, y.toFloat(), paint)
1913
+
1914
+ val width = paint.measureText(text, start, end)
1915
+ val thickness = max(1f, paint.textSize / 16f)
1916
+ val underlineY = y + max(1f, paint.descent() * 0.15f)
1917
+ val strikeY = y - paint.textSize * 0.3f
1918
+ val decoration = decorationColor ?: originalColor
1919
+ paint.color = decoration
1920
+ paint.style = Paint.Style.STROKE
1921
+ paint.strokeWidth = thickness
1922
+ paint.strokeCap = if (style == "dotted") Paint.Cap.ROUND else Paint.Cap.BUTT
1923
+ paint.pathEffect = when (style) {
1924
+ "dashed" -> DashPathEffect(floatArrayOf(paint.textSize * 0.35f, paint.textSize * 0.25f), 0f)
1925
+ "dotted" -> DashPathEffect(floatArrayOf(thickness, paint.textSize * 0.22f), 0f)
1926
+ else -> null
1927
+ }
1928
+
1929
+ fun drawLine(position: Float) {
1930
+ canvas.drawLine(x, position, x + width, position, paint)
1931
+ }
1932
+ if (line == "underline" || line == "underline line-through") {
1933
+ drawLine(underlineY)
1934
+ if (style == "double") drawLine(underlineY + thickness * 2.5f)
1935
+ }
1936
+ if (line == "line-through" || line == "underline line-through") {
1937
+ drawLine(strikeY)
1938
+ if (style == "double") drawLine(strikeY + thickness * 2.5f)
1939
+ }
1940
+
1941
+ paint.color = originalColor
1942
+ paint.style = originalStyle
1943
+ paint.strokeWidth = originalStrokeWidth
1944
+ paint.pathEffect = originalPathEffect
1945
+ paint.strokeCap = originalStrokeCap
1946
+ }
1947
+ }
1948
+
1949
+ private class CraftNativeSideBorderDrawable(
1950
+ private val fillColor: Int,
1951
+ private val radii: FloatArray,
1952
+ private val widths: IntArray,
1953
+ private val colors: IntArray,
1954
+ ) : Drawable() {
1955
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
1956
+
1957
+ override fun draw(canvas: Canvas) {
1958
+ val bounds = RectF(this.bounds)
1959
+ paint.style = Paint.Style.FILL
1960
+ paint.color = fillColor
1961
+ if (Color.alpha(fillColor) > 0) {
1962
+ val path = Path().apply { addRoundRect(bounds, radii, Path.Direction.CW) }
1963
+ canvas.drawPath(path, paint)
1964
+ }
1965
+ val top = widths.getOrElse(0) { 0 }
1966
+ val right = widths.getOrElse(1) { 0 }
1967
+ val bottom = widths.getOrElse(2) { 0 }
1968
+ val left = widths.getOrElse(3) { 0 }
1969
+ if (top > 0) { paint.color = colors.getOrElse(0) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + top, paint) }
1970
+ if (right > 0) { paint.color = colors.getOrElse(1) { Color.TRANSPARENT }; canvas.drawRect(bounds.right - right, bounds.top, bounds.right, bounds.bottom, paint) }
1971
+ if (bottom > 0) { paint.color = colors.getOrElse(2) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.bottom - bottom, bounds.right, bounds.bottom, paint) }
1972
+ if (left > 0) { paint.color = colors.getOrElse(3) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.left + left, bounds.bottom, paint) }
1973
+ }
1974
+
1975
+ override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
1976
+ override fun setColorFilter(colorFilter: ColorFilter?) { paint.colorFilter = colorFilter; invalidateSelf() }
1977
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
1978
+ }
1979
+
1795
1980
  /** WebView-free host for a route bundle compiled by `stx native compile`. */
1796
1981
  class MainActivity : AppCompatActivity() {
1797
1982
  private class Screen(
@@ -2113,6 +2298,7 @@ class MainActivity : AppCompatActivity() {
2113
2298
  capabilities: ${capabilities.names()},
2114
2299
  initialAppState: ${JSONObject.quote(nativeAppState)},
2115
2300
  colorScheme: ${JSONObject.quote(colorScheme())},
2301
+ appearance: ${appearance()},
2116
2302
  postMessage: function(value) { port.postMessage(value); },
2117
2303
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
2118
2304
  };
@@ -2128,6 +2314,7 @@ class MainActivity : AppCompatActivity() {
2128
2314
  capabilities: ${capabilities.names()},
2129
2315
  initialAppState: ${JSONObject.quote(nativeAppState)},
2130
2316
  colorScheme: ${JSONObject.quote(colorScheme())},
2317
+ appearance: ${appearance()},
2131
2318
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
2132
2319
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
2133
2320
  };
@@ -2179,15 +2366,17 @@ class MainActivity : AppCompatActivity() {
2179
2366
 
2180
2367
  override fun onConfigurationChanged(newConfig: Configuration) {
2181
2368
  super.onConfigurationChanged(newConfig)
2369
+ publishAppearance()
2370
+ }
2371
+
2372
+ private fun publishAppearance() {
2182
2373
  if (!::root.isInitialized) return
2183
2374
  refreshThemeDefaults()
2184
2375
  ViewCompat.requestApplyInsets(root)
2185
2376
  refreshInputDefaultsForTraitChange()
2186
2377
  screens.lastOrNull()?.let { screen ->
2187
2378
  refreshScreenTheme(screen)
2188
- if (screen.active) {
2189
- send(screen, "APPEARANCE", JSONObject().put("colorScheme", colorScheme()))
2190
- }
2379
+ if (screen.active) send(screen, "APPEARANCE", appearance())
2191
2380
  }
2192
2381
  }
2193
2382
 
@@ -2212,6 +2401,25 @@ class MainActivity : AppCompatActivity() {
2212
2401
  resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
2213
2402
  ) "dark" else "light"
2214
2403
 
2404
+ private fun appearance(): JSONObject {
2405
+ val scale = resources.configuration.fontScale.toDouble().coerceAtLeast(0.1)
2406
+ val contentSize = when {
2407
+ scale >= 1.5 -> "accessibilityExtraExtraExtraLarge"
2408
+ scale >= 1.35 -> "accessibilityExtraExtraLarge"
2409
+ scale >= 1.2 -> "accessibilityExtraLarge"
2410
+ scale >= 1.1 -> "extraLarge"
2411
+ scale <= 0.85 -> "extraSmall"
2412
+ scale <= 0.92 -> "small"
2413
+ else -> "large"
2414
+ }
2415
+ return JSONObject()
2416
+ .put("contentSizeCategory", contentSize)
2417
+ .put("fontScale", (scale * 1000).roundToInt() / 1000.0)
2418
+ .put("reduceMotion", !ValueAnimator.areAnimatorsEnabled())
2419
+ .put("reduceTransparency", false)
2420
+ .put("colorScheme", colorScheme())
2421
+ }
2422
+
2215
2423
  private fun refreshInputDefaultsForTraitChange() {
2216
2424
  if (inputHintDefaults.isEmpty() && inputHighlightDefaults.isEmpty()) return
2217
2425
  val probe = EditText(this)
@@ -2529,7 +2737,12 @@ class MainActivity : AppCompatActivity() {
2529
2737
  (style.optDouble("paddingBottom", vertical) * density).toInt(),
2530
2738
  )
2531
2739
  }
2532
- autofillHintFor(props)?.let { setAutofillHints(it) }
2740
+ // Reused EditTexts must forget the previous field's hint as
2741
+ // well as accept a new one. Otherwise a password manager can
2742
+ // offer credentials in a field whose props no longer ask for
2743
+ // them after keyed reconciliation.
2744
+ val autofillHint = autofillHintFor(props)
2745
+ if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
2533
2746
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
2534
2747
  if (controlledValue != null && text.toString() != controlledValue) {
2535
2748
  screen.suppressedTextChanges.add(this)
@@ -2537,6 +2750,7 @@ class MainActivity : AppCompatActivity() {
2537
2750
  }
2538
2751
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
2539
2752
  else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
2753
+ applyTextDecoration(this, style)
2540
2754
  screen.inputIdentities[this] = identity
2541
2755
  val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
2542
2756
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -2723,12 +2937,29 @@ class MainActivity : AppCompatActivity() {
2723
2937
  val minimum = props.optDouble("minimumValue", 0.0)
2724
2938
  val maximumCandidate = props.optDouble("maximumValue", 1.0)
2725
2939
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
2726
- max = 1000
2727
2940
  setOnSeekBarChangeListener(null)
2728
2941
  val step = props.optDouble("step", 0.0).takeIf { it > 0 }
2942
+ // A stepped slider needs one native position per authored
2943
+ // step; a fixed 1,000-position SeekBar silently made large
2944
+ // ranges unable to represent most valid values. Continuous
2945
+ // sliders retain the bounded scale to avoid an enormous max.
2946
+ val progressMax = step?.let {
2947
+ // SeekBar stores progress as an Int. Do not impose a
2948
+ // smaller arbitrary ceiling: large authored ranges must
2949
+ // still be able to reach their final stepped value.
2950
+ ceil((maximum - minimum) / it).toLong()
2951
+ .coerceIn(1L, Int.MAX_VALUE.toLong())
2952
+ .toInt()
2953
+ } ?: 1000
2954
+ max = progressMax
2729
2955
  val requested = props.optDouble("value", minimum)
2730
- val value = if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested
2731
- progress = (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
2956
+ val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
2957
+ .coerceIn(minimum, maximum)
2958
+ progress = if (step != null) {
2959
+ ((value - minimum) / step).roundToInt().coerceIn(0, max)
2960
+ } else {
2961
+ (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
2962
+ }
2732
2963
  if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
2733
2964
  progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
2734
2965
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
@@ -2744,25 +2975,19 @@ class MainActivity : AppCompatActivity() {
2744
2975
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
2745
2976
  override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
2746
2977
  val current = screen.handlers[seekBar] ?: return
2747
- val raw = minimum + (maximum - minimum) * progress.toDouble() / max.toDouble()
2748
2978
  val step = screen.sliderSteps[seekBar]
2749
- val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
2750
- if (step != null) {
2751
- val snappedProgress = (((value - minimum) / (maximum - minimum)) * max)
2752
- .roundToInt().coerceIn(0, max)
2753
- if (seekBar.progress != snappedProgress) {
2754
- seekBar.progress = snappedProgress
2755
- return
2756
- }
2757
- }
2979
+ val value = (if (step != null) minimum + progress * step else
2980
+ minimum + (maximum - minimum) * progress.toDouble() / max.toDouble())
2981
+ .coerceIn(minimum, maximum)
2758
2982
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
2759
2983
  }
2760
2984
  override fun onStartTrackingTouch(seekBar: SeekBar) {}
2761
2985
  override fun onStopTrackingTouch(seekBar: SeekBar) {
2762
2986
  val current = screen.sliderCompleteHandlers[seekBar] ?: return
2763
- val raw = minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble()
2764
2987
  val step = screen.sliderSteps[seekBar]
2765
- val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
2988
+ val value = (if (step != null) minimum + seekBar.progress * step else
2989
+ minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble())
2990
+ .coerceIn(minimum, maximum)
2766
2991
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
2767
2992
  }
2768
2993
  })
@@ -3253,27 +3478,79 @@ class MainActivity : AppCompatActivity() {
3253
3478
 
3254
3479
  private fun applyViewStyle(view: View, style: JSONObject) {
3255
3480
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
3481
+ applyTransform(view, style.optJSONArray("transform"))
3256
3482
  if (view !is CraftNativeModalView) {
3257
3483
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
3258
3484
  }
3259
3485
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
3486
+ view.z = style.optDouble("zIndex", 0.0).toFloat()
3260
3487
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
3261
3488
  val border = style.optDouble("borderWidth", 0.0)
3262
3489
  val radius = style.optDouble("borderRadius", 0.0)
3490
+ val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
3491
+ val hasSideBorders = (sideBorderKeys + listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")).any(style::has)
3492
+ val sideWidths = sideBorderKeys.map { dp(style.optDouble(it, border)) }.toIntArray()
3493
+ val cornerRadiusKeys = listOf("borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius")
3494
+ val hasCornerRadii = cornerRadiusKeys.any(style::has)
3495
+ val cornerRadii = cornerRadiusKeys.flatMap { value ->
3496
+ val radius = dp(style.optDouble(value, radius)).toFloat()
3497
+ listOf(radius, radius)
3498
+ }.toFloatArray()
3499
+ val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
3500
+ .map { color(style.optString(it).ifBlank { style.optString("borderColor") }, Color.TRANSPARENT) }
3501
+ .toIntArray()
3263
3502
  val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
3264
3503
  defaultBackgrounds[view]
3265
3504
  } else {
3266
3505
  view.background.also { defaultBackgrounds[view] = it }
3267
3506
  }
3268
- if (background || border > 0 || radius > 0) {
3269
- view.background = GradientDrawable().apply {
3270
- setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
3271
- cornerRadius = dp(radius).toFloat()
3272
- if (border > 0) setStroke(dp(border), color(style.optString("borderColor"), Color.TRANSPARENT))
3507
+ if (background || border > 0 || radius > 0 || hasSideBorders) {
3508
+ if (hasSideBorders) {
3509
+ view.background = CraftNativeSideBorderDrawable(
3510
+ color(style.optString("backgroundColor"), Color.TRANSPARENT),
3511
+ cornerRadii,
3512
+ sideWidths,
3513
+ sideColors,
3514
+ )
3515
+ } else {
3516
+ view.background = GradientDrawable().apply {
3517
+ setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
3518
+ if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
3519
+ if (border > 0) {
3520
+ val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
3521
+ when (style.optString("borderStyle")) {
3522
+ "dashed" -> setStroke(dp(border), borderColor, dp(border * 3).toFloat(), dp(border * 2).toFloat())
3523
+ "dotted" -> setStroke(dp(border), borderColor, dp(border).toFloat(), dp(border * 1.5).toFloat())
3524
+ else -> setStroke(dp(border), borderColor)
3525
+ }
3526
+ }
3527
+ }
3273
3528
  }
3274
3529
  } else view.background = defaultBackground
3275
3530
  view.clipToOutline = style.optString("overflow") == "hidden"
3276
3531
  if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
3532
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
3533
+ (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
3534
+ ) {
3535
+ val shadowOpacity = style.optDouble("shadowOpacity", 1.0).coerceIn(0.0, 1.0)
3536
+ val shadow = color(style.optString("shadowColor"), Color.BLACK)
3537
+ val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
3538
+ view.outlineAmbientShadowColor = alphaShadow
3539
+ view.outlineSpotShadowColor = alphaShadow
3540
+ view.elevation = max(view.elevation, dp(style.optDouble("shadowRadius", 0.0) * 2.0).toFloat())
3541
+ val offset = style.optJSONObject("shadowOffset")
3542
+ val offsetX = dp(offset?.optDouble("width", 0.0) ?: 0.0)
3543
+ val offsetY = dp(offset?.optDouble("height", 0.0) ?: 0.0)
3544
+ val shadowRadius = dp(style.optDouble("shadowRadius", 0.0)).toFloat()
3545
+ view.outlineProvider = object : ViewOutlineProvider() {
3546
+ override fun getOutline(target: View, outline: Outline) {
3547
+ outline.setRoundRect(offsetX, offsetY, target.width + offsetX, target.height + offsetY, shadowRadius)
3548
+ }
3549
+ }
3550
+ view.invalidateOutline()
3551
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
3552
+ view.outlineProvider = ViewOutlineProvider.BACKGROUND
3553
+ }
3277
3554
  if (view is ImageView) {
3278
3555
  val tint = style.opt("tintColor") as? String
3279
3556
  if (tint.isNullOrBlank() || tint == "null" || tint == "undefined") {
@@ -3284,6 +3561,49 @@ class MainActivity : AppCompatActivity() {
3284
3561
  }
3285
3562
  }
3286
3563
 
3564
+ private fun applyTransform(view: View, transforms: JSONArray?) {
3565
+ view.translationX = 0f
3566
+ view.translationY = 0f
3567
+ view.scaleX = 1f
3568
+ view.scaleY = 1f
3569
+ view.rotation = 0f
3570
+ view.rotationX = 0f
3571
+ view.rotationY = 0f
3572
+ CraftNativeSkewTransforms.clear(view)
3573
+ if (transforms == null) return
3574
+ var skewX = 0f
3575
+ var skewY = 0f
3576
+ for (index in 0 until transforms.length()) {
3577
+ val transform = transforms.optJSONObject(index) ?: continue
3578
+ val key = transform.keys().asSequence().firstOrNull() ?: continue
3579
+ when (key) {
3580
+ "translateX" -> view.translationX += dp(transform.optDouble(key, 0.0)).toFloat()
3581
+ "translateY" -> view.translationY += dp(transform.optDouble(key, 0.0)).toFloat()
3582
+ "scale" -> transform.optDouble(key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
3583
+ "scaleX" -> view.scaleX *= transform.optDouble(key, 1.0).toFloat()
3584
+ "scaleY" -> view.scaleY *= transform.optDouble(key, 1.0).toFloat()
3585
+ "rotate", "rotateZ" -> view.rotation += angleDegrees(transform.opt(key))
3586
+ "rotateX" -> view.rotationX += angleDegrees(transform.opt(key))
3587
+ "rotateY" -> view.rotationY += angleDegrees(transform.opt(key))
3588
+ "skewX" -> skewX += skewFactor(transform.opt(key))
3589
+ "skewY" -> skewY += skewFactor(transform.opt(key))
3590
+ }
3591
+ }
3592
+ CraftNativeSkewTransforms.set(view, skewX, skewY)
3593
+ }
3594
+
3595
+ private fun skewFactor(value: Any?): Float =
3596
+ kotlin.math.tan(angleDegrees(value).toDouble() * PI / 180.0).toFloat()
3597
+
3598
+ private fun angleDegrees(value: Any?): Float {
3599
+ val text = value?.toString()?.trim() ?: return 0f
3600
+ return when {
3601
+ text.endsWith("rad") -> Math.toDegrees(text.dropLast(3).toDoubleOrNull() ?: 0.0).toFloat()
3602
+ text.endsWith("deg") -> text.dropLast(3).toFloatOrNull() ?: 0f
3603
+ else -> text.toFloatOrNull() ?: 0f
3604
+ }
3605
+ }
3606
+
3287
3607
  private fun ensureLayoutListener(screen: Screen, view: View) {
3288
3608
  if (screen.layoutListeners.containsKey(view)) return
3289
3609
  val listener = View.OnLayoutChangeListener { changed, left, top, right, bottom, _, _, _, _ ->
@@ -3392,13 +3712,59 @@ class MainActivity : AppCompatActivity() {
3392
3712
  style.optDouble("lineHeight", Double.NaN).takeUnless { it.isNaN() }?.let { dp(it) - view.textSize } ?: 0f,
3393
3713
  1f
3394
3714
  )
3715
+ val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
3716
+ if (shadowColor == null) {
3717
+ view.paint.clearShadowLayer()
3718
+ } else {
3719
+ val offset = style.optJSONObject("textShadowOffset")
3720
+ view.setShadowLayer(
3721
+ dp(style.optDouble("textShadowRadius", 0.0)).toFloat().coerceAtLeast(0f),
3722
+ dp(offset?.optDouble("width", 0.0) ?: 0.0).toFloat(),
3723
+ dp(offset?.optDouble("height", 0.0) ?: 0.0).toFloat(),
3724
+ color(shadowColor, Color.TRANSPARENT),
3725
+ )
3726
+ }
3395
3727
  view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
3396
- when (style.optString("textDecorationLine")) {
3397
- "underline" -> view.paintFlags = view.paintFlags or Paint.UNDERLINE_TEXT_FLAG
3398
- "line-through" -> view.paintFlags = view.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
3399
- "underline line-through" -> view.paintFlags = view.paintFlags or
3400
- Paint.UNDERLINE_TEXT_FLAG or Paint.STRIKE_THRU_TEXT_FLAG
3728
+ applyTextDecoration(view, style)
3729
+ }
3730
+
3731
+ private fun applyTextDecoration(view: TextView, style: JSONObject) {
3732
+ val line = style.optString("textDecorationLine")
3733
+ val text = view.text?.toString() ?: return
3734
+ if (line.isBlank() || line == "none" || text.isEmpty()) {
3735
+ if (view.text is Spannable) view.text = text
3736
+ return
3401
3737
  }
3738
+ val decorationColor = style.optString("textDecorationColor")
3739
+ .takeIf { it.isNotBlank() }
3740
+ ?.let { color(it, Color.TRANSPARENT) }
3741
+ val decorated = SpannableString(text)
3742
+ decorated.setSpan(
3743
+ CraftTextDecorationSpan(
3744
+ line,
3745
+ style.optString("textDecorationStyle").ifBlank { "solid" },
3746
+ decorationColor,
3747
+ ),
3748
+ 0,
3749
+ decorated.length,
3750
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
3751
+ )
3752
+ view.text = decorated
3753
+ }
3754
+
3755
+ /**
3756
+ * Match iOS image loading: HTTPS is allowed everywhere, while plain HTTP
3757
+ * is limited to loopback/development hosts. Android's generated manifest
3758
+ * enables cleartext traffic only for the corresponding dev-server or
3759
+ * local-network configurations.
3760
+ */
3761
+ private fun loadsImage(uri: Uri): Boolean {
3762
+ val scheme = uri.scheme?.lowercase() ?: return false
3763
+ if (scheme == "https") return true
3764
+ if (scheme != "http") return false
3765
+ val host = uri.host?.lowercase() ?: return false
3766
+ return host == "localhost" || host == "127.0.0.1" || host == "10.0.2.2" ||
3767
+ host == "::1" || host.endsWith(".localhost")
3402
3768
  }
3403
3769
 
3404
3770
  private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
@@ -3442,7 +3808,7 @@ class MainActivity : AppCompatActivity() {
3442
3808
  } else imageFailure(screen, view, uri, "Bundled image was not found")
3443
3809
  return
3444
3810
  }
3445
- if (!uri.startsWith("https://")) {
3811
+ if (!loadsImage(Uri.parse(uri))) {
3446
3812
  imageFailure(screen, view, uri, "Unsupported image source")
3447
3813
  return
3448
3814
  }
@@ -3582,14 +3948,30 @@ class MainActivity : AppCompatActivity() {
3582
3948
  val raw = props.optString("textContentType").ifBlank { props.optString("autoComplete") }
3583
3949
  return when (raw.replace("-", "").replace("_", "").lowercase()) {
3584
3950
  "username" -> View.AUTOFILL_HINT_USERNAME
3951
+ "nickname" -> "personNickname"
3585
3952
  "password", "currentpassword" -> View.AUTOFILL_HINT_PASSWORD
3586
3953
  "newpassword" -> "newPassword"
3587
3954
  "onetimecode" -> "smsOTPCode"
3588
3955
  "emailaddress", "email" -> View.AUTOFILL_HINT_EMAIL_ADDRESS
3589
3956
  "telephonenumber", "tel", "phone" -> View.AUTOFILL_HINT_PHONE
3590
3957
  "name" -> View.AUTOFILL_HINT_NAME
3958
+ "givenname", "firstname" -> "personGivenName"
3959
+ "middlename", "additionalname" -> "personMiddleName"
3960
+ "familyname", "lastname" -> "personFamilyName"
3961
+ "nameprefix", "honorificprefix" -> "personNamePrefix"
3962
+ "namesuffix", "honorificsuffix" -> "personNameSuffix"
3963
+ "organizationname", "organization" -> "organizationName"
3964
+ "jobtitle", "organizationtitle" -> "jobTitle"
3965
+ "url" -> "url"
3591
3966
  "postalcode" -> View.AUTOFILL_HINT_POSTAL_CODE
3592
3967
  "fullstreetaddress", "streetaddress" -> View.AUTOFILL_HINT_POSTAL_ADDRESS
3968
+ "streetaddressline1", "addressline1" -> "streetAddress"
3969
+ "streetaddressline2", "addressline2" -> "streetAddressLine2"
3970
+ "addresscity", "addresslevel2" -> "addressLocality"
3971
+ "addressstate", "addresslevel1" -> "addressRegion"
3972
+ "countryname", "country" -> "addressCountry"
3973
+ "location" -> "location"
3974
+ "creditcardnumber", "ccnumber" -> "creditCardNumber"
3593
3975
  else -> null
3594
3976
  }
3595
3977
  }
@@ -3600,6 +3982,11 @@ class MainActivity : AppCompatActivity() {
3600
3982
  "next" -> EditorInfo.IME_ACTION_NEXT
3601
3983
  "search" -> EditorInfo.IME_ACTION_SEARCH
3602
3984
  "send" -> EditorInfo.IME_ACTION_SEND
3985
+ // Android has no separate JOIN, ROUTE, CONTINUE, or EMERGENCY_CALL
3986
+ // action. GO/NEXT are the closest platform actions and still expose
3987
+ // an actionable IME key instead of silently falling back to NONE.
3988
+ "join", "route", "emergency-call" -> EditorInfo.IME_ACTION_GO
3989
+ "continue" -> EditorInfo.IME_ACTION_NEXT
3603
3990
  else -> EditorInfo.IME_ACTION_NONE
3604
3991
  }
3605
3992
 
@@ -3693,6 +4080,11 @@ class MainActivity : AppCompatActivity() {
3693
4080
 
3694
4081
  override fun onStart() {
3695
4082
  super.onStart()
4083
+ if (!animationScaleObserverRegistered) {
4084
+ contentResolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, animationScaleObserver)
4085
+ contentResolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE), false, animationScaleObserver)
4086
+ animationScaleObserverRegistered = true
4087
+ }
3696
4088
  nativeAppState = "active"
3697
4089
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
3698
4090
  }
@@ -3704,6 +4096,10 @@ class MainActivity : AppCompatActivity() {
3704
4096
  }
3705
4097
 
3706
4098
  override fun onStop() {
4099
+ if (animationScaleObserverRegistered) {
4100
+ contentResolver.unregisterContentObserver(animationScaleObserver)
4101
+ animationScaleObserverRegistered = false
4102
+ }
3707
4103
  nativeAppState = "background"
3708
4104
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
3709
4105
  super.onStop()
@@ -3947,6 +4343,7 @@ class MainActivity : AppCompatActivity() {
3947
4343
  }
3948
4344
 
3949
4345
  override fun onDestroy() {
4346
+ if (animationScaleObserverRegistered) contentResolver.unregisterContentObserver(animationScaleObserver)
3950
4347
  screens.forEach { closeScreen(it) }
3951
4348
  screens.clear()
3952
4349
  imageJobs.values.forEach { it.cancel(true) }