craft-native 0.0.164 → 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,6 +96,7 @@ 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
@@ -178,6 +190,32 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
178
190
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
179
191
  }
180
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
+
181
219
  /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
182
220
  private class CraftNativeSvgView(context: Context) : View(context) {
183
221
  var drawing: JSONObject = JSONObject()
@@ -403,11 +441,23 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
403
441
  private var gridColumns = 1
404
442
  private var gridAutoRows: CraftNativeLength? = null
405
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
+ }
406
454
 
407
455
  fun configure(style: JSONObject, density: Float) {
408
456
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
409
457
  reverse = style.optString("flexDirection").endsWith("-reverse")
410
- 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"
411
461
  alignItems = style.optString("alignItems", "stretch")
412
462
  alignContent = style.optString("alignContent", "flex-start")
413
463
  justifyContent = style.optString("justifyContent", "flex-start")
@@ -895,9 +945,12 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
895
945
  crossFree / lines.size.toFloat()
896
946
  else 0f
897
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()
898
950
  for ((lineIndex, line) in lines.withIndex()) {
899
951
  val lineCross = line.cross.toFloat() + stretchExtra
900
- val lineOffset = sharedCross?.first?.get(lineIndex)?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat() + it } ?: crossOffset
952
+ val lineOffset = sharedCross?.first?.get(lineIndex)?.let { crossOrigin + it } ?: crossOffset
953
+ val physicalLineOffset = if (wrapReverse && wrap) crossOrigin + crossExtent - (lineOffset - crossOrigin) - lineCross else lineOffset
901
954
  val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
902
955
  val sharedPrimary = CraftNative.flexRow(
903
956
  availableMain.toFloat(),
@@ -940,6 +993,12 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
940
993
  val free = max(0f, availableMain.toFloat() - distributedMain.toFloat())
941
994
  val (leading, between) = distribution(free, line.entries.size)
942
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
943
1002
  for ((index, entry) in line.entries.withIndex()) {
944
1003
  val lp = entry.view.layoutParams as? MarginLayoutParams
945
1004
  val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
@@ -950,18 +1009,26 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
950
1009
  val align = entry.style.alignSelf ?: alignItems
951
1010
  val crossLeading = (if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0).toFloat()
952
1011
  val crossTrailing = (if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0).toFloat()
953
- val alignment = when (align) {
954
- "center" -> 1
955
- "flex-end" -> 2
956
- else -> 0
957
- }
958
- val crossPosition = CraftNative.crossAxisPosition(
959
- lineCross, crossSize.toFloat(), crossLeading, crossTrailing, alignment
960
- ) ?: when (alignment) {
961
- 1 -> crossLeading + (lineCross - crossLeading - crossTrailing - crossSize) / 2f
962
- 2 -> lineCross - crossTrailing - crossSize
963
- else -> crossLeading
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
+ }
964
1030
  }
1031
+ val physicalCrossPosition = if (wrapReverse && wrap) lineCross - crossPosition - crossSize else crossPosition
965
1032
  // Shared positions cover every non-reverse justification; the
966
1033
  // local main-axis rule remains the fallback for old runtimes
967
1034
  // that return widths only.
@@ -970,8 +1037,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
970
1037
  val sharedPosition = if (!reverse) sharedPositions?.get(line.entries.size + index) else null
971
1038
  val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + it }
972
1039
  ?: mainOffset + marginStart
973
- val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else (lineOffset + crossPosition).roundToInt()
974
- val y = if (orientation == HORIZONTAL) (lineOffset + crossPosition).roundToInt() 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()
975
1042
  val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
976
1043
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
977
1044
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
@@ -1002,6 +1069,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1002
1069
  else -> 0f to 0f
1003
1070
  }
1004
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
1005
1075
  }
1006
1076
 
1007
1077
  /** An image-backed flex container with ordinary native children over the image. */
@@ -1024,6 +1094,12 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
1024
1094
  private var behavior = "padding"
1025
1095
  private var keyboardVerticalOffset = 0
1026
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
+ }
1027
1103
  private var basePadding = intArrayOf(0, 0, 0, 0)
1028
1104
 
1029
1105
  init {
@@ -1432,6 +1508,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1432
1508
  private var datePicker: DatePicker? = null
1433
1509
  private var timePicker: TimePicker? = null
1434
1510
  private var minuteInterval = 1
1511
+ private var countdownSecond = 0
1435
1512
  private var suppressChanges = true
1436
1513
  var onValueChanged: (() -> Unit)? = null
1437
1514
 
@@ -1483,6 +1560,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1483
1560
  pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1484
1561
  pickerDate(props.opt("maximumDate"))?.let { datePicker?.maxDate = it.timeInMillis }
1485
1562
  if (requestedMode == PickerMode.COUNTDOWN) {
1563
+ countdownSecond = 0
1486
1564
  countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
1487
1565
  } else {
1488
1566
  pickerDate(props.opt("value"))?.let { applyDate(it) }
@@ -1533,11 +1611,12 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1533
1611
  }
1534
1612
 
1535
1613
  private fun applyCountdown(seconds: Int) {
1614
+ countdownSecond = seconds % 60
1536
1615
  timePicker?.hour = (seconds / 3600).coerceIn(0, 23)
1537
1616
  timePicker?.minute = (seconds % 3600 / 60).coerceIn(0, 59)
1538
1617
  }
1539
1618
 
1540
- 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
1541
1620
 
1542
1621
  private fun formatCountdown(seconds: Int): String = "%02d:%02d:%02d".format(
1543
1622
  Locale.US,
@@ -1583,11 +1662,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1583
1662
  val text = value?.toString()?.trim().orEmpty()
1584
1663
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1585
1664
  text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
1586
- val parts = text.split(':').mapNotNull { it.toDoubleOrNull() }
1665
+ val parts = text.split(':')
1587
1666
  if (parts.size != 2 && parts.size != 3) return null
1588
- val hours = parts[0]
1589
- val minutes = parts[1]
1590
- 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
1591
1670
  if (hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
1592
1671
  return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1593
1672
  }
@@ -1803,6 +1882,101 @@ private class CraftNativeAudio {
1803
1882
  }
1804
1883
  }
1805
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
+
1806
1980
  /** WebView-free host for a route bundle compiled by `stx native compile`. */
1807
1981
  class MainActivity : AppCompatActivity() {
1808
1982
  private class Screen(
@@ -2124,6 +2298,7 @@ class MainActivity : AppCompatActivity() {
2124
2298
  capabilities: ${capabilities.names()},
2125
2299
  initialAppState: ${JSONObject.quote(nativeAppState)},
2126
2300
  colorScheme: ${JSONObject.quote(colorScheme())},
2301
+ appearance: ${appearance()},
2127
2302
  postMessage: function(value) { port.postMessage(value); },
2128
2303
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
2129
2304
  };
@@ -2139,6 +2314,7 @@ class MainActivity : AppCompatActivity() {
2139
2314
  capabilities: ${capabilities.names()},
2140
2315
  initialAppState: ${JSONObject.quote(nativeAppState)},
2141
2316
  colorScheme: ${JSONObject.quote(colorScheme())},
2317
+ appearance: ${appearance()},
2142
2318
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
2143
2319
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
2144
2320
  };
@@ -2190,15 +2366,17 @@ class MainActivity : AppCompatActivity() {
2190
2366
 
2191
2367
  override fun onConfigurationChanged(newConfig: Configuration) {
2192
2368
  super.onConfigurationChanged(newConfig)
2369
+ publishAppearance()
2370
+ }
2371
+
2372
+ private fun publishAppearance() {
2193
2373
  if (!::root.isInitialized) return
2194
2374
  refreshThemeDefaults()
2195
2375
  ViewCompat.requestApplyInsets(root)
2196
2376
  refreshInputDefaultsForTraitChange()
2197
2377
  screens.lastOrNull()?.let { screen ->
2198
2378
  refreshScreenTheme(screen)
2199
- if (screen.active) {
2200
- send(screen, "APPEARANCE", JSONObject().put("colorScheme", colorScheme()))
2201
- }
2379
+ if (screen.active) send(screen, "APPEARANCE", appearance())
2202
2380
  }
2203
2381
  }
2204
2382
 
@@ -2223,6 +2401,25 @@ class MainActivity : AppCompatActivity() {
2223
2401
  resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
2224
2402
  ) "dark" else "light"
2225
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
+
2226
2423
  private fun refreshInputDefaultsForTraitChange() {
2227
2424
  if (inputHintDefaults.isEmpty() && inputHighlightDefaults.isEmpty()) return
2228
2425
  val probe = EditText(this)
@@ -2540,7 +2737,12 @@ class MainActivity : AppCompatActivity() {
2540
2737
  (style.optDouble("paddingBottom", vertical) * density).toInt(),
2541
2738
  )
2542
2739
  }
2543
- 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)
2544
2746
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
2545
2747
  if (controlledValue != null && text.toString() != controlledValue) {
2546
2748
  screen.suppressedTextChanges.add(this)
@@ -2548,6 +2750,7 @@ class MainActivity : AppCompatActivity() {
2548
2750
  }
2549
2751
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
2550
2752
  else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
2753
+ applyTextDecoration(this, style)
2551
2754
  screen.inputIdentities[this] = identity
2552
2755
  val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
2553
2756
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -2741,7 +2944,12 @@ class MainActivity : AppCompatActivity() {
2741
2944
  // ranges unable to represent most valid values. Continuous
2742
2945
  // sliders retain the bounded scale to avoid an enormous max.
2743
2946
  val progressMax = step?.let {
2744
- ceil((maximum - minimum) / it).toInt().coerceIn(1, 1_000_000)
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()
2745
2953
  } ?: 1000
2746
2954
  max = progressMax
2747
2955
  val requested = props.optDouble("value", minimum)
@@ -3270,27 +3478,79 @@ class MainActivity : AppCompatActivity() {
3270
3478
 
3271
3479
  private fun applyViewStyle(view: View, style: JSONObject) {
3272
3480
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
3481
+ applyTransform(view, style.optJSONArray("transform"))
3273
3482
  if (view !is CraftNativeModalView) {
3274
3483
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
3275
3484
  }
3276
3485
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
3486
+ view.z = style.optDouble("zIndex", 0.0).toFloat()
3277
3487
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
3278
3488
  val border = style.optDouble("borderWidth", 0.0)
3279
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()
3280
3502
  val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
3281
3503
  defaultBackgrounds[view]
3282
3504
  } else {
3283
3505
  view.background.also { defaultBackgrounds[view] = it }
3284
3506
  }
3285
- if (background || border > 0 || radius > 0) {
3286
- view.background = GradientDrawable().apply {
3287
- setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
3288
- cornerRadius = dp(radius).toFloat()
3289
- 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
+ }
3290
3528
  }
3291
3529
  } else view.background = defaultBackground
3292
3530
  view.clipToOutline = style.optString("overflow") == "hidden"
3293
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
+ }
3294
3554
  if (view is ImageView) {
3295
3555
  val tint = style.opt("tintColor") as? String
3296
3556
  if (tint.isNullOrBlank() || tint == "null" || tint == "undefined") {
@@ -3301,6 +3561,49 @@ class MainActivity : AppCompatActivity() {
3301
3561
  }
3302
3562
  }
3303
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
+
3304
3607
  private fun ensureLayoutListener(screen: Screen, view: View) {
3305
3608
  if (screen.layoutListeners.containsKey(view)) return
3306
3609
  val listener = View.OnLayoutChangeListener { changed, left, top, right, bottom, _, _, _, _ ->
@@ -3409,13 +3712,59 @@ class MainActivity : AppCompatActivity() {
3409
3712
  style.optDouble("lineHeight", Double.NaN).takeUnless { it.isNaN() }?.let { dp(it) - view.textSize } ?: 0f,
3410
3713
  1f
3411
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
+ }
3412
3727
  view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
3413
- when (style.optString("textDecorationLine")) {
3414
- "underline" -> view.paintFlags = view.paintFlags or Paint.UNDERLINE_TEXT_FLAG
3415
- "line-through" -> view.paintFlags = view.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
3416
- "underline line-through" -> view.paintFlags = view.paintFlags or
3417
- 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
3418
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")
3419
3768
  }
3420
3769
 
3421
3770
  private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
@@ -3459,7 +3808,7 @@ class MainActivity : AppCompatActivity() {
3459
3808
  } else imageFailure(screen, view, uri, "Bundled image was not found")
3460
3809
  return
3461
3810
  }
3462
- if (!uri.startsWith("https://")) {
3811
+ if (!loadsImage(Uri.parse(uri))) {
3463
3812
  imageFailure(screen, view, uri, "Unsupported image source")
3464
3813
  return
3465
3814
  }
@@ -3599,14 +3948,30 @@ class MainActivity : AppCompatActivity() {
3599
3948
  val raw = props.optString("textContentType").ifBlank { props.optString("autoComplete") }
3600
3949
  return when (raw.replace("-", "").replace("_", "").lowercase()) {
3601
3950
  "username" -> View.AUTOFILL_HINT_USERNAME
3951
+ "nickname" -> "personNickname"
3602
3952
  "password", "currentpassword" -> View.AUTOFILL_HINT_PASSWORD
3603
3953
  "newpassword" -> "newPassword"
3604
3954
  "onetimecode" -> "smsOTPCode"
3605
3955
  "emailaddress", "email" -> View.AUTOFILL_HINT_EMAIL_ADDRESS
3606
3956
  "telephonenumber", "tel", "phone" -> View.AUTOFILL_HINT_PHONE
3607
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"
3608
3966
  "postalcode" -> View.AUTOFILL_HINT_POSTAL_CODE
3609
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"
3610
3975
  else -> null
3611
3976
  }
3612
3977
  }
@@ -3617,6 +3982,11 @@ class MainActivity : AppCompatActivity() {
3617
3982
  "next" -> EditorInfo.IME_ACTION_NEXT
3618
3983
  "search" -> EditorInfo.IME_ACTION_SEARCH
3619
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
3620
3990
  else -> EditorInfo.IME_ACTION_NONE
3621
3991
  }
3622
3992
 
@@ -3710,6 +4080,11 @@ class MainActivity : AppCompatActivity() {
3710
4080
 
3711
4081
  override fun onStart() {
3712
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
+ }
3713
4088
  nativeAppState = "active"
3714
4089
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
3715
4090
  }
@@ -3721,6 +4096,10 @@ class MainActivity : AppCompatActivity() {
3721
4096
  }
3722
4097
 
3723
4098
  override fun onStop() {
4099
+ if (animationScaleObserverRegistered) {
4100
+ contentResolver.unregisterContentObserver(animationScaleObserver)
4101
+ animationScaleObserverRegistered = false
4102
+ }
3724
4103
  nativeAppState = "background"
3725
4104
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
3726
4105
  super.onStop()
@@ -3964,6 +4343,7 @@ class MainActivity : AppCompatActivity() {
3964
4343
  }
3965
4344
 
3966
4345
  override fun onDestroy() {
4346
+ if (animationScaleObserverRegistered) contentResolver.unregisterContentObserver(animationScaleObserver)
3967
4347
  screens.forEach { closeScreen(it) }
3968
4348
  screens.clear()
3969
4349
  imageJobs.values.forEach { it.cancel(true) }