craft-native 0.0.246 → 0.0.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +11 -0
- package/dist/android/src/index.js +33 -21
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +136 -1
- package/dist/android/templates/CraftHeartRate.kt.template +938 -0
- package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
- package/dist/android/templates/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
- package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
- package/dist/android/templates/CraftNativeServices.kt.template +59 -1
- package/dist/android/templates/CraftNativeShell.kt.template +54 -14
- package/dist/android/templates/MainActivityNative.kt.template +369 -69
- package/dist/android/templates/build.gradle.kts.project.template +1 -1
- package/dist/api/mobile.d.ts +39 -3
- package/dist/browser.js +9 -9
- package/dist/cli.js +90 -673
- package/dist/components/index.d.ts +50 -9
- package/dist/index.cjs +394 -438
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +446 -493
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +54 -53
- package/dist/ios/templates/CraftApp.swift +37 -2
- package/dist/ios/templates/CraftHealth.swift +217 -0
- package/dist/ios/templates/CraftHeartRate.swift +6 -3
- package/dist/ios/templates/CraftHybrid.swift +18 -19
- package/dist/ios/templates/CraftLiveActivities.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +87 -8
- package/dist/ios/templates/CraftNativeFiles.swift +15 -3
- package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
- package/dist/ios/templates/CraftNativeMutation.swift +36 -0
- package/dist/ios/templates/CraftNativeScreen.swift +813 -65
- package/dist/ios/templates/CraftNativeServices.swift +4 -2
- package/dist/ios/templates/CraftNativeUI.swift +10 -2
- package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
- package/dist/ios/templates/testing/HostApp.swift +9 -2
- package/dist/macos/templates/CraftNativeMutation.swift +36 -0
- package/dist/macos/templates/native_layout.zig +226 -2
- package/dist/mobile-testing.js +21 -3
- package/dist/mobile.js +101 -63
- package/dist/testing/media-cache/media-cache.kt +4 -2
- package/package.json +1 -1
|
@@ -113,6 +113,7 @@ import org.json.JSONArray
|
|
|
113
113
|
import org.json.JSONObject
|
|
114
114
|
import java.io.File
|
|
115
115
|
import java.net.HttpURLConnection
|
|
116
|
+
import java.net.URI
|
|
116
117
|
import java.net.URL
|
|
117
118
|
import java.text.SimpleDateFormat
|
|
118
119
|
import java.util.Calendar
|
|
@@ -166,7 +167,7 @@ private fun craftNativeFontWeight(value: Any?): String? = when (value) {
|
|
|
166
167
|
internal fun craftNativePadding(style: JSONObject?, name: String, fallback: Int, density: Float): Int {
|
|
167
168
|
val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
|
|
168
169
|
val pixels = value * density
|
|
169
|
-
return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.
|
|
170
|
+
return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.roundToInt() else fallback
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback: Float): Float {
|
|
@@ -271,6 +272,18 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
|
|
|
271
272
|
return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
|
|
272
273
|
}
|
|
273
274
|
|
|
275
|
+
private fun craftNativeGridRowCount(value: Any?): Int? {
|
|
276
|
+
if (value is Number) return max(1, value.toInt())
|
|
277
|
+
val text = value as? String ?: return null
|
|
278
|
+
val trimmed = text.trim()
|
|
279
|
+
if (trimmed.isEmpty()) return null
|
|
280
|
+
if (trimmed.startsWith("repeat(")) {
|
|
281
|
+
val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
|
|
282
|
+
if (count != null) return max(1, count)
|
|
283
|
+
}
|
|
284
|
+
return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
|
|
285
|
+
}
|
|
286
|
+
|
|
274
287
|
/**
|
|
275
288
|
* A CSS colour as Android's ARGB. STX writes `#RRGGBBAA` (and `#RGBA`) for a
|
|
276
289
|
* colour with alpha, where Android's parser reads `#AARRGGBB`: a translucent
|
|
@@ -709,7 +722,7 @@ internal class CraftNativeCelebration(private val view: View) {
|
|
|
709
722
|
}
|
|
710
723
|
|
|
711
724
|
/** A deterministic flex-shaped container shared by normal views and scroll content. */
|
|
712
|
-
|
|
725
|
+
internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
713
726
|
private var celebration: CraftNativeCelebration? = null
|
|
714
727
|
fun configureCelebration(raw: JSONObject?, radius: Float) {
|
|
715
728
|
if (raw != null && celebration == null) celebration = CraftNativeCelebration(this)
|
|
@@ -737,6 +750,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
737
750
|
private var columnGap = 0f
|
|
738
751
|
private var grid = false
|
|
739
752
|
private var gridColumns = 1
|
|
753
|
+
private var gridRows: Int? = null
|
|
740
754
|
private var gridAutoRows: CraftNativeLength? = null
|
|
741
755
|
private var reverse = false
|
|
742
756
|
private var wrapReverse = false
|
|
@@ -873,6 +887,37 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
873
887
|
child.layout(left, top, right, bottom)
|
|
874
888
|
}
|
|
875
889
|
|
|
890
|
+
/**
|
|
891
|
+
* Resolve an absolute child against the parent's content box before it is
|
|
892
|
+
* placed. In particular, left+right anchors define the child's width; an
|
|
893
|
+
* aspect-ratio child must be measured again at that width or Android can
|
|
894
|
+
* retain the parent's intrinsic height from its first, wider probe.
|
|
895
|
+
*/
|
|
896
|
+
private fun absoluteSize(entry: Entry, contentWidth: Int, contentHeight: Int): Pair<Int, Int> {
|
|
897
|
+
val style = entry.style
|
|
898
|
+
val anchoredWidth = style.left != null && style.right != null
|
|
899
|
+
val anchoredHeight = style.top != null && style.bottom != null
|
|
900
|
+
var width = when {
|
|
901
|
+
anchoredWidth -> contentWidth - style.left!! - style.right!!
|
|
902
|
+
style.width != null -> style.width.resolve(contentWidth) ?: entry.width
|
|
903
|
+
style.aspectRatio != null && style.height != null ->
|
|
904
|
+
((style.height.resolve(contentHeight) ?: entry.height) * style.aspectRatio).roundToInt()
|
|
905
|
+
else -> entry.width
|
|
906
|
+
}
|
|
907
|
+
var height = if (anchoredHeight) contentHeight - style.top!! - style.bottom!! else entry.height
|
|
908
|
+
width = clamp(width, style.minWidth?.resolve(contentWidth), style.maxWidth?.resolve(contentWidth))
|
|
909
|
+
if (!anchoredHeight) {
|
|
910
|
+
height = when {
|
|
911
|
+
style.height != null -> measureChildHeight(entry.view, style, contentHeight, 0, width)
|
|
912
|
+
anchoredWidth && style.aspectRatio != null && style.aspectRatio > 0 ->
|
|
913
|
+
clamp((width / style.aspectRatio).roundToInt(), style.minHeight?.resolve(contentHeight), style.maxHeight?.resolve(contentHeight))
|
|
914
|
+
else -> measureChildHeight(entry.view, style, contentHeight, 0, width)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
height = clamp(height, style.minHeight?.resolve(contentHeight), style.maxHeight?.resolve(contentHeight))
|
|
918
|
+
return max(0, width) to max(0, height)
|
|
919
|
+
}
|
|
920
|
+
|
|
876
921
|
override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
|
|
877
922
|
val skew = CraftNativeSkewTransforms.matrix(child) ?: return super.drawChild(canvas, child, drawingTime)
|
|
878
923
|
val save = canvas.save()
|
|
@@ -905,6 +950,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
905
950
|
gridColumns = craftNativeGridColumnCount(
|
|
906
951
|
if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
|
|
907
952
|
)
|
|
953
|
+
gridRows = craftNativeGridRowCount(style.opt("gridTemplateRows"))
|
|
908
954
|
gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
|
|
909
955
|
val all = craftNativePadding(style, "padding", 0, density)
|
|
910
956
|
val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
|
|
@@ -1081,12 +1127,20 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1081
1127
|
rows.last() += entry
|
|
1082
1128
|
}
|
|
1083
1129
|
}
|
|
1084
|
-
val
|
|
1130
|
+
val naturalRowHeights = rows.map { row ->
|
|
1085
1131
|
max((gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0).toFloat(), row.maxOfOrNull { entry ->
|
|
1086
1132
|
val lp = entry.view.layoutParams as? MarginLayoutParams
|
|
1087
1133
|
(entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)).toFloat()
|
|
1088
1134
|
} ?: 0f)
|
|
1089
1135
|
}
|
|
1136
|
+
val rowHeights = if (gridRows != null) {
|
|
1137
|
+
val count = max(gridRows ?: 0, naturalRowHeights.size)
|
|
1138
|
+
while (rows.size < count) rows.add(mutableListOf())
|
|
1139
|
+
val available = max(0f, height - paddingTop - paddingBottom - max(0, count - 1) * rowGap)
|
|
1140
|
+
val equal = available / max(1, count).toFloat()
|
|
1141
|
+
(naturalRowHeights + List(count - naturalRowHeights.size) { equal })
|
|
1142
|
+
.map { max(it, equal) }
|
|
1143
|
+
} else naturalRowHeights
|
|
1090
1144
|
return GridLayout(rows, absolute, cellWidth, rowHeights)
|
|
1091
1145
|
}
|
|
1092
1146
|
|
|
@@ -1236,6 +1290,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1236
1290
|
val grid: Boolean,
|
|
1237
1291
|
val gridColumns: Int,
|
|
1238
1292
|
val gridAutoRows: CraftNativeLength?,
|
|
1293
|
+
val gridRows: Int?,
|
|
1239
1294
|
val reverse: Boolean,
|
|
1240
1295
|
val wrapReverse: Boolean,
|
|
1241
1296
|
val children: List<SharedTreeNode>,
|
|
@@ -1287,6 +1342,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1287
1342
|
grid = nested?.grid ?: false,
|
|
1288
1343
|
gridColumns = nested?.gridColumns ?: 1,
|
|
1289
1344
|
gridAutoRows = nested?.gridAutoRows,
|
|
1345
|
+
gridRows = nested?.gridRows,
|
|
1290
1346
|
reverse = nested?.reverse ?: false,
|
|
1291
1347
|
wrapReverse = nested?.wrapReverse ?: false,
|
|
1292
1348
|
children = children.map { child -> sharedTreeNode(child) ?: return null },
|
|
@@ -1315,6 +1371,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1315
1371
|
grid = grid,
|
|
1316
1372
|
gridColumns = gridColumns,
|
|
1317
1373
|
gridAutoRows = gridAutoRows,
|
|
1374
|
+
gridRows = gridRows,
|
|
1318
1375
|
reverse = reverse,
|
|
1319
1376
|
wrapReverse = wrapReverse,
|
|
1320
1377
|
children = children,
|
|
@@ -1331,7 +1388,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1331
1388
|
val reverseAware = nodes.any { it.reverse || it.wrapReverse }
|
|
1332
1389
|
val wrapReverseAware = nodes.any { it.wrapReverse }
|
|
1333
1390
|
val autoRowsAware = nodes.any { it.gridAutoRows != null }
|
|
1391
|
+
val explicitRowsAware = nodes.any { it.gridRows != null }
|
|
1334
1392
|
val fields = when {
|
|
1393
|
+
explicitRowsAware -> 35
|
|
1335
1394
|
autoRowsAware -> 34
|
|
1336
1395
|
reverseAware -> 33
|
|
1337
1396
|
else -> 32
|
|
@@ -1384,13 +1443,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1384
1443
|
put(base, 31, node.columnGap)
|
|
1385
1444
|
if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
|
|
1386
1445
|
if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
|
|
1446
|
+
if (explicitRowsAware) put(base, 34, (node.gridRows ?: 0).toFloat())
|
|
1387
1447
|
val firstChild = cursor
|
|
1388
1448
|
node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
|
|
1389
1449
|
treeMeta[index * 2] = firstChild.toFloat()
|
|
1390
1450
|
treeMeta[index * 2 + 1] = (cursor - firstChild).toFloat()
|
|
1391
1451
|
}
|
|
1392
1452
|
write(root, width, height, true, root.direction)
|
|
1393
|
-
val frames = (if (
|
|
1453
|
+
val frames = (if (explicitRowsAware) {
|
|
1454
|
+
CraftNative.layoutTreeV5(width.toFloat(), height.toFloat(), nodeData, treeMeta)
|
|
1455
|
+
} else if (autoRowsAware) {
|
|
1394
1456
|
CraftNative.layoutTreeV4(width.toFloat(), height.toFloat(), nodeData, treeMeta)
|
|
1395
1457
|
} else if (wrapReverseAware) {
|
|
1396
1458
|
CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)
|
|
@@ -1638,7 +1700,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1638
1700
|
val flow = layout.rows.flatten()
|
|
1639
1701
|
// Older runtimes return null for the optional symbol; the local
|
|
1640
1702
|
// layout below remains the compatibility path in that case.
|
|
1641
|
-
val sharedFrames = CraftNative.grid(
|
|
1703
|
+
val sharedFrames = if (gridRows == null) CraftNative.grid(
|
|
1642
1704
|
contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
|
|
1643
1705
|
flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
|
|
1644
1706
|
flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
@@ -1657,7 +1719,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1657
1719
|
}.toFloatArray(),
|
|
1658
1720
|
gridAutoRows?.resolve(contentHeight)?.toFloat(),
|
|
1659
1721
|
alignContentCode(),
|
|
1660
|
-
)
|
|
1722
|
+
) else null
|
|
1661
1723
|
if (sharedFrames != null) {
|
|
1662
1724
|
for ((index, entry) in flow.withIndex()) {
|
|
1663
1725
|
val offset = index * 4
|
|
@@ -1720,11 +1782,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1720
1782
|
}
|
|
1721
1783
|
for (entry in layout.absolute) {
|
|
1722
1784
|
if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
|
|
1723
|
-
val childWidth =
|
|
1724
|
-
val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
|
|
1785
|
+
val (childWidth, childHeight) = absoluteSize(entry, contentWidth, contentHeight)
|
|
1725
1786
|
val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
|
|
1726
1787
|
val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - childHeight } ?: 0))
|
|
1727
|
-
place(entry.view, x, y, x +
|
|
1788
|
+
place(entry.view, x, y, x + childWidth, y + childHeight)
|
|
1728
1789
|
}
|
|
1729
1790
|
onLayoutChanged?.invoke()
|
|
1730
1791
|
return
|
|
@@ -1906,11 +1967,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
1906
1967
|
}
|
|
1907
1968
|
for (entry in absolute) {
|
|
1908
1969
|
if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
|
|
1909
|
-
val widthValue =
|
|
1910
|
-
val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
|
|
1970
|
+
val (widthValue, heightValue) = absoluteSize(entry, contentWidth, contentHeight)
|
|
1911
1971
|
val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
|
|
1912
1972
|
val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
|
|
1913
|
-
place(entry.view, x, y, x +
|
|
1973
|
+
place(entry.view, x, y, x + widthValue, y + heightValue)
|
|
1914
1974
|
}
|
|
1915
1975
|
onLayoutChanged?.invoke()
|
|
1916
1976
|
}
|
|
@@ -2015,6 +2075,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2015
2075
|
private var stickyIndices = emptyList<Int>()
|
|
2016
2076
|
private var scrollTargetKey: String? = null
|
|
2017
2077
|
private var pendingScrollTarget: Pair<String, Boolean>? = null
|
|
2078
|
+
private var scrollListenerGeneration = 0
|
|
2018
2079
|
|
|
2019
2080
|
init {
|
|
2020
2081
|
vertical.isNestedScrollingEnabled = true
|
|
@@ -2160,6 +2221,21 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2160
2221
|
horizontal.isNestedScrollingEnabled = value
|
|
2161
2222
|
}
|
|
2162
2223
|
|
|
2224
|
+
fun scrollTo(x: Int?, y: Int?, animated: Boolean): Pair<Int, Int> {
|
|
2225
|
+
val scroller: View = if (horizontalMode == true) horizontal else vertical
|
|
2226
|
+
val maxX = max(0, content.width - horizontal.width)
|
|
2227
|
+
val maxY = max(0, content.height - vertical.height)
|
|
2228
|
+
val targetX = (x ?: scroller.scrollX).coerceIn(0, maxX)
|
|
2229
|
+
val targetY = (y ?: scroller.scrollY).coerceIn(0, maxY)
|
|
2230
|
+
if (animated) {
|
|
2231
|
+
if (scroller is HorizontalScrollView) scroller.smoothScrollTo(targetX, targetY)
|
|
2232
|
+
else (scroller as ScrollView).smoothScrollTo(targetX, targetY)
|
|
2233
|
+
} else {
|
|
2234
|
+
scroller.scrollTo(targetX, targetY)
|
|
2235
|
+
}
|
|
2236
|
+
return targetX to targetY
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2163
2239
|
fun setScrollEnabled(value: Boolean) {
|
|
2164
2240
|
vertical.isEnabled = value
|
|
2165
2241
|
horizontal.isEnabled = value
|
|
@@ -2286,7 +2362,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2286
2362
|
return
|
|
2287
2363
|
}
|
|
2288
2364
|
val scrollY = vertical.scrollY
|
|
2289
|
-
|
|
2365
|
+
// Keep sticky content below the ScrollView's authored top inset,
|
|
2366
|
+
// matching the native content viewport instead of pinning it to the
|
|
2367
|
+
// raw window edge.
|
|
2368
|
+
var pinnedBottom = content.paddingTop
|
|
2290
2369
|
stickyIndices.forEachIndexed { position, index ->
|
|
2291
2370
|
val header = content.getChildAt(index)
|
|
2292
2371
|
if (header == null || header.visibility != View.VISIBLE || header.height <= 0) return@forEachIndexed
|
|
@@ -2362,6 +2441,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2362
2441
|
keyboardDismissMode: String,
|
|
2363
2442
|
dismissKeyboard: (() -> Unit)?,
|
|
2364
2443
|
) {
|
|
2444
|
+
val generation = ++scrollListenerGeneration
|
|
2365
2445
|
fun attach(scroller: View) {
|
|
2366
2446
|
var dismissed = false
|
|
2367
2447
|
var moved = false
|
|
@@ -2370,7 +2450,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2370
2450
|
var lastY = 0
|
|
2371
2451
|
|
|
2372
2452
|
fun pollMomentum() {
|
|
2373
|
-
if (!momentumActive) return
|
|
2453
|
+
if (generation != scrollListenerGeneration || !momentumActive) return
|
|
2374
2454
|
val x = scroller.scrollX
|
|
2375
2455
|
val y = scroller.scrollY
|
|
2376
2456
|
if (x == lastX && y == lastY) {
|
|
@@ -2426,6 +2506,14 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
|
|
|
2426
2506
|
attach(vertical)
|
|
2427
2507
|
attach(horizontal)
|
|
2428
2508
|
}
|
|
2509
|
+
|
|
2510
|
+
fun clearScrollListeners() {
|
|
2511
|
+
scrollListenerGeneration += 1
|
|
2512
|
+
vertical.setOnScrollChangeListener(null)
|
|
2513
|
+
vertical.setOnTouchListener(null)
|
|
2514
|
+
horizontal.setOnScrollChangeListener(null)
|
|
2515
|
+
horizontal.setOnTouchListener(null)
|
|
2516
|
+
}
|
|
2429
2517
|
}
|
|
2430
2518
|
|
|
2431
2519
|
private class CraftNativeFlexSpacer(context: Context) : View(context)
|
|
@@ -2471,7 +2559,8 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2471
2559
|
private var renderedVisible = false
|
|
2472
2560
|
private var dismissSuppressedByRequest = false
|
|
2473
2561
|
private var animationGeneration = 0
|
|
2474
|
-
var
|
|
2562
|
+
var focusToRestore: View? = null
|
|
2563
|
+
var onRequestClose: ((String) -> Unit)? = null
|
|
2475
2564
|
|
|
2476
2565
|
var transparent: Boolean = false
|
|
2477
2566
|
set(value) {
|
|
@@ -2483,7 +2572,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2483
2572
|
isClickable = true
|
|
2484
2573
|
blocker.isClickable = true
|
|
2485
2574
|
blocker.setOnTouchListener { _, event ->
|
|
2486
|
-
if (event.action == MotionEvent.ACTION_UP) requestClose()
|
|
2575
|
+
if (event.action == MotionEvent.ACTION_UP) requestClose(reason = "backdrop")
|
|
2487
2576
|
true
|
|
2488
2577
|
}
|
|
2489
2578
|
blocker.setBackgroundColor(Color.argb(82, 0, 0, 0))
|
|
@@ -2491,12 +2580,12 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2491
2580
|
addView(content, LayoutParams(-1, -1))
|
|
2492
2581
|
}
|
|
2493
2582
|
|
|
2494
|
-
fun requestClose() {
|
|
2583
|
+
fun requestClose(reason: String = "backdrop") {
|
|
2495
2584
|
val handler = onRequestClose ?: return
|
|
2496
2585
|
dismissSuppressedByRequest = true
|
|
2497
2586
|
isClickable = false
|
|
2498
2587
|
blocker.isEnabled = false
|
|
2499
|
-
handler()
|
|
2588
|
+
handler(reason)
|
|
2500
2589
|
}
|
|
2501
2590
|
|
|
2502
2591
|
fun consumeDismissSuppression(): Boolean {
|
|
@@ -2693,6 +2782,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
|
|
|
2693
2782
|
return JSONObject().put("value", value).put("iso", iso).put("timestamp", current.timeInMillis)
|
|
2694
2783
|
}
|
|
2695
2784
|
|
|
2785
|
+
fun resetForTeardown() {
|
|
2786
|
+
suppressChanges = true
|
|
2787
|
+
onValueChanged = null
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2696
2790
|
private fun changed() {
|
|
2697
2791
|
if (suppressChanges) return
|
|
2698
2792
|
// A date-only picker has no time half to snap, but its change is
|
|
@@ -2983,6 +3077,12 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
|
|
|
2983
3077
|
fun stop() {
|
|
2984
3078
|
// Released with its screen: nothing more is told to it.
|
|
2985
3079
|
emit = null
|
|
3080
|
+
// VideoView otherwise keeps these listener objects until its media
|
|
3081
|
+
// player is replaced. Clear them before stopping so an asynchronous
|
|
3082
|
+
// prepare/completion/error callback cannot outlive the native node.
|
|
3083
|
+
setOnPreparedListener(null)
|
|
3084
|
+
setOnCompletionListener(null)
|
|
3085
|
+
setOnErrorListener(null)
|
|
2986
3086
|
progressHandler.removeCallbacks(progressTick)
|
|
2987
3087
|
stopPlayback()
|
|
2988
3088
|
setPlaying(false)
|
|
@@ -2995,6 +3095,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
|
|
|
2995
3095
|
pictureInPicture = false
|
|
2996
3096
|
onPictureInPictureChanged?.invoke()
|
|
2997
3097
|
}
|
|
3098
|
+
onPictureInPictureChanged = null
|
|
3099
|
+
handlers = JSONObject()
|
|
2998
3100
|
}
|
|
2999
3101
|
|
|
3000
3102
|
private fun applyScalingMode() {
|
|
@@ -3525,6 +3627,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3525
3627
|
private class Screen(
|
|
3526
3628
|
val route: String?,
|
|
3527
3629
|
val params: JSONObject,
|
|
3630
|
+
val props: JSONObject,
|
|
3528
3631
|
val view: CraftNativeFlexLayout,
|
|
3529
3632
|
val isolate: JavaScriptIsolate,
|
|
3530
3633
|
val port: MessagePort?,
|
|
@@ -3549,6 +3652,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3549
3652
|
val drafts = mutableMapOf<String, String>()
|
|
3550
3653
|
val inputIdentities = mutableMapOf<EditText, String>()
|
|
3551
3654
|
val suppressedTextChanges = mutableSetOf<EditText>()
|
|
3655
|
+
val inputWatchers = mutableMapOf<EditText, TextWatcher>()
|
|
3552
3656
|
/**
|
|
3553
3657
|
* The texts a field sent the screen that its `value` has not come
|
|
3554
3658
|
* back as yet, oldest first. Typing faster than the round trip makes
|
|
@@ -3562,6 +3666,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3562
3666
|
// the original tree path without manufacturing a detached view.
|
|
3563
3667
|
val nodeViews = mutableMapOf<String, View>()
|
|
3564
3668
|
val nodePaths = mutableMapOf<String, String>()
|
|
3669
|
+
val protocolIds = mutableMapOf<View, String>()
|
|
3565
3670
|
val handlers = mutableMapOf<View, String>()
|
|
3566
3671
|
val linkTargets = mutableMapOf<View, String>()
|
|
3567
3672
|
val pressInHandlers = mutableMapOf<View, String>()
|
|
@@ -3709,8 +3814,6 @@ class MainActivity : AppCompatActivity() {
|
|
|
3709
3814
|
private val imageJobs = mutableMapOf<ImageView, Future<*>>()
|
|
3710
3815
|
private val imageErrors = mutableMapOf<ImageView, String>()
|
|
3711
3816
|
private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
|
|
3712
|
-
/** Fields that already carry their text watcher. */
|
|
3713
|
-
private val watchedInputs: MutableSet<EditText> = java.util.Collections.newSetFromMap(WeakHashMap())
|
|
3714
3817
|
private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
|
|
3715
3818
|
private val inputPaddingDefaults = mutableMapOf<EditText, IntArray>()
|
|
3716
3819
|
private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
|
|
@@ -4060,7 +4163,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4060
4163
|
* page on the app's site in the in-app browser. Another site's address
|
|
4061
4164
|
* opens in the browser too.
|
|
4062
4165
|
*/
|
|
4063
|
-
private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
|
|
4166
|
+
private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false, props: JSONObject = JSONObject()) {
|
|
4064
4167
|
val path = routes.appPath(target, urlSchemes)
|
|
4065
4168
|
if (path == null) {
|
|
4066
4169
|
openExternal(target)
|
|
@@ -4072,7 +4175,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4072
4175
|
val removed = stack.removeAt(stack.lastIndex)
|
|
4073
4176
|
val previous = stack.lastOrNull()
|
|
4074
4177
|
closeScreen(removed) {
|
|
4075
|
-
if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
|
|
4178
|
+
if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink, props = props)
|
|
4076
4179
|
}
|
|
4077
4180
|
return
|
|
4078
4181
|
}
|
|
@@ -4081,8 +4184,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
4081
4184
|
selectTab(routes.tabs.indexOf(tab))
|
|
4082
4185
|
popToRoot()
|
|
4083
4186
|
val root = screens.firstOrNull()
|
|
4084
|
-
if (root != null && match != null && match.params.length() > 0) {
|
|
4085
|
-
send(root, "ROUTE_PARAMS", JSONObject().put("params", match.params))
|
|
4187
|
+
if (root != null && match != null && (match.params.length() > 0 || props.length() > 0)) {
|
|
4188
|
+
send(root, "ROUTE_PARAMS", JSONObject().put("params", match.params).put("props", props))
|
|
4086
4189
|
}
|
|
4087
4190
|
return
|
|
4088
4191
|
}
|
|
@@ -4104,12 +4207,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
4104
4207
|
closeScreens(removed) {
|
|
4105
4208
|
if (screens === stack && stack.lastOrNull() === existing) {
|
|
4106
4209
|
show(existing)
|
|
4107
|
-
send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params))
|
|
4210
|
+
send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params).put("props", props))
|
|
4108
4211
|
}
|
|
4109
4212
|
}
|
|
4110
4213
|
return
|
|
4111
4214
|
}
|
|
4112
|
-
openScreen(match.screen, match.params)?.path = match.path
|
|
4215
|
+
openScreen(match.screen, match.params, props = props)?.path = match.path
|
|
4113
4216
|
}
|
|
4114
4217
|
|
|
4115
4218
|
/** A page the app does not draw, in a Custom Tab over the app. */
|
|
@@ -4117,7 +4220,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4117
4220
|
services.perform("Browser", "open", JSONArray().put(url), {})
|
|
4118
4221
|
}
|
|
4119
4222
|
|
|
4120
|
-
private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
|
|
4223
|
+
private fun openScreen(route: String?, params: JSONObject = JSONObject(), props: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
|
|
4121
4224
|
if (showImmediately && !headless) interruptTabSwipe()
|
|
4122
4225
|
val engine = sandbox ?: return null
|
|
4123
4226
|
val isolate = engine.createIsolate()
|
|
@@ -4145,7 +4248,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4145
4248
|
})
|
|
4146
4249
|
null
|
|
4147
4250
|
}
|
|
4148
|
-
val activeScreen = Screen(route, params, view, isolate, port, headless)
|
|
4251
|
+
val activeScreen = Screen(route, params, props, view, isolate, port, headless)
|
|
4149
4252
|
activeScreen.view.onLayoutChanged = {
|
|
4150
4253
|
if (activeScreen.active) emitLayoutEvents(activeScreen)
|
|
4151
4254
|
}
|
|
@@ -4160,6 +4263,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4160
4263
|
val routePrelude = """
|
|
4161
4264
|
globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
|
|
4162
4265
|
globalThis.__stxNativeParams = ${params};
|
|
4266
|
+
globalThis.__stxNativeProps = ${props};
|
|
4163
4267
|
""".trimIndent()
|
|
4164
4268
|
val audioPrelude = """
|
|
4165
4269
|
(function() {
|
|
@@ -4367,6 +4471,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
4367
4471
|
load.addListener({
|
|
4368
4472
|
try {
|
|
4369
4473
|
load.get()
|
|
4474
|
+
// The STX runtime may install its browser-shaped
|
|
4475
|
+
// craft.dialog after the host prelude. Re-assert the
|
|
4476
|
+
// native capability once the bundle has finished.
|
|
4477
|
+
isolate.evaluateJavaScriptAsync(
|
|
4478
|
+
"if (typeof globalThis.__craftInstallNativeDialog === 'function') globalThis.__craftInstallNativeDialog();"
|
|
4479
|
+
).get()
|
|
4370
4480
|
if (activeScreen.headless) {
|
|
4371
4481
|
CraftNativeBackground.foreground = { event, id, done ->
|
|
4372
4482
|
val app = appScreen
|
|
@@ -4518,6 +4628,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4518
4628
|
/** The bridge's capability names: Craft's core set and this host's services. */
|
|
4519
4629
|
private fun capabilityNames(): JSONArray = capabilities.names().apply {
|
|
4520
4630
|
services.names().forEach { name -> put(name) }
|
|
4631
|
+
put("viewFocus")
|
|
4632
|
+
put("viewMeasure")
|
|
4633
|
+
put("viewScroll")
|
|
4634
|
+
put("viewReparent")
|
|
4521
4635
|
}
|
|
4522
4636
|
|
|
4523
4637
|
private fun appVersion(): Pair<String, String> = runCatching {
|
|
@@ -4570,6 +4684,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4570
4684
|
ViewCompat.requestApplyInsets(root)
|
|
4571
4685
|
refreshInputDefaultsForTraitChange()
|
|
4572
4686
|
chrome?.refreshColors()
|
|
4687
|
+
mapViews().forEach { it.refreshThemeDefaults() }
|
|
4573
4688
|
// Every screen follows light and dark, the ones in other tabs and
|
|
4574
4689
|
// under the one on display too, and the app script hears it.
|
|
4575
4690
|
val appearance = appearance()
|
|
@@ -4589,6 +4704,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4589
4704
|
else -> null
|
|
4590
4705
|
}
|
|
4591
4706
|
}.forEach { it.refreshThemeDefaults() }
|
|
4707
|
+
// Appearance/configuration changes can arrive while this screen is
|
|
4708
|
+
// retained in another tab. Keep its SafeAreaView measured with the
|
|
4709
|
+
// current window insets before the next interactive transition.
|
|
4710
|
+
updateSafeAreaInsets(screen)
|
|
4592
4711
|
updateSafeAreaViews()
|
|
4593
4712
|
}
|
|
4594
4713
|
|
|
@@ -4697,7 +4816,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4697
4816
|
.put("message", "mutation batch could not be applied")
|
|
4698
4817
|
.put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
|
|
4699
4818
|
}
|
|
4700
|
-
"NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let
|
|
4819
|
+
"NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let {
|
|
4820
|
+
navigateOpen(it, props = payload.optJSONObject("props") ?: JSONObject())
|
|
4821
|
+
}
|
|
4701
4822
|
"NAVIGATION_SET_OPTIONS" -> {
|
|
4702
4823
|
// The app script has no navigation bar of its own. Screen
|
|
4703
4824
|
// options remain valid while that screen is off-stack, so
|
|
@@ -4913,6 +5034,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4913
5034
|
screen.controls.clear()
|
|
4914
5035
|
screen.nodeViews.clear()
|
|
4915
5036
|
screen.nodePaths.clear()
|
|
5037
|
+
screen.protocolIds.clear()
|
|
4916
5038
|
screen.handlers.clear()
|
|
4917
5039
|
screen.linkTargets.clear()
|
|
4918
5040
|
screen.flatListRows.clear()
|
|
@@ -4934,15 +5056,18 @@ class MainActivity : AppCompatActivity() {
|
|
|
4934
5056
|
val children = node.optJSONArray("children") ?: JSONArray()
|
|
4935
5057
|
val protocolId = node.optString("id").takeIf { it.isNotBlank() }
|
|
4936
5058
|
val explicitKey = authoredKey(node, props)
|
|
4937
|
-
//
|
|
4938
|
-
//
|
|
4939
|
-
//
|
|
4940
|
-
// compiler's unique protocol ID must win over that legacy fallback.
|
|
5059
|
+
// Authored keys retain conditional replacements and scope their
|
|
5060
|
+
// descendants. A protocol alias additionally finds the same live
|
|
5061
|
+
// view when a popup moves to another parent.
|
|
4941
5062
|
val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
|
|
4942
5063
|
?: protocolId?.let { "id:$it" }
|
|
4943
5064
|
?: props.optString("testID").takeIf { it.isNotBlank() }?.let { "${path.substringBeforeLast('.', "")}/$it" }
|
|
4944
5065
|
?: path
|
|
4945
|
-
val
|
|
5066
|
+
val keyedPrevious = previousControls[identity]?.takeIf { candidate ->
|
|
5067
|
+
val previousId = screen.protocolIds[candidate]
|
|
5068
|
+
previousId == null || previousId == protocolId || !screen.mutations.containsNode(previousId)
|
|
5069
|
+
}
|
|
5070
|
+
val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
|
|
4946
5071
|
val reusableInput = previous as? EditText
|
|
4947
5072
|
val control = when (type) {
|
|
4948
5073
|
"Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
|
|
@@ -4955,6 +5080,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
4955
5080
|
else -> null
|
|
4956
5081
|
}
|
|
4957
5082
|
configureText(this, childText(children), style)
|
|
5083
|
+
val selectable = craftNativeBoolean(props.opt("selectable"))
|
|
5084
|
+
setTextIsSelectable(selectable)
|
|
5085
|
+
isFocusable = selectable
|
|
5086
|
+
isFocusableInTouchMode = selectable
|
|
5087
|
+
isLongClickable = selectable
|
|
4958
5088
|
}
|
|
4959
5089
|
"StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
|
|
4960
5090
|
screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
|
|
@@ -5163,21 +5293,27 @@ class MainActivity : AppCompatActivity() {
|
|
|
5163
5293
|
if (text.toString() == keptText && keptStart >= 0 && (selectionStart != keptStart || selectionEnd != keptEnd)) {
|
|
5164
5294
|
setSelection(keptStart.coerceAtMost(keptText.length), keptEnd.coerceAtMost(keptText.length))
|
|
5165
5295
|
}
|
|
5166
|
-
// One watcher per field, however often it is reused.
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
(
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
.
|
|
5177
|
-
.
|
|
5296
|
+
// One watcher per field, however often it is reused. Keep the
|
|
5297
|
+
// instance so releaseViewState can detach the closure when a
|
|
5298
|
+
// row or screen goes away.
|
|
5299
|
+
if (screen.inputWatchers[this] == null) {
|
|
5300
|
+
val watcher = object : TextWatcher {
|
|
5301
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
5302
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
5303
|
+
val updated = s?.toString().orEmpty()
|
|
5304
|
+
(screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
|
|
5305
|
+
if (screen.suppressedTextChanges.remove(this@apply)) return
|
|
5306
|
+
val current = screen.handlers[this@apply] ?: return
|
|
5307
|
+
screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
|
|
5308
|
+
send(screen, "EVENT", JSONObject()
|
|
5309
|
+
.put("handlerName", current)
|
|
5310
|
+
.put("nativeEvent", JSONObject().put("text", updated)))
|
|
5311
|
+
}
|
|
5312
|
+
override fun afterTextChanged(s: Editable?) {}
|
|
5178
5313
|
}
|
|
5179
|
-
|
|
5180
|
-
|
|
5314
|
+
screen.inputWatchers[this] = watcher
|
|
5315
|
+
addTextChangedListener(watcher)
|
|
5316
|
+
}
|
|
5181
5317
|
}
|
|
5182
5318
|
"Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
|
|
5183
5319
|
val itemOptions = props.optJSONArray("items")?.let { items ->
|
|
@@ -5240,6 +5376,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5240
5376
|
}
|
|
5241
5377
|
}
|
|
5242
5378
|
"Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
|
|
5379
|
+
val focusBeforeContent = screen.view.findFocus()
|
|
5243
5380
|
transparent = craftNativeBoolean(props.opt("transparent"))
|
|
5244
5381
|
content.configure(style, resources.displayMetrics.density)
|
|
5245
5382
|
reconcileChildren(screen, content, children, identity, next, style, previousControls)
|
|
@@ -5255,14 +5392,24 @@ class MainActivity : AppCompatActivity() {
|
|
|
5255
5392
|
}
|
|
5256
5393
|
val wasVisible = screen.modalVisibility[this]
|
|
5257
5394
|
screen.modalVisibility[this] = visible
|
|
5395
|
+
if (visible && wasVisible != true && focusBeforeContent != null && focusBeforeContent !is CraftNativeModalView) {
|
|
5396
|
+
focusToRestore = focusBeforeContent
|
|
5397
|
+
}
|
|
5258
5398
|
val requestClose = craftNativeOptionalString(events.opt("onRequestClose")).orEmpty()
|
|
5259
5399
|
if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
|
|
5260
5400
|
onRequestClose = if (requestClose.isNotBlank()) {
|
|
5261
|
-
{ send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
|
|
5401
|
+
{ reason -> send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject().put("reason", reason))) }
|
|
5262
5402
|
} else {
|
|
5263
5403
|
null
|
|
5264
5404
|
}
|
|
5265
5405
|
setVisible(visible, craftNativeFiniteFloat(style, "opacity", 1f), craftNativeOptionalString(props.opt("animationType")))
|
|
5406
|
+
if (!visible && wasVisible == true) {
|
|
5407
|
+
val restore = focusToRestore
|
|
5408
|
+
focusToRestore = null
|
|
5409
|
+
restore?.post {
|
|
5410
|
+
if (restore.isAttachedToWindow) restore.requestFocus()
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5266
5413
|
if (visible) clearDismissSuppression()
|
|
5267
5414
|
val suppressDismiss = !visible && consumeDismissSuppression()
|
|
5268
5415
|
if (wasVisible != visible && (visible || wasVisible == true)) {
|
|
@@ -5301,10 +5448,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
5301
5448
|
setOnCheckedChangeListener(null)
|
|
5302
5449
|
val requestedValue = props.opt("value")
|
|
5303
5450
|
val requestedChecked = props.opt("checked")
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5451
|
+
if (requestedValue != null && requestedValue != JSONObject.NULL) {
|
|
5452
|
+
isChecked = requestedValue as? Boolean ?: false
|
|
5453
|
+
} else if (requestedChecked is Boolean) {
|
|
5454
|
+
isChecked = requestedChecked
|
|
5308
5455
|
}
|
|
5309
5456
|
isEnabled = !craftNativeBoolean(props.opt("disabled"))
|
|
5310
5457
|
props.optJSONObject("trackColor")?.let { track ->
|
|
@@ -5755,6 +5902,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
5755
5902
|
screen.nodePaths[it] = path
|
|
5756
5903
|
}
|
|
5757
5904
|
next[identity] = control
|
|
5905
|
+
protocolId?.let {
|
|
5906
|
+
next["id:$it"] = control
|
|
5907
|
+
screen.protocolIds[control] = it
|
|
5908
|
+
}
|
|
5758
5909
|
applyViewStyle(control, style)
|
|
5759
5910
|
if (type == "ActivityIndicator") {
|
|
5760
5911
|
val animating = craftNativeBoolean(props.opt("animating"), true)
|
|
@@ -5916,6 +6067,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
5916
6067
|
list.onLayoutChanged = {
|
|
5917
6068
|
if (screen.active) emitLayoutEvents(screen)
|
|
5918
6069
|
}
|
|
6070
|
+
list.onContentSizeChanged = {
|
|
6071
|
+
if (screen.active) CraftNativeFlexLayout.invalidateMeasurementAncestors(list.parent)
|
|
6072
|
+
}
|
|
5919
6073
|
val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
|
|
5920
6074
|
nodes.forEach { registerFlatListOwnership(screen, it, owner) }
|
|
5921
6075
|
list.onScrollEvent = craftNativeEventName(events, "onScroll").takeIf { it.isNotBlank() }?.let { handler ->
|
|
@@ -5955,6 +6109,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
5955
6109
|
val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
|
|
5956
6110
|
releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
|
|
5957
6111
|
},
|
|
6112
|
+
removeItem = { itemIdentity ->
|
|
6113
|
+
val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
|
|
6114
|
+
releaseViewState(screen, controls.values.toList(), preservingInputDrafts = false)
|
|
6115
|
+
},
|
|
5958
6116
|
endReached = craftNativeEventName(events, "onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
|
|
5959
6117
|
{ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
|
|
5960
6118
|
},
|
|
@@ -6411,6 +6569,13 @@ class MainActivity : AppCompatActivity() {
|
|
|
6411
6569
|
host == "::1" || host.endsWith(".localhost")
|
|
6412
6570
|
}
|
|
6413
6571
|
|
|
6572
|
+
/** Resolve app paths against the configured site; bare names stay assets. */
|
|
6573
|
+
private fun imageURI(uri: String): String {
|
|
6574
|
+
if (!uri.startsWith("/")) return uri
|
|
6575
|
+
val base = config.optString("devServerURL").takeIf { it.isNotBlank() } ?: return uri
|
|
6576
|
+
return runCatching { URI(base).resolve(uri).toString() }.getOrDefault(uri)
|
|
6577
|
+
}
|
|
6578
|
+
|
|
6414
6579
|
private fun imageUri(source: Any?): String = when (source) {
|
|
6415
6580
|
is String -> source
|
|
6416
6581
|
is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
|
|
@@ -6439,6 +6604,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6439
6604
|
|
|
6440
6605
|
private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
|
|
6441
6606
|
val uri = imageUri(source)
|
|
6607
|
+
val requestUri = imageURI(uri)
|
|
6442
6608
|
val headers = imageHeaders(source)
|
|
6443
6609
|
val requestKey = imageRequestKey(uri, headers)
|
|
6444
6610
|
if (uri.isBlank()) {
|
|
@@ -6472,7 +6638,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6472
6638
|
} else imageFailure(screen, view, uri, "Image data is invalid")
|
|
6473
6639
|
return
|
|
6474
6640
|
}
|
|
6475
|
-
if (!uri.contains(':')) {
|
|
6641
|
+
if (!uri.startsWith("/") && !uri.contains(':')) {
|
|
6476
6642
|
val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
|
|
6477
6643
|
if (bitmap != null) {
|
|
6478
6644
|
view.setImageBitmap(bitmap)
|
|
@@ -6482,22 +6648,23 @@ class MainActivity : AppCompatActivity() {
|
|
|
6482
6648
|
} else imageFailure(screen, view, uri, "Bundled image was not found")
|
|
6483
6649
|
return
|
|
6484
6650
|
}
|
|
6485
|
-
if (!loadsImage(Uri.parse(
|
|
6486
|
-
imageFailure(screen, view, uri, "
|
|
6651
|
+
if (!loadsImage(Uri.parse(requestUri))) {
|
|
6652
|
+
if (uri.startsWith("/")) imageFailure(screen, view, uri, "Relative image source requires devServerURL")
|
|
6653
|
+
else imageFailure(screen, view, uri, "Unsupported image source")
|
|
6487
6654
|
return
|
|
6488
6655
|
}
|
|
6489
6656
|
imageJobs[view] = imageExecutor.submit {
|
|
6490
6657
|
val bitmap = runCatching {
|
|
6491
|
-
val publicRequest = headers.isEmpty() && Uri.parse(
|
|
6658
|
+
val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
|
|
6492
6659
|
if (publicRequest) {
|
|
6493
|
-
val bytes = imageCache.read(
|
|
6660
|
+
val bytes = imageCache.read(requestUri)
|
|
6494
6661
|
if (bytes != null) {
|
|
6495
6662
|
val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
6496
6663
|
if (saved != null) return@runCatching saved
|
|
6497
|
-
imageCache.remove(
|
|
6664
|
+
imageCache.remove(requestUri)
|
|
6498
6665
|
}
|
|
6499
6666
|
}
|
|
6500
|
-
val connection = (URL(
|
|
6667
|
+
val connection = (URL(requestUri).openConnection() as HttpURLConnection).apply {
|
|
6501
6668
|
connectTimeout = 10_000
|
|
6502
6669
|
readTimeout = 10_000
|
|
6503
6670
|
// This loader owns its bounded public cache. Do not let
|
|
@@ -6515,8 +6682,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
6515
6682
|
if (downloaded != null && policy != null) {
|
|
6516
6683
|
// Encode only after a complete decode, then commit
|
|
6517
6684
|
// before onLoad so a cold offline reopen is safe.
|
|
6518
|
-
imageCache.write(
|
|
6519
|
-
} else imageCache.remove(
|
|
6685
|
+
imageCache.write(requestUri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
|
6686
|
+
} else imageCache.remove(requestUri)
|
|
6520
6687
|
}
|
|
6521
6688
|
downloaded
|
|
6522
6689
|
} finally { connection.disconnect() }
|
|
@@ -6593,8 +6760,17 @@ class MainActivity : AppCompatActivity() {
|
|
|
6593
6760
|
else -> null
|
|
6594
6761
|
}
|
|
6595
6762
|
view.tooltipText = craftNativeOptionalString(props.opt("accessibilityHint"))
|
|
6763
|
+
val hasLinkTarget = listOf("href", "to").any { key ->
|
|
6764
|
+
!craftNativeOptionalString(props.opt(key)).isNullOrBlank()
|
|
6765
|
+
}
|
|
6766
|
+
val linkedControl = type in setOf("Link", "TouchableOpacity", "TouchableHighlight", "Pressable") && hasLinkTarget
|
|
6596
6767
|
val role = craftNativeOptionalString(props.opt("accessibilityRole")) ?: run {
|
|
6597
|
-
|
|
6768
|
+
when {
|
|
6769
|
+
linkedControl -> "link"
|
|
6770
|
+
type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") -> "button"
|
|
6771
|
+
type == "Icon" -> "image"
|
|
6772
|
+
else -> type.lowercase()
|
|
6773
|
+
}
|
|
6598
6774
|
}
|
|
6599
6775
|
val state = props.optJSONObject("accessibilityState")
|
|
6600
6776
|
val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
|
|
@@ -6746,15 +6922,18 @@ class MainActivity : AppCompatActivity() {
|
|
|
6746
6922
|
capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
|
|
6747
6923
|
}
|
|
6748
6924
|
val module = payload.optString("module")
|
|
6749
|
-
val
|
|
6750
|
-
|
|
6925
|
+
val method = payload.optString("method")
|
|
6926
|
+
val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
|
|
6927
|
+
{ completion -> performView(screen, method, args, completion) }
|
|
6928
|
+
} else if (services.handles(module)) {
|
|
6929
|
+
{ completion -> services.perform(module, method, args, completion) }
|
|
6751
6930
|
} else {
|
|
6752
6931
|
{ completion ->
|
|
6753
6932
|
capabilities.perform(
|
|
6754
6933
|
token,
|
|
6755
6934
|
payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
|
|
6756
6935
|
module,
|
|
6757
|
-
|
|
6936
|
+
method,
|
|
6758
6937
|
args,
|
|
6759
6938
|
completion,
|
|
6760
6939
|
)
|
|
@@ -6772,6 +6951,97 @@ class MainActivity : AppCompatActivity() {
|
|
|
6772
6951
|
}
|
|
6773
6952
|
}
|
|
6774
6953
|
|
|
6954
|
+
/** View operations are screen-owned: node ids only exist while a route is mounted. */
|
|
6955
|
+
private fun performView(
|
|
6956
|
+
screen: Screen,
|
|
6957
|
+
method: String,
|
|
6958
|
+
args: JSONArray,
|
|
6959
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
6960
|
+
) {
|
|
6961
|
+
val options = args.optJSONObject(0)
|
|
6962
|
+
val nodeId = options?.optString("nodeId")?.takeIf { it.isNotBlank() } ?: args.optString(0)
|
|
6963
|
+
if (nodeId.isBlank()) {
|
|
6964
|
+
completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.$method needs a nodeId"))
|
|
6965
|
+
return
|
|
6966
|
+
}
|
|
6967
|
+
val target = screen.nodeViews[nodeId]
|
|
6968
|
+
if (target == null) {
|
|
6969
|
+
completion(CraftNativeCapabilityResult(code = "VIEW_NOT_FOUND", message = "View.$method could not find mounted node $nodeId"))
|
|
6970
|
+
return
|
|
6971
|
+
}
|
|
6972
|
+
if (!target.isAttachedToWindow) {
|
|
6973
|
+
completion(CraftNativeCapabilityResult(code = "VIEW_NOT_MOUNTED", message = "View.$method requires a mounted node"))
|
|
6974
|
+
return
|
|
6975
|
+
}
|
|
6976
|
+
when (method) {
|
|
6977
|
+
"focus" -> when (options?.optString("mode") ?: args.optString(1, "keyboard")) {
|
|
6978
|
+
"keyboard" -> if (target is EditText) {
|
|
6979
|
+
if (!target.requestFocus()) {
|
|
6980
|
+
completion(CraftNativeCapabilityResult(code = "FOCUS_FAILED", message = "The mounted view refused keyboard focus"))
|
|
6981
|
+
} else {
|
|
6982
|
+
target.post {
|
|
6983
|
+
(getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
|
|
6984
|
+
?.showSoftInput(target, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
|
6985
|
+
}
|
|
6986
|
+
completion(CraftNativeCapabilityResult(data = true))
|
|
6987
|
+
}
|
|
6988
|
+
} else completion(CraftNativeCapabilityResult(code = "UNSUPPORTED_TARGET", message = "Keyboard focus is supported only for text inputs"))
|
|
6989
|
+
"accessibility" -> {
|
|
6990
|
+
target.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED)
|
|
6991
|
+
completion(CraftNativeCapabilityResult(data = true))
|
|
6992
|
+
}
|
|
6993
|
+
else -> completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.focus mode must be keyboard or accessibility"))
|
|
6994
|
+
}
|
|
6995
|
+
"measure" -> {
|
|
6996
|
+
screen.view.requestLayout()
|
|
6997
|
+
screen.view.measure(
|
|
6998
|
+
View.MeasureSpec.makeMeasureSpec(screen.view.width, View.MeasureSpec.EXACTLY),
|
|
6999
|
+
View.MeasureSpec.makeMeasureSpec(screen.view.height, View.MeasureSpec.EXACTLY),
|
|
7000
|
+
)
|
|
7001
|
+
val targetLocation = IntArray(2)
|
|
7002
|
+
val viewportLocation = IntArray(2)
|
|
7003
|
+
target.getLocationOnScreen(targetLocation)
|
|
7004
|
+
screen.view.getLocationOnScreen(viewportLocation)
|
|
7005
|
+
val density = resources.displayMetrics.density.coerceAtLeast(0.1f)
|
|
7006
|
+
completion(CraftNativeCapabilityResult(data = JSONObject()
|
|
7007
|
+
.put("x", (targetLocation[0] - viewportLocation[0]) / density)
|
|
7008
|
+
.put("y", (targetLocation[1] - viewportLocation[1]) / density)
|
|
7009
|
+
.put("width", target.width / density)
|
|
7010
|
+
.put("height", target.height / density)
|
|
7011
|
+
.put("viewportWidth", screen.view.width / density)
|
|
7012
|
+
.put("viewportHeight", screen.view.height / density)
|
|
7013
|
+
.put("unit", "dp")
|
|
7014
|
+
.put("coordinateSpace", "screenViewport")))
|
|
7015
|
+
}
|
|
7016
|
+
"scrollTo" -> {
|
|
7017
|
+
val scroll = target as? CraftNativeScrollView
|
|
7018
|
+
if (scroll == null) {
|
|
7019
|
+
completion(CraftNativeCapabilityResult(code = "UNSUPPORTED_TARGET", message = "View.scrollTo is supported only for scroll views"))
|
|
7020
|
+
return
|
|
7021
|
+
}
|
|
7022
|
+
val rawX = options?.opt("x")
|
|
7023
|
+
val rawY = options?.opt("y")
|
|
7024
|
+
val x = (rawX as? Number)?.toDouble()?.takeIf { it.isFinite() }
|
|
7025
|
+
val y = (rawY as? Number)?.toDouble()?.takeIf { it.isFinite() }
|
|
7026
|
+
if ((options?.has("x") == true && x == null) || (options?.has("y") == true && y == null)) {
|
|
7027
|
+
completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.scrollTo coordinates must be finite numbers"))
|
|
7028
|
+
return
|
|
7029
|
+
}
|
|
7030
|
+
val density = resources.displayMetrics.density.coerceAtLeast(0.1f)
|
|
7031
|
+
val position = scroll.scrollTo(
|
|
7032
|
+
x?.times(density)?.roundToInt(),
|
|
7033
|
+
y?.times(density)?.roundToInt(),
|
|
7034
|
+
options?.optBoolean("animated", false) == true,
|
|
7035
|
+
)
|
|
7036
|
+
completion(CraftNativeCapabilityResult(data = JSONObject()
|
|
7037
|
+
.put("x", position.first / density)
|
|
7038
|
+
.put("y", position.second / density)
|
|
7039
|
+
.put("unit", "dp")))
|
|
7040
|
+
}
|
|
7041
|
+
else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported View operation"))
|
|
7042
|
+
}
|
|
7043
|
+
}
|
|
7044
|
+
|
|
6775
7045
|
internal fun performImmediateCapability(module: String, method: String, args: JSONArray): CraftNativeCapabilityResult =
|
|
6776
7046
|
when (module to method) {
|
|
6777
7047
|
"Device" to "getInfo" -> CraftNativeCapabilityResult(data = JSONObject()
|
|
@@ -7172,7 +7442,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7172
7442
|
preservingInputDrafts: Boolean,
|
|
7173
7443
|
) {
|
|
7174
7444
|
val owner = screen.flatListOwnerByView.remove(list)
|
|
7175
|
-
list.
|
|
7445
|
+
list.resetForTeardown()
|
|
7176
7446
|
screen.flatListRows.remove(list)?.values?.forEach {
|
|
7177
7447
|
releaseViewState(screen, it.values, preservingInputDrafts)
|
|
7178
7448
|
}
|
|
@@ -7196,13 +7466,39 @@ class MainActivity : AppCompatActivity() {
|
|
|
7196
7466
|
releaseFlatList(screen, it, preservingInputDrafts)
|
|
7197
7467
|
}
|
|
7198
7468
|
released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
|
|
7469
|
+
released.filterIsInstance<CraftNativeDatePickerView>().forEach { it.resetForTeardown() }
|
|
7470
|
+
released.filterIsInstance<CraftNativeRefreshableFlatList>().forEach { it.resetForTeardown() }
|
|
7199
7471
|
released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
|
|
7200
7472
|
released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }
|
|
7201
7473
|
released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
|
|
7202
7474
|
released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
|
|
7203
|
-
released.filterIsInstance<CraftNativeScrollView>().forEach {
|
|
7475
|
+
released.filterIsInstance<CraftNativeScrollView>().forEach {
|
|
7476
|
+
it.setRefreshHandler(null, false)
|
|
7477
|
+
it.clearScrollListeners()
|
|
7478
|
+
}
|
|
7204
7479
|
releaseImages(released)
|
|
7480
|
+
// Clearing the handler maps is not enough: Android Views retain the
|
|
7481
|
+
// listener objects themselves. Detach every interaction callback from
|
|
7482
|
+
// released/recycled rows so a later identity cannot inherit a closure
|
|
7483
|
+
// that still captures the previous row's screen state.
|
|
7484
|
+
released.forEach {
|
|
7485
|
+
// AdapterView (including Spinner, our native Picker) rejects
|
|
7486
|
+
// setOnClickListener altogether. Its selection callback is
|
|
7487
|
+
// detached below; ordinary Views still need their click target
|
|
7488
|
+
// cleared here.
|
|
7489
|
+
if (it !is android.widget.AdapterView<*>) it.setOnClickListener(null)
|
|
7490
|
+
it.setOnTouchListener(null)
|
|
7491
|
+
it.setOnLongClickListener(null)
|
|
7492
|
+
it.setOnFocusChangeListener(null)
|
|
7493
|
+
it.isClickable = false
|
|
7494
|
+
it.isLongClickable = false
|
|
7495
|
+
}
|
|
7496
|
+
released.filterIsInstance<android.widget.CompoundButton>().forEach { it.setOnCheckedChangeListener(null) }
|
|
7497
|
+
released.filterIsInstance<SeekBar>().forEach { it.setOnSeekBarChangeListener(null) }
|
|
7498
|
+
released.filterIsInstance<Spinner>().forEach { it.onItemSelectedListener = null }
|
|
7499
|
+
released.filterIsInstance<EditText>().forEach { it.setOnEditorActionListener(null) }
|
|
7205
7500
|
released.filterIsInstance<EditText>().forEach {
|
|
7501
|
+
screen.inputWatchers.remove(it)?.let(it::removeTextChangedListener)
|
|
7206
7502
|
inputHintDefaults.remove(it)
|
|
7207
7503
|
inputHighlightDefaults.remove(it)
|
|
7208
7504
|
inputPaddingDefaults.remove(it)
|
|
@@ -7213,6 +7509,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7213
7509
|
}
|
|
7214
7510
|
released.forEach(defaultBackgrounds::remove)
|
|
7215
7511
|
screen.linkTargets.keys.removeAll(released)
|
|
7512
|
+
screen.protocolIds.keys.removeAll(released)
|
|
7216
7513
|
screen.handlers.keys.removeAll(released)
|
|
7217
7514
|
screen.pressInHandlers.keys.removeAll(released)
|
|
7218
7515
|
screen.pressOutHandlers.keys.removeAll(released)
|
|
@@ -7270,9 +7567,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7270
7567
|
screen.pendingCapabilities.forEach(capabilities::cancel)
|
|
7271
7568
|
screen.pendingCapabilities.clear()
|
|
7272
7569
|
releaseViewState(screen, retainedViews(screen).toList())
|
|
7570
|
+
screen.inputWatchers.forEach { (input, watcher) -> input.removeTextChangedListener(watcher) }
|
|
7571
|
+
screen.inputWatchers.clear()
|
|
7273
7572
|
screen.controls.clear()
|
|
7274
7573
|
screen.nodeViews.clear()
|
|
7275
7574
|
screen.nodePaths.clear()
|
|
7575
|
+
screen.protocolIds.clear()
|
|
7276
7576
|
screen.handlers.clear()
|
|
7277
7577
|
screen.pressInHandlers.clear()
|
|
7278
7578
|
screen.pressOutHandlers.clear()
|
|
@@ -7316,7 +7616,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7316
7616
|
val screen = screens.lastOrNull() ?: return back()
|
|
7317
7617
|
val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
|
|
7318
7618
|
if (modal != null) {
|
|
7319
|
-
(modal.key as? CraftNativeModalView)?.requestClose()
|
|
7619
|
+
(modal.key as? CraftNativeModalView)?.requestClose(reason = "back")
|
|
7320
7620
|
return
|
|
7321
7621
|
}
|
|
7322
7622
|
back()
|