craft-native 0.0.167 → 0.0.169

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.
@@ -85,7 +85,6 @@ import androidx.javascriptengine.JavaScriptSandbox
85
85
  import androidx.javascriptengine.Message
86
86
  import androidx.javascriptengine.MessagePort
87
87
  import androidx.javascriptengine.MessagePortClient
88
- import com.google.common.util.concurrent.ListenableFuture
89
88
  import com.craft.runtime.CraftNative
90
89
  import org.json.JSONArray
91
90
  import org.json.JSONObject
@@ -190,6 +189,39 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
190
189
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
191
190
  }
192
191
 
192
+ /**
193
+ * A CSS colour as Android's ARGB. STX writes `#RRGGBBAA` (and `#RGBA`) for a
194
+ * colour with alpha, where Android's parser reads `#AARRGGBB`: a translucent
195
+ * grey fill (`#7676801f`) came out olive. Also `rgb()`/`rgba()` and
196
+ * `transparent`; anything else goes to Android's parser (named colours).
197
+ */
198
+ internal fun craftNativeColor(raw: String): Int {
199
+ val value = raw.trim()
200
+ if (value.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
201
+ if (value.startsWith("#")) {
202
+ val hex = value.substring(1)
203
+ fun channel(text: String) = text.toInt(16)
204
+ return when (hex.length) {
205
+ 3 -> Color.rgb(channel("${hex[0]}${hex[0]}"), channel("${hex[1]}${hex[1]}"), channel("${hex[2]}${hex[2]}"))
206
+ 4 -> Color.argb(channel("${hex[3]}${hex[3]}"), channel("${hex[0]}${hex[0]}"), channel("${hex[1]}${hex[1]}"), channel("${hex[2]}${hex[2]}"))
207
+ 6 -> Color.rgb(channel(hex.substring(0, 2)), channel(hex.substring(2, 4)), channel(hex.substring(4, 6)))
208
+ 8 -> Color.argb(channel(hex.substring(6, 8)), channel(hex.substring(0, 2)), channel(hex.substring(2, 4)), channel(hex.substring(4, 6)))
209
+ else -> throw IllegalArgumentException("Unknown color $raw")
210
+ }
211
+ }
212
+ val functional = Regex("""^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$""", RegexOption.IGNORE_CASE).find(value)
213
+ if (functional != null) {
214
+ val (r, g, b, a) = functional.destructured
215
+ val alpha = when {
216
+ a.isBlank() -> 1.0
217
+ a.endsWith("%") -> a.dropLast(1).toDouble() / 100.0
218
+ else -> a.toDouble()
219
+ }
220
+ return Color.argb((alpha.coerceIn(0.0, 1.0) * 255).roundToInt(), r.toDouble().roundToInt(), g.toDouble().roundToInt(), b.toDouble().roundToInt())
221
+ }
222
+ return Color.parseColor(value)
223
+ }
224
+
193
225
  /** Android View has no skew setters before API 29; draw the shared skew around the view's center. */
194
226
  private object CraftNativeSkewTransforms {
195
227
  private val values = WeakHashMap<View, Pair<Float, Float>>()
@@ -421,7 +453,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
421
453
  }
422
454
 
423
455
  private fun resolveColor(value: String?, current: String, fallback: Int): Int = runCatching {
424
- Color.parseColor(if (value == "currentColor") current else value ?: current)
456
+ craftNativeColor(if (value == "currentColor") current else value ?: current)
425
457
  }.getOrDefault(fallback)
426
458
 
427
459
  private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
@@ -443,6 +475,67 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
443
475
  private var reverse = false
444
476
  private var wrapReverse = false
445
477
 
478
+ /**
479
+ * What a child measures at a pair of specs, remembered for the frame.
480
+ * `entries` asks each child up to three times (max-content, its target
481
+ * width, its height) and again when laying out; every child doing the
482
+ * same to its own made a deep screen (the Calendar) exponential, and the
483
+ * app stopped responding. Views only change between frames, so within
484
+ * one (the animation clock is locked for a frame's traversal) the same
485
+ * question has the same answer.
486
+ */
487
+ /**
488
+ * Whether this container's width is fixed while it measures (an EXACTLY
489
+ * spec, or a laid-out frame): only then does a column stretch its
490
+ * children to it. Measured for its content width (AT_MOST, a chip in a
491
+ * row), it is as wide as its widest child.
492
+ */
493
+ private var widthDefinite = false
494
+
495
+ private class MeasureMemo(var clock: Long = Long.MIN_VALUE, val results: HashMap<Long, Long> = HashMap())
496
+ private val memos = WeakHashMap<View, MeasureMemo>()
497
+
498
+ private fun probe(child: View, widthSpec: Int, heightSpec: Int): Long {
499
+ val clock = android.view.animation.AnimationUtils.currentAnimationTimeMillis()
500
+ val memo = memos.getOrPut(child) { MeasureMemo() }
501
+ if (memo.clock != clock) {
502
+ memo.clock = clock
503
+ memo.results.clear()
504
+ }
505
+ val key = (widthSpec.toLong() shl 32) or (heightSpec.toLong() and 0xffffffffL)
506
+ memo.results[key]?.let {
507
+ lastProbe[child] = it
508
+ return it
509
+ }
510
+ child.measure(widthSpec, heightSpec)
511
+ val size = (child.measuredWidth.toLong() shl 32) or (child.measuredHeight.toLong() and 0xffffffffL)
512
+ memo.results[key] = size
513
+ lastProbe[child] = size
514
+ return size
515
+ }
516
+ private val lastProbe = WeakHashMap<View, Long>()
517
+ private fun probedWidth(size: Long): Int = (size shr 32).toInt()
518
+ private fun probedHeight(size: Long): Int = size.toInt()
519
+
520
+ /**
521
+ * Lays a child out at its final frame, measuring it again at exactly
522
+ * that size first when the measure pass left it another size (a
523
+ * stretched or flex-grown child). A container such as the scroll view's
524
+ * SwipeRefreshLayout lays its own children out from its measured size,
525
+ * so a frame it was never measured at left its content 0 tall.
526
+ */
527
+ private fun place(child: View, left: Int, top: Int, right: Int, bottom: Int) {
528
+ val width = max(0, right - left)
529
+ val height = max(0, bottom - top)
530
+ if (child.measuredWidth != width || child.measuredHeight != height) {
531
+ child.measure(
532
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
533
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
534
+ )
535
+ }
536
+ child.layout(left, top, right, bottom)
537
+ }
538
+
446
539
  override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
447
540
  val skew = CraftNativeSkewTransforms.matrix(child) ?: return super.drawChild(canvas, child, drawingTime)
448
541
  val save = canvas.save()
@@ -560,6 +653,29 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
560
653
  }
561
654
  if (line.entries.isNotEmpty()) lines += line
562
655
  }
656
+ // A row's items take their widths from the line (`flex-1` starts
657
+ // from a zero basis), and text wraps to the width it gets: measured
658
+ // at its basis, a `flex-1` column of text was a word per line and
659
+ // thousands of pixels tall. Heights are measured again at the width
660
+ // each item will have.
661
+ if (orientation == HORIZONTAL && width < 500_000) {
662
+ for (line in lines) {
663
+ val widths = fallbackFlexWidths(line, availableMain, gap)
664
+ for (index in line.entries.indices) {
665
+ val entry = line.entries[index]
666
+ val given = widths.getOrNull(index) ?: continue
667
+ if (given == entry.width || entry.style.height != null) continue
668
+ val lp = entry.view.layoutParams as? MarginLayoutParams
669
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
670
+ val remeasured = measureChildHeight(entry.view, entry.style, height - paddingTop - paddingBottom, verticalMargins, max(0, given))
671
+ line.entries[index] = entry.copy(height = remeasured)
672
+ }
673
+ line.cross = line.entries.maxOfOrNull { entry ->
674
+ val lp = entry.view.layoutParams as? MarginLayoutParams
675
+ (entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)).toFloat()
676
+ } ?: 0f
677
+ }
678
+ }
563
679
  // Reconciliation reverses the child order so the main axis runs in
564
680
  // the requested direction. With wrapping, that also reverses the
565
681
  // line order; flexbox keeps wrap lines in cross-axis order, so restore
@@ -647,30 +763,47 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
647
763
  val authored = style.width?.resolve(base)
648
764
  val explicit = authored ?: style.aspectRatio?.let { ratio -> style.height?.resolve(base)?.times(ratio)?.roundToInt() } ?: flexBasis
649
765
  if (explicit != null) {
650
- child.measure(
766
+ probe(
767
+ child,
651
768
  View.MeasureSpec.makeMeasureSpec(explicit, View.MeasureSpec.EXACTLY),
652
769
  View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
653
770
  )
654
771
  return clamp(explicit, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
655
772
  }
773
+ // A column of a definite width stretches its children across
774
+ // (`align-items: stretch`, the default): they are as wide as the
775
+ // column, not as wide as their content, and what they hold wraps to that width. Measured at their
776
+ // content width, a row with a `flex-1` text column measured as a word
777
+ // per line and stayed that tall once stretched.
778
+ if (orientation == VERTICAL && widthDefinite && style.position != "absolute" && (style.alignSelf ?: alignItems) == "stretch" && base < 500_000 && child !is CraftNativeModalView) {
779
+ val stretched = clamp(base, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
780
+ probe(
781
+ child,
782
+ View.MeasureSpec.makeMeasureSpec(stretched, View.MeasureSpec.EXACTLY),
783
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
784
+ )
785
+ return stretched
786
+ }
656
787
  // Measure text and nested flex containers at max-content first. An
657
788
  // AT_MOST spec equal to the available room can make Android report the
658
789
  // longest-word (min-content) width for a wrapping TextView, collapsing
659
790
  // shrink-to-fit bubbles and pills before the parent can clamp them.
660
791
  val intrinsicSpec = if (child is TextView && child !is EditText) 1_000_000 else base
661
- child.measure(
792
+ val intrinsic = probe(
793
+ child,
662
794
  View.MeasureSpec.makeMeasureSpec(intrinsicSpec, View.MeasureSpec.AT_MOST),
663
795
  View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
664
796
  )
665
- val target = clamp(min(base, child.measuredWidth), style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
666
- child.measure(
797
+ val target = clamp(min(base, probedWidth(intrinsic)), style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
798
+ probe(
799
+ child,
667
800
  View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.AT_MOST),
668
801
  View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
669
802
  )
670
803
  return target
671
804
  }
672
805
 
673
- private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
806
+ private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int, widthOverride: Int? = null): Int {
674
807
  val base = max(0, available - margins)
675
808
  val flexBasis = if (orientation == VERTICAL) style.flexBasis?.resolve(base) else null
676
809
  // Match width measurement: do not let stale native LayoutParams keep
@@ -680,8 +813,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
680
813
  val target = explicit ?: base
681
814
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
682
815
  else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
683
- child.measure(child.measuredWidth.let { View.MeasureSpec.makeMeasureSpec(max(0, it), View.MeasureSpec.EXACTLY) }, spec)
684
- return clamp(if (explicit != null) target else child.measuredHeight, style.minHeight?.resolve(base), style.maxHeight?.resolve(base))
816
+ val width = widthOverride ?: lastProbe[child]?.let(::probedWidth) ?: child.measuredWidth
817
+ val size = probe(child, View.MeasureSpec.makeMeasureSpec(max(0, width), View.MeasureSpec.EXACTLY), spec)
818
+ return clamp(if (explicit != null) target else probedHeight(size), style.minHeight?.resolve(base), style.maxHeight?.resolve(base))
685
819
  }
686
820
 
687
821
  private fun clamp(value: Int, lower: Int?, upper: Int?): Int {
@@ -780,6 +914,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
780
914
 
781
915
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
782
916
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
917
+ widthDefinite = widthMode == View.MeasureSpec.EXACTLY
783
918
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
784
919
  val height = View.MeasureSpec.getSize(heightMeasureSpec).takeUnless { View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
785
920
  if (grid) {
@@ -811,6 +946,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
811
946
  }
812
947
 
813
948
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
949
+ widthDefinite = true
814
950
  if (grid) {
815
951
  val layout = gridLayout(width, height)
816
952
  val contentWidth = width - paddingLeft - paddingRight
@@ -845,7 +981,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
845
981
  val y = paddingTop + sharedFrames[offset + 1].roundToInt()
846
982
  val childWidth = sharedFrames[offset + 2].roundToInt()
847
983
  val childHeight = sharedFrames[offset + 3].roundToInt()
848
- entry.view.layout(x, y, x + childWidth, y + childHeight)
984
+ place(entry.view, x, y, x + childWidth, y + childHeight)
849
985
  }
850
986
  } else {
851
987
  val totalRows = layout.rowHeights.sum()
@@ -893,7 +1029,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
893
1029
  "flex-end" -> availableHeight - childHeight
894
1030
  else -> 0
895
1031
  }).roundToInt()
896
- entry.view.layout(x, y, x + childWidth, y + childHeight)
1032
+ place(entry.view, x, y, x + childWidth, y + childHeight)
897
1033
  }
898
1034
  rowTop += rowHeight + rowBetween
899
1035
  }
@@ -904,7 +1040,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
904
1040
  val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
905
1041
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
906
1042
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - childHeight } ?: 0))
907
- entry.view.layout(x, y, x + max(0, childWidth), y + max(0, childHeight))
1043
+ place(entry.view, x, y, x + max(0, childWidth), y + max(0, childHeight))
908
1044
  }
909
1045
  onLayoutChanged?.invoke()
910
1046
  return
@@ -948,7 +1084,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
948
1084
  val crossOrigin = (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat()
949
1085
  val crossExtent = availableCross.toFloat()
950
1086
  for ((lineIndex, line) in lines.withIndex()) {
951
- val lineCross = line.cross.toFloat() + stretchExtra
1087
+ // A single-line container's one line is as tall (or wide) as the
1088
+ // container itself, as in CSS: `h-12 items-center` centres its
1089
+ // children in the 48 points, not in the tallest child.
1090
+ val lineCross = if (!wrap) max(line.cross.toFloat(), availableCross.toFloat())
1091
+ else line.cross.toFloat() + stretchExtra
952
1092
  val lineOffset = sharedCross?.first?.get(lineIndex)?.let { crossOrigin + it } ?: crossOffset
953
1093
  val physicalLineOffset = if (wrapReverse && wrap) crossOrigin + crossExtent - (lineOffset - crossOrigin) - lineCross else lineOffset
954
1094
  val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
@@ -1042,7 +1182,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1042
1182
  val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
1043
1183
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
1044
1184
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
1045
- entry.view.layout(x, y, x + w, y + h)
1185
+ place(entry.view, x, y, x + w, y + h)
1046
1186
  if (sharedPosition == null) mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
1047
1187
  }
1048
1188
  if (sharedCross == null) crossOffset += lineCross + crossBetween
@@ -1053,7 +1193,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1053
1193
  val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
1054
1194
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
1055
1195
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
1056
- entry.view.layout(x, y, x + max(0, widthValue), y + max(0, heightValue))
1196
+ place(entry.view, x, y, x + max(0, widthValue), y + max(0, heightValue))
1057
1197
  }
1058
1198
  onLayoutChanged?.invoke()
1059
1199
  }
@@ -1094,12 +1234,6 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
1094
1234
  private var behavior = "padding"
1095
1235
  private var keyboardVerticalOffset = 0
1096
1236
  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
- }
1103
1237
  private var basePadding = intArrayOf(0, 0, 0, 0)
1104
1238
 
1105
1239
  init {
@@ -1163,6 +1297,45 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1163
1297
  setHorizontal(false)
1164
1298
  }
1165
1299
 
1300
+ /**
1301
+ * Along an axis its parent does not fix, a scroll view is as big as its
1302
+ * content (up to what the parent allows): a horizontal strip is as tall
1303
+ * as its row, a vertical scroll with room to spare as tall as what it
1304
+ * holds. SwipeRefreshLayout alone takes all of an AT_MOST size, which
1305
+ * made a strip in a column a million pixels tall.
1306
+ */
1307
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1308
+ val widthMode = MeasureSpec.getMode(widthMeasureSpec)
1309
+ val heightMode = MeasureSpec.getMode(heightMeasureSpec)
1310
+ val widthSize = MeasureSpec.getSize(widthMeasureSpec)
1311
+ val heightSize = MeasureSpec.getSize(heightMeasureSpec)
1312
+ if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
1313
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
1314
+ return
1315
+ }
1316
+ val sideways = horizontalMode == true
1317
+ val unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
1318
+ val horizontalPadding = paddingLeft + paddingRight
1319
+ val verticalPadding = paddingTop + paddingBottom
1320
+ content.measure(
1321
+ if (sideways || widthMode == MeasureSpec.UNSPECIFIED) unspecified
1322
+ else MeasureSpec.makeMeasureSpec(max(0, widthSize - horizontalPadding), MeasureSpec.EXACTLY),
1323
+ if (!sideways || heightMode == MeasureSpec.UNSPECIFIED) unspecified
1324
+ else MeasureSpec.makeMeasureSpec(max(0, heightSize - verticalPadding), MeasureSpec.AT_MOST),
1325
+ )
1326
+ fun fit(mode: Int, size: Int, wanted: Int): Int = when (mode) {
1327
+ MeasureSpec.EXACTLY -> size
1328
+ MeasureSpec.AT_MOST -> min(size, wanted)
1329
+ else -> wanted
1330
+ }
1331
+ val width = fit(widthMode, widthSize, content.measuredWidth + horizontalPadding)
1332
+ val height = fit(heightMode, heightSize, content.measuredHeight + verticalPadding)
1333
+ super.onMeasure(
1334
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
1335
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
1336
+ )
1337
+ }
1338
+
1166
1339
  fun setRefreshHandler(handler: (() -> Unit)?, refreshing: Boolean) {
1167
1340
  refreshHandler = handler
1168
1341
  isEnabled = handler != null
@@ -1361,12 +1534,28 @@ private class CraftNativeFlexSpacer(context: Context) : View(context)
1361
1534
  * the other native leaf controls without adding an icon-font dependency.
1362
1535
  */
1363
1536
  private class CraftNativeIconView(context: Context) : TextView(context) {
1537
+ /** The app's vector for the icon (CraftNativeIcons), drawn in place of the glyph. */
1538
+ var vector: Drawable? = null
1539
+ set(value) {
1540
+ field = value
1541
+ invalidate()
1542
+ }
1543
+
1364
1544
  init {
1365
1545
  includeFontPadding = false
1366
1546
  gravity = Gravity.CENTER
1367
1547
  setPadding(0, 0, 0, 0)
1368
1548
  isSingleLine = true
1369
1549
  }
1550
+
1551
+ override fun onDraw(canvas: Canvas) {
1552
+ val icon = vector ?: return super.onDraw(canvas)
1553
+ // A single-line TextView keeps a horizontal scroll from centring its
1554
+ // text in a max-content measure (half a million pixels): the canvas
1555
+ // arrives scrolled, so the box is placed where the view is.
1556
+ icon.setBounds(scrollX + paddingLeft, scrollY + paddingTop, scrollX + width - paddingRight, scrollY + height - paddingBottom)
1557
+ icon.draw(canvas)
1558
+ }
1370
1559
  }
1371
1560
 
1372
1561
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
@@ -1692,7 +1881,10 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
1692
1881
  sourceUri = uri
1693
1882
  stopPlayback()
1694
1883
  if (uri.isBlank()) return
1695
- setVideoURI(Uri.parse(uri))
1884
+ // A copy kept on the phone (craft.media.keep) plays in place of
1885
+ // the address: offline in a gym with no signal.
1886
+ val kept = CraftNativeMedia.local(context, uri)
1887
+ setVideoURI(if (kept != null) Uri.fromFile(kept) else Uri.parse(uri))
1696
1888
  setOnPreparedListener { mediaPlayer ->
1697
1889
  mediaPlayer.isLooping = shouldLoop
1698
1890
  val volume = if (this.muted) 0f else 1f
@@ -1984,11 +2176,32 @@ class MainActivity : AppCompatActivity() {
1984
2176
  val params: JSONObject,
1985
2177
  val view: CraftNativeFlexLayout,
1986
2178
  val isolate: JavaScriptIsolate,
1987
- val port: MessagePort?
2179
+ val port: MessagePort?,
2180
+ /** The app script (`nativeApp`): runs beside the screens and draws nothing. */
2181
+ val headless: Boolean = false,
1988
2182
  ) {
2183
+ /** What the screen set with `craft.navigation.setOptions`, merged. */
2184
+ var options = JSONObject()
2185
+ /** Shown before: its next showing is a `SCREEN_FOCUS`. */
2186
+ var shown = false
2187
+ /** The app path it was opened for (`/m/workout/42`), to find it again. */
2188
+ var path: String? = null
2189
+ /** Long messages from the console bridge, put back together. */
2190
+ val chunks = CraftNativeChunks()
2191
+ /** A `requestAnimationFrame` is waiting for the next frame. */
2192
+ var frameRequested = false
2193
+ /** The screen's timers (`setTimeout`), kept by the host. */
2194
+ val timers = mutableMapOf<Int, Runnable>()
1989
2195
  val drafts = mutableMapOf<String, String>()
1990
2196
  val inputIdentities = mutableMapOf<EditText, String>()
1991
2197
  val suppressedTextChanges = mutableSetOf<EditText>()
2198
+ /**
2199
+ * The texts a field sent the screen that its `value` has not come
2200
+ * back as yet, oldest first. Typing faster than the round trip makes
2201
+ * the screen answer with a value the field has already moved past;
2202
+ * that echo is not written back over what was typed since.
2203
+ */
2204
+ val inputEchoes = mutableMapOf<EditText, ArrayDeque<String>>()
1992
2205
  val controls = mutableMapOf<String, View>()
1993
2206
  // Protocol ids identify mutation targets; authored keys identify the
1994
2207
  // retained view. Keep both mappings so a targeted update can recover
@@ -2057,7 +2270,22 @@ class MainActivity : AppCompatActivity() {
2057
2270
 
2058
2271
  private lateinit var root: FrameLayout
2059
2272
  private var sandbox: JavaScriptSandbox? = null
2060
- private val screens = mutableListOf<Screen>()
2273
+ /** The stack on screen: the current tab's, or the only one in an app without tabs. */
2274
+ private var screens = mutableListOf<Screen>()
2275
+ private lateinit var routes: CraftNativeRoutes
2276
+ private lateinit var stores: CraftNativeStores
2277
+ private lateinit var services: CraftNativeServices
2278
+ private var chrome: CraftNativeChrome? = null
2279
+ private val tabStacks = mutableListOf<MutableList<Screen>>()
2280
+ private var currentTab = 0
2281
+ private var visibleScreen: Screen? = null
2282
+ /** The app script's isolate (`nativeApp`), when the app has one. */
2283
+ private var appScreen: Screen? = null
2284
+ private val urlSchemes = mutableSetOf<String>()
2285
+ /** Background refreshes the running app script was handed, by id, until it answers. */
2286
+ private val backgroundWaiters = mutableMapOf<String, (Boolean) -> Unit>()
2287
+ /** A tapped notification that opened the app, for the app script once it runs. */
2288
+ private var pendingNotificationTap: JSONObject? = null
2061
2289
  private var bundle = ""
2062
2290
  private lateinit var config: JSONObject
2063
2291
  private lateinit var capabilities: CraftNativeCapabilities
@@ -2072,13 +2300,23 @@ class MainActivity : AppCompatActivity() {
2072
2300
  private var defaultTextColor = Color.WHITE
2073
2301
  private var useMessagePorts = false
2074
2302
  private val imageExecutor = Executors.newFixedThreadPool(2)
2303
+ /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
2304
+ private val preludeExecutor = Executors.newSingleThreadExecutor()
2075
2305
  private val imageSources = mutableMapOf<ImageView, String>()
2076
2306
  private val imageJobs = mutableMapOf<ImageView, Future<*>>()
2077
2307
  private val imageErrors = mutableMapOf<ImageView, String>()
2078
2308
  private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
2309
+ /** Fields that already carry their text watcher. */
2310
+ private val watchedInputs: MutableSet<EditText> = java.util.Collections.newSetFromMap(WeakHashMap())
2079
2311
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
2080
2312
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
2081
2313
  private var keyboardInset = 0
2314
+ private var animationScaleObserverRegistered = false
2315
+ private val animationScaleObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
2316
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
2317
+ publishAppearance()
2318
+ }
2319
+ }
2082
2320
 
2083
2321
  override fun onCreate(savedInstanceState: Bundle?) {
2084
2322
  applyConfiguredAppearance()
@@ -2086,6 +2324,7 @@ class MainActivity : AppCompatActivity() {
2086
2324
  initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
2087
2325
  initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
2088
2326
  deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
2327
+ if (savedInstanceState == null) pendingNotificationTap = notificationTap(intent)
2089
2328
  setContentView(R.layout.activity_main)
2090
2329
  root = findViewById(R.id.native_root)
2091
2330
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
@@ -2107,6 +2346,20 @@ class MainActivity : AppCompatActivity() {
2107
2346
  hapticsEnabled = config.optBoolean("enableHaptics", false)
2108
2347
  clipboardEnabled = config.optBoolean("enableClipboard", false)
2109
2348
  capabilities = CraftNativeCapabilities(this, config)
2349
+ routes = CraftNativeRoutes(config)
2350
+ stores = CraftNativeStores.get(this, config)
2351
+ services = CraftNativeServices(this, config) { this }
2352
+ services.onBackgroundComplete = { ok, id ->
2353
+ if (id == null) {
2354
+ val waiting = backgroundWaiters.values.toList()
2355
+ backgroundWaiters.clear()
2356
+ waiting.forEach { it(ok) }
2357
+ } else backgroundWaiters.remove(id)?.invoke(ok)
2358
+ }
2359
+ capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, null) }
2360
+ val schemes = config.optJSONArray("urlSchemes") ?: JSONArray()
2361
+ for (index in 0 until schemes.length()) urlSchemes.add(schemes.optString(index).lowercase())
2362
+ if (!routes.isEmpty) installChrome()
2110
2363
  refreshThemeDefaults()
2111
2364
  bundle = runCatching {
2112
2365
  assets.open("native-screen.js").bufferedReader().use { it.readText() }
@@ -2118,34 +2371,19 @@ class MainActivity : AppCompatActivity() {
2118
2371
  showError("Native STX requires Android JavaScriptSandbox, which is unavailable on this device.")
2119
2372
  return
2120
2373
  }
2121
- val connection = synchronized(SANDBOX_LOCK) {
2122
- sharedSandboxConnection ?: sharedSandbox?.let { existing ->
2123
- sandbox = existing
2124
- null
2125
- } ?: JavaScriptSandbox.createConnectedInstanceAsync(applicationContext).also {
2126
- sharedSandboxConnection = it
2374
+ // One sandbox for the process, shared with a background refresh
2375
+ // (CraftNativeBackground.kt): an app may hold only one.
2376
+ CraftNativeSandbox.connect(applicationContext) { engine, error ->
2377
+ if (isDestroyed) return@connect
2378
+ if (engine == null) {
2379
+ Log.e(TAG, "JavaScriptSandbox connection failed", error)
2380
+ showError("Native STX JavaScript engine could not start: ${error?.message}")
2381
+ return@connect
2127
2382
  }
2383
+ sandbox = engine
2384
+ attachSandbox(engine)
2128
2385
  }
2129
- if (connection == null) {
2130
- sandbox?.let(::attachSandbox)
2131
- } else {
2132
- connection.addListener({
2133
- try {
2134
- val engine = connection.get()
2135
- synchronized(SANDBOX_LOCK) {
2136
- sharedSandbox = engine
2137
- }
2138
- if (!isDestroyed) {
2139
- sandbox = engine
2140
- attachSandbox(engine)
2141
- }
2142
- } catch (error: Exception) {
2143
- synchronized(SANDBOX_LOCK) { sharedSandboxConnection = null }
2144
- Log.e(TAG, "JavaScriptSandbox connection failed", error)
2145
- if (!isDestroyed) showError("Native STX JavaScript engine could not start: ${error.message}")
2146
- }
2147
- }, ContextCompat.getMainExecutor(this))
2148
- }
2386
+ if (!routes.isEmpty) CraftNativeBackground.schedule(applicationContext, config)
2149
2387
  }
2150
2388
 
2151
2389
  private fun applyConfiguredAppearance() {
@@ -2173,11 +2411,120 @@ class MainActivity : AppCompatActivity() {
2173
2411
  return
2174
2412
  }
2175
2413
  Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
2176
- openScreen(null)
2414
+ startApp()
2415
+ }
2416
+
2417
+ /**
2418
+ * An app with `nativeScreens`: the app script, then the first tab (or
2419
+ * the screen the launch link names). A bundle with no route table opens
2420
+ * its initial screen, as it always did.
2421
+ */
2422
+ private fun startApp() {
2423
+ if (routes.isEmpty) {
2424
+ openScreen(null)
2425
+ return
2426
+ }
2427
+ config.optString("nativeApp").takeIf { it.isNotBlank() }?.let { route ->
2428
+ appScreen = openScreen(route, JSONObject(), headless = true)
2429
+ }
2430
+ val launch = initialDeepLink?.let { routes.appPath(it.toString(), urlSchemes) }
2431
+ if (launch != null) deliveredInitialDeepLink = true
2432
+ selectTab(routes.tabFor(launch ?: routes.initialPath)?.let { routes.tabs.indexOf(it) } ?: 0)
2433
+ if (launch != null && routes.tabFor(launch) == null) navigateOpen(launch, fromLink = true)
2434
+ else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(routes.initialPath)
2435
+ }
2436
+
2437
+ private fun installChrome() {
2438
+ val bars = CraftNativeChrome(this, routes.tabs)
2439
+ bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
2440
+ bars.onBack = { onBackPressedDispatcherCompat() }
2441
+ bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
2442
+ bars.onTab = { index ->
2443
+ haptic()
2444
+ if (index == currentTab) popToRoot() else selectTab(index)
2445
+ }
2446
+ root.addView(bars)
2447
+ chrome = bars
2448
+ tabStacks.clear()
2449
+ repeat(maxOf(1, routes.tabs.size)) { tabStacks.add(mutableListOf()) }
2450
+ screens = tabStacks[0]
2451
+ }
2452
+
2453
+ private fun onBackPressedDispatcherCompat() {
2454
+ @Suppress("DEPRECATION")
2455
+ onBackPressed()
2456
+ }
2457
+
2458
+ private fun haptic() {
2459
+ if (!hapticsEnabled) return
2460
+ root.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
2461
+ }
2462
+
2463
+ /** A tab chosen: its stack shows, its root screen opened the first time. */
2464
+ private fun selectTab(index: Int) {
2465
+ if (index !in tabStacks.indices) return
2466
+ currentTab = index
2467
+ screens = tabStacks[index]
2468
+ chrome?.selectTab(index)
2469
+ if (screens.isEmpty()) {
2470
+ val match = routes.tabs.getOrNull(index)?.let { routes.match(it.id) }
2471
+ if (match != null) openScreen(match.screen, match.params)?.path = match.path else openScreen(null)
2472
+ } else {
2473
+ show(screens.last())
2474
+ }
2475
+ }
2476
+
2477
+ /**
2478
+ * `craft.navigation.open(path)`: the tab whose root it is, the screen
2479
+ * that draws it (pushed on the current tab, or its params changed when
2480
+ * that screen is already in front), or, for a path no screen draws, the
2481
+ * page on the app's site in the in-app browser. Another site's address
2482
+ * opens in the browser too.
2483
+ */
2484
+ private fun navigateOpen(target: String, fromLink: Boolean = false) {
2485
+ val path = routes.appPath(target, urlSchemes)
2486
+ if (path == null) {
2487
+ openExternal(target)
2488
+ return
2489
+ }
2490
+ val match = routes.match(path)
2491
+ val tab = routes.tabFor(path)
2492
+ if (tab != null) {
2493
+ selectTab(routes.tabs.indexOf(tab))
2494
+ popToRoot()
2495
+ val root = screens.firstOrNull()
2496
+ if (root != null && match != null && match.params.length() > 0) {
2497
+ send(root, "ROUTE_PARAMS", JSONObject().put("params", match.params))
2498
+ }
2499
+ return
2500
+ }
2501
+ if (match == null) {
2502
+ routes.siteURL(path)?.let(::openExternal)
2503
+ return
2504
+ }
2505
+ // A link from outside opens under the tab that owns its path.
2506
+ if (fromLink) routes.tabOwning(path)?.let { owner ->
2507
+ val index = routes.tabs.indexOf(owner)
2508
+ if (index != currentTab) selectTab(index)
2509
+ }
2510
+ // Already in this stack (perhaps under others): back to it, with the params it was opened with now.
2511
+ val existing = screens.lastOrNull { it.path == match.path }
2512
+ if (existing != null) {
2513
+ while (screens.lastOrNull() !== existing) closeScreen(screens.removeAt(screens.lastIndex))
2514
+ show(existing)
2515
+ send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params))
2516
+ return
2517
+ }
2518
+ openScreen(match.screen, match.params)?.path = match.path
2519
+ }
2520
+
2521
+ /** A page the app does not draw, in a Custom Tab over the app. */
2522
+ private fun openExternal(url: String) {
2523
+ services.perform("Browser", "open", JSONArray().put(url), {})
2177
2524
  }
2178
2525
 
2179
- private fun openScreen(route: String?, params: JSONObject = JSONObject()) {
2180
- val engine = sandbox ?: return
2526
+ private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false): Screen? {
2527
+ val engine = sandbox ?: return null
2181
2528
  val isolate = engine.createIsolate()
2182
2529
  val view = CraftNativeFlexLayout(this).apply {
2183
2530
  orientation = LinearLayout.VERTICAL
@@ -2196,18 +2543,25 @@ class MainActivity : AppCompatActivity() {
2196
2543
  isolate.setConsoleCallback(executor, JavaScriptConsoleCallback { entry ->
2197
2544
  val current = screen ?: return@JavaScriptConsoleCallback
2198
2545
  if (current.active && entry.message.startsWith(prefix)) {
2199
- receiveSafely(current, entry.message.removePrefix(prefix))
2546
+ current.chunks.accept(entry.message.removePrefix(prefix))?.let { receiveSafely(current, it) }
2547
+ } else if (current.active) {
2548
+ Log.i(TAG, "[${current.route ?: "home"}] ${entry.message}")
2200
2549
  }
2201
2550
  })
2202
2551
  null
2203
2552
  }
2204
- val activeScreen = Screen(route, params, view, isolate, port)
2553
+ val activeScreen = Screen(route, params, view, isolate, port, headless)
2205
2554
  activeScreen.view.onLayoutChanged = {
2206
2555
  if (activeScreen.active) emitLayoutEvents(activeScreen)
2207
2556
  }
2208
2557
  screen = activeScreen
2209
- screens.add(activeScreen)
2210
- show(activeScreen)
2558
+ CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
2559
+ if (activeScreen.active) send(activeScreen, type, payload)
2560
+ }
2561
+ if (!headless) {
2562
+ screens.add(activeScreen)
2563
+ show(activeScreen)
2564
+ }
2211
2565
  val routePrelude = """
2212
2566
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
2213
2567
  globalThis.__stxNativeParams = ${params};
@@ -2286,41 +2640,42 @@ class MainActivity : AppCompatActivity() {
2286
2640
  }
2287
2641
  })()
2288
2642
  """.trimIndent()
2289
- val prelude = if (useMessagePorts) """
2290
- (async function() {
2291
- const port = await android.getNamedPort('craft');
2292
- $routePrelude
2293
- globalThis.__stxNativeBridge = {
2294
- platform: 'android',
2295
- mutationProtocolVersion: 1,
2296
- capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
2297
- capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
2298
- capabilities: ${capabilities.names()},
2299
- initialAppState: ${JSONObject.quote(nativeAppState)},
2300
- colorScheme: ${JSONObject.quote(colorScheme())},
2301
- appearance: ${appearance()},
2302
- postMessage: function(value) { port.postMessage(value); },
2303
- onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
2304
- };
2305
- $audioPrelude
2306
- })()
2307
- """.trimIndent() else """
2308
- $routePrelude
2309
- globalThis.__stxNativeBridge = {
2310
- platform: 'android',
2311
- mutationProtocolVersion: 1,
2312
- capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
2313
- capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
2314
- capabilities: ${capabilities.names()},
2315
- initialAppState: ${JSONObject.quote(nativeAppState)},
2316
- colorScheme: ${JSONObject.quote(colorScheme())},
2317
- appearance: ${appearance()},
2318
- postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
2319
- onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
2320
- };
2321
- $audioPrelude
2643
+ val bridgeFields = """
2644
+ platform: 'android',
2645
+ mutationProtocolVersion: 1,
2646
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
2647
+ capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
2648
+ capabilities: ${capabilityNames()},
2649
+ initialAppState: ${JSONObject.quote(nativeAppState)},
2650
+ colorScheme: ${JSONObject.quote(colorScheme())},
2651
+ appearance: ${appearance()},
2652
+ appURL: ${JSONObject.quote(config.optString("devServerURL"))},
2653
+ appVersion: ${JSONObject.quote(appVersion().first)},
2654
+ appBuild: ${JSONObject.quote(appVersion().second)}
2322
2655
  """.trimIndent()
2323
- val setup = isolate.evaluateJavaScriptAsync(prelude)
2656
+ // The stores can run to megabytes: read and laid out off the main
2657
+ // thread, then handed to the isolate.
2658
+ val preludeReady = com.google.common.util.concurrent.SettableFuture.create<String>()
2659
+ preludeExecutor.execute {
2660
+ preludeReady.set(runCatching {
2661
+ val preloaded = if (routes.isEmpty) "{}" else stores.preloadText()
2662
+ if (useMessagePorts) """
2663
+ (async function() {
2664
+ const port = await android.getNamedPort('craft');
2665
+ globalThis.__craftPort = port;
2666
+ __CRAFT_PRELUDE__
2667
+ $audioPrelude
2668
+ })()
2669
+ """.trimIndent().replace("__CRAFT_PRELUDE__", CraftNativePrelude.script(bridgeFields, "globalThis.__craftPort.postMessage(value);", "globalThis.__craftPort.onmessage = function(event) { deliver(event.data); };", preloaded, routePrelude))
2670
+ else CraftNativePrelude.script(bridgeFields, CraftNativeChunks.consolePost(prefix), "globalThis.__stxNativeCallback = deliver;", preloaded, routePrelude) + "\n" + audioPrelude
2671
+ }.getOrElse { error ->
2672
+ Log.e(TAG, "Native STX prelude failed", error)
2673
+ ""
2674
+ })
2675
+ }
2676
+ val setup = com.google.common.util.concurrent.Futures.transformAsync(preludeReady, { prelude ->
2677
+ isolate.evaluateJavaScriptAsync(prelude)
2678
+ }, executor)
2324
2679
  setup.addListener({
2325
2680
  if (!activeScreen.active) return@addListener
2326
2681
  try {
@@ -2329,6 +2684,21 @@ class MainActivity : AppCompatActivity() {
2329
2684
  load.addListener({
2330
2685
  try {
2331
2686
  load.get()
2687
+ if (activeScreen.headless) {
2688
+ CraftNativeBackground.foreground = { event, id, done ->
2689
+ val app = appScreen
2690
+ if (app == null || !app.active) false
2691
+ else {
2692
+ backgroundWaiters[id] = done
2693
+ send(app, "CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject().put("id", id)))
2694
+ true
2695
+ }
2696
+ }
2697
+ pendingNotificationTap?.let {
2698
+ pendingNotificationTap = null
2699
+ send(activeScreen, "CRAFT_EVENT", JSONObject().put("event", "craftNotificationResponse").put("detail", it))
2700
+ }
2701
+ }
2332
2702
  val launchLink = initialDeepLink
2333
2703
  if (!deliveredInitialDeepLink
2334
2704
  && config.optBoolean("enableDeepLinks")
@@ -2348,15 +2718,96 @@ class MainActivity : AppCompatActivity() {
2348
2718
  showError("Native STX bridge failed: ${error.message}")
2349
2719
  }
2350
2720
  }, executor)
2721
+ return activeScreen
2351
2722
  }
2352
2723
 
2724
+ /**
2725
+ * Puts a screen on the display: in the bars' content area when the app
2726
+ * has them. The screen it replaces hears `SCREEN_FOCUS` false, and a
2727
+ * screen shown before hears true (its `navigation.onAppear`).
2728
+ */
2353
2729
  private fun show(screen: Screen) {
2354
- root.removeAllViews()
2730
+ val bars = chrome
2731
+ val host: ViewGroup = bars?.content ?: root
2732
+ host.removeAllViews()
2355
2733
  (screen.view.parent as? ViewGroup)?.removeView(screen.view)
2356
- root.addView(screen.view)
2734
+ host.addView(screen.view)
2735
+ val previous = visibleScreen
2736
+ visibleScreen = screen
2737
+ if (screen.frameRequested) postFrame(screen)
2738
+ if (bars != null) applyChrome(screen)
2739
+ if (previous !== screen) {
2740
+ if (previous != null && previous.active) send(previous, "SCREEN_FOCUS", JSONObject().put("focused", false))
2741
+ if (screen.shown) send(screen, "SCREEN_FOCUS", JSONObject().put("focused", true))
2742
+ screen.shown = true
2743
+ }
2357
2744
  refreshScreenTheme(screen)
2358
2745
  }
2359
2746
 
2747
+ /**
2748
+ * `requestAnimationFrame`: the next vsync for the screen on display. A
2749
+ * screen out of sight (another tab, under a pushed one) waits until it
2750
+ * is shown again, as a browser pauses a hidden tab's frames.
2751
+ */
2752
+ private fun requestFrame(screen: Screen) {
2753
+ if (screen.frameRequested) return
2754
+ screen.frameRequested = true
2755
+ if (visibleScreen === screen || screen.headless) postFrame(screen)
2756
+ }
2757
+
2758
+ private fun postFrame(screen: Screen) {
2759
+ android.view.Choreographer.getInstance().postFrameCallback { frameTime ->
2760
+ if (!screen.active || !screen.frameRequested) return@postFrameCallback
2761
+ if (visibleScreen !== screen && !screen.headless) return@postFrameCallback
2762
+ screen.frameRequested = false
2763
+ send(screen, "FRAME_FIRE", JSONObject().put("time", frameTime / 1_000_000.0))
2764
+ }
2765
+ }
2766
+
2767
+ private fun applyChrome(screen: Screen) {
2768
+ val bars = chrome ?: return
2769
+ val depth = screens.indexOf(screen).coerceAtLeast(0)
2770
+ val fallback = if (depth == 0) routes.tabs.getOrNull(currentTab)?.title else null
2771
+ bars.apply(screen.options, depth, fallback)
2772
+ }
2773
+
2774
+ /** The bridge's capability names: Craft's core set and this host's services. */
2775
+ private fun capabilityNames(): JSONArray = capabilities.names().apply {
2776
+ services.names().forEach { name -> put(name) }
2777
+ }
2778
+
2779
+ private fun appVersion(): Pair<String, String> = runCatching {
2780
+ val info = packageManager.getPackageInfo(packageName, 0)
2781
+ val build = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else @Suppress("DEPRECATION") info.versionCode.toString()
2782
+ (info.versionName ?: "") to build
2783
+ }.getOrDefault("" to "")
2784
+
2785
+ /** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
2786
+ private fun syncWrite(screen: Screen, payload: JSONObject) {
2787
+ val id = payload.optString("id").takeIf { it.isNotBlank() }
2788
+ val reply: (Boolean) -> Unit = { ok ->
2789
+ if (id != null && screen.active) send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", ok))
2790
+ }
2791
+ val key = payload.optString("key")
2792
+ val value = if (payload.isNull("value")) null else payload.optString("value")
2793
+ when (payload.optString("store")) {
2794
+ "storage" -> if (key.isNotBlank()) stores.setStorage(key, value, from = screen.capabilityScope)
2795
+ "snapshots" -> if (payload.optBoolean("clear")) {
2796
+ val keep = payload.optJSONArray("keep") ?: JSONArray()
2797
+ stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
2798
+ } else if (key.isNotBlank()) {
2799
+ stores.setSnapshot(key, value, from = screen.capabilityScope, done = reply)
2800
+ } else reply(false)
2801
+ else -> reply(false)
2802
+ }
2803
+ }
2804
+
2805
+ /** Every live isolate: each tab's stack and the app script. */
2806
+ private fun allScreens(): List<Screen> = buildList {
2807
+ if (tabStacks.isEmpty()) addAll(screens) else tabStacks.forEach(::addAll)
2808
+ appScreen?.let(::add)
2809
+ }
2810
+
2360
2811
  override fun onSaveInstanceState(outState: Bundle) {
2361
2812
  outState.putString("craft.initialDeepLink", initialDeepLink?.toString())
2362
2813
  outState.putBoolean("craft.initialDeepLinkClaimed", initialDeepLinkClaimed)
@@ -2374,9 +2825,13 @@ class MainActivity : AppCompatActivity() {
2374
2825
  refreshThemeDefaults()
2375
2826
  ViewCompat.requestApplyInsets(root)
2376
2827
  refreshInputDefaultsForTraitChange()
2377
- screens.lastOrNull()?.let { screen ->
2378
- refreshScreenTheme(screen)
2379
- if (screen.active) send(screen, "APPEARANCE", appearance())
2828
+ chrome?.refreshColors()
2829
+ // Every screen follows light and dark, the ones in other tabs and
2830
+ // under the one on display too, and the app script hears it.
2831
+ val appearance = appearance()
2832
+ allScreens().forEach { screen ->
2833
+ if (!screen.headless) refreshScreenTheme(screen)
2834
+ if (screen.active) send(screen, "APPEARANCE", appearance)
2380
2835
  }
2381
2836
  }
2382
2837
 
@@ -2430,9 +2885,16 @@ class MainActivity : AppCompatActivity() {
2430
2885
  }
2431
2886
 
2432
2887
  private fun receive(screen: Screen, raw: String) {
2433
- val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
2888
+ val message = runCatching { JSONObject(raw) }.getOrElse { error ->
2889
+ Log.w(TAG, "A message from ${screen.route ?: "home"} is not JSON (${raw.length} chars): ${error.message}; it starts ${raw.take(120)}")
2890
+ return
2891
+ }
2434
2892
  val payload = message.optJSONObject("payload") ?: JSONObject()
2435
- Log.d(TAG, "Native STX ${message.optString("type")} (${raw.length} chars) for ${screen.route ?: "home"}")
2893
+ val type = message.optString("type")
2894
+ // Timers and frames come many times a second: not worth a log line each.
2895
+ if (type != "TIMER" && type != "TIMER_CLEAR" && type != "FRAME" && Log.isLoggable(TAG, Log.DEBUG)) {
2896
+ Log.d(TAG, "Native STX $type (${raw.length} chars) for ${screen.route ?: "home"}")
2897
+ }
2436
2898
  when (message.optString("type")) {
2437
2899
  "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
2438
2900
  "MUTATE" -> try {
@@ -2458,16 +2920,34 @@ class MainActivity : AppCompatActivity() {
2458
2920
  .put("message", "mutation batch could not be applied")
2459
2921
  .put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
2460
2922
  }
2923
+ "NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let(::navigateOpen)
2924
+ "NAVIGATION_SET_OPTIONS" -> {
2925
+ payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
2926
+ if (visibleScreen === screen) applyChrome(screen)
2927
+ }
2928
+ "SYNC_WRITE" -> syncWrite(screen, payload)
2929
+ "TIMER" -> {
2930
+ val id = payload.optInt("id")
2931
+ screen.timers.remove(id)?.let(capabilityHandler::removeCallbacks)
2932
+ val fire = Runnable {
2933
+ screen.timers.remove(id)
2934
+ if (screen.active) send(screen, "TIMER_FIRE", JSONObject().put("id", id))
2935
+ }
2936
+ screen.timers[id] = fire
2937
+ capabilityHandler.postDelayed(fire, payload.optLong("ms", 0).coerceAtLeast(0))
2938
+ }
2939
+ "TIMER_CLEAR" -> screen.timers.remove(payload.optInt("id"))?.let(capabilityHandler::removeCallbacks)
2940
+ "FRAME" -> requestFrame(screen)
2461
2941
  "NAVIGATE", "NAVIGATE_REPLACE" -> {
2462
- if (screens.lastOrNull() !== screen) return
2942
+ if (screens.lastOrNull() !== screen && screen !== appScreen) return
2463
2943
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
2464
2944
  if (message.optString("type") == "NAVIGATE_REPLACE") {
2465
2945
  closeScreen(screens.removeAt(screens.lastIndex))
2466
2946
  }
2467
2947
  openScreen(route, payload.optJSONObject("params") ?: JSONObject())
2468
2948
  }
2469
- "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen) back()
2470
- "NAVIGATE_POP_TO_ROOT" -> if (screens.lastOrNull() === screen) popToRoot()
2949
+ "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen || screen === appScreen) back()
2950
+ "NAVIGATE_POP_TO_ROOT" -> if (screens.lastOrNull() === screen || screen === appScreen) popToRoot()
2471
2951
  "API_REQUEST" -> answerAPI(screen, message, payload)
2472
2952
  "API_CANCEL" -> {
2473
2953
  val id = payload.optString("requestId")
@@ -2660,7 +3140,17 @@ class MainActivity : AppCompatActivity() {
2660
3140
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
2661
3141
  }
2662
3142
  }
2663
- "Button" -> (previous as? Button ?: Button(this)).apply {
3143
+ "Button" -> (previous as? Button ?: Button(this).apply {
3144
+ // A plain button, as on iOS (UIButton) and the web: its
3145
+ // style draws any fill, not Material's grey raised box.
3146
+ background = null
3147
+ stateListAnimator = null
3148
+ minWidth = 0
3149
+ minHeight = 0
3150
+ minimumWidth = 0
3151
+ minimumHeight = 0
3152
+ setPadding(0, 0, 0, 0)
3153
+ }).apply {
2664
3154
  val title = props.optString("title").ifBlank { childText(children) }
2665
3155
  isAllCaps = false
2666
3156
  configureText(this, title, style)
@@ -2701,6 +3191,13 @@ class MainActivity : AppCompatActivity() {
2701
3191
  }
2702
3192
  "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
2703
3193
  ?: EditText(this)).apply {
3194
+ // Setting filters, the input type or single-line mode makes
3195
+ // TextView set its text again, which puts the caret back at
3196
+ // the start: typing then came out backwards. Each is set only
3197
+ // when it changes, and the caret is put back after.
3198
+ val keptText = text.toString()
3199
+ val keptStart = selectionStart
3200
+ val keptEnd = selectionEnd
2704
3201
  val defaultHintColors = inputHintDefaults.getOrPut(this) {
2705
3202
  hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
2706
3203
  }
@@ -2715,12 +3212,18 @@ class MainActivity : AppCompatActivity() {
2715
3212
  ?.let { color(it, defaultHighlightColor) }
2716
3213
  ?: defaultHighlightColor
2717
3214
  val maxLength = props.optInt("maxLength", 0)
2718
- filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
2719
- inputType = inputTypeFor(props)
2720
- imeOptions = imeOptionsFor(props)
3215
+ val currentLength = (filters.firstOrNull { it is InputFilter.LengthFilter } as? InputFilter.LengthFilter)?.max ?: 0
3216
+ if (currentLength != maxLength || (maxLength == 0 && filters.isNotEmpty())) {
3217
+ filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
3218
+ }
3219
+ val wantedType = inputTypeFor(props)
3220
+ if (inputType != wantedType) inputType = wantedType
3221
+ val wantedIme = imeOptionsFor(props)
3222
+ if (imeOptions != wantedIme) imeOptions = wantedIme
2721
3223
  val multiline = props.optBoolean("multiline", false)
2722
- isSingleLine = !multiline
2723
- maxLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
3224
+ if (isSingleLine == multiline) isSingleLine = !multiline
3225
+ val wantedLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
3226
+ if (maxLines != wantedLines) maxLines = wantedLines
2724
3227
  isEnabled = props.optBoolean("editable", true)
2725
3228
  configureTextStyle(this, style)
2726
3229
  // The style's padding is where the text starts, as on iOS and
@@ -2744,7 +3247,10 @@ class MainActivity : AppCompatActivity() {
2744
3247
  val autofillHint = autofillHintFor(props)
2745
3248
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
2746
3249
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
2747
- if (controlledValue != null && text.toString() != controlledValue) {
3250
+ val echoes = screen.inputEchoes[this]
3251
+ val echoIndex = if (controlledValue == null || echoes == null) -1 else echoes.indexOf(controlledValue)
3252
+ if (echoIndex >= 0) repeat(echoIndex + 1) { echoes!!.removeFirst() }
3253
+ if (controlledValue != null && text.toString() != controlledValue && echoIndex < 0) {
2748
3254
  screen.suppressedTextChanges.add(this)
2749
3255
  setTextPreservingSelection(this, controlledValue)
2750
3256
  }
@@ -2791,13 +3297,20 @@ class MainActivity : AppCompatActivity() {
2791
3297
  ?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
2792
3298
  }
2793
3299
  }
2794
- if (this !== previous) addTextChangedListener(object : TextWatcher {
3300
+ if (text.toString() == keptText && keptStart >= 0 && (selectionStart != keptStart || selectionEnd != keptEnd)) {
3301
+ setSelection(keptStart.coerceAtMost(keptText.length), keptEnd.coerceAtMost(keptText.length))
3302
+ }
3303
+ // One watcher per field, however often it is reused.
3304
+ if (watchedInputs.add(this)) addTextChangedListener(object : TextWatcher {
2795
3305
  override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
2796
3306
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
2797
3307
  val updated = s?.toString().orEmpty()
2798
- screen.drafts[identity] = updated
3308
+ (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
2799
3309
  if (screen.suppressedTextChanges.remove(this@apply)) return
2800
3310
  val current = screen.handlers[this@apply] ?: return
3311
+ val echoes = screen.inputEchoes.getOrPut(this@apply) { ArrayDeque() }
3312
+ echoes.addLast(updated)
3313
+ while (echoes.size > 64) echoes.removeFirst()
2801
3314
  send(screen, "EVENT", JSONObject()
2802
3315
  .put("handlerName", current)
2803
3316
  .put("nativeEvent", JSONObject().put("text", updated)))
@@ -3219,9 +3732,12 @@ class MainActivity : AppCompatActivity() {
3219
3732
  }
3220
3733
  } else {
3221
3734
  screen.handlers.remove(control)
3222
- control.isClickable = false
3735
+ // Clearing a listener makes a View clickable again, so the
3736
+ // flag goes last: a clickable Text inside a pressable row
3737
+ // kept every tap from reaching the row.
3223
3738
  control.setOnClickListener(null)
3224
3739
  control.setOnTouchListener(null)
3740
+ control.isClickable = false
3225
3741
  }
3226
3742
  control.isEnabled = !disabled
3227
3743
  }
@@ -3238,6 +3754,7 @@ class MainActivity : AppCompatActivity() {
3238
3754
  } else {
3239
3755
  screen.longPressHandlers.remove(control)
3240
3756
  control.setOnLongClickListener(null)
3757
+ control.isLongClickable = false
3241
3758
  }
3242
3759
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
3243
3760
  val layoutHandler = events.optString("onLayout")
@@ -3478,6 +3995,24 @@ class MainActivity : AppCompatActivity() {
3478
3995
 
3479
3996
  private fun applyViewStyle(view: View, style: JSONObject) {
3480
3997
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
3998
+ // A Text's padding (`px-4` on a line of text) is where its text
3999
+ // starts, as on iOS; containers and fields lay out their own.
4000
+ if (view is TextView && view !is EditText && view !is CraftNativeIconView) {
4001
+ val keys = listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom")
4002
+ if (keys.any(style::has)) {
4003
+ val all = style.optDouble("padding", 0.0)
4004
+ val horizontal = style.optDouble("paddingHorizontal", all)
4005
+ val vertical = style.optDouble("paddingVertical", all)
4006
+ view.setPadding(
4007
+ dp(style.optDouble("paddingLeft", horizontal)),
4008
+ dp(style.optDouble("paddingTop", vertical)),
4009
+ dp(style.optDouble("paddingRight", horizontal)),
4010
+ dp(style.optDouble("paddingBottom", vertical)),
4011
+ )
4012
+ } else if (view.javaClass == TextView::class.java) {
4013
+ view.setPadding(0, 0, 0, 0)
4014
+ }
4015
+ }
3481
4016
  applyTransform(view, style.optJSONArray("transform"))
3482
4017
  if (view !is CraftNativeModalView) {
3483
4018
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
@@ -3526,7 +4061,11 @@ class MainActivity : AppCompatActivity() {
3526
4061
  }
3527
4062
  }
3528
4063
  }
3529
- } else view.background = defaultBackground
4064
+ } else view.background = if (view is EditText && style.has("borderWidth")) {
4065
+ // `border-0` on a field: no Material underline either, as on iOS
4066
+ // and the web.
4067
+ null
4068
+ } else defaultBackground
3530
4069
  view.clipToOutline = style.optString("overflow") == "hidden"
3531
4070
  if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
3532
4071
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
@@ -3625,11 +4164,16 @@ class MainActivity : AppCompatActivity() {
3625
4164
 
3626
4165
  private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
3627
4166
  val symbol = props.optString("symbol").ifBlank { props.optString("name") }
3628
- view.text = androidIconGlyph(symbol)
3629
4167
  val size = style.optDouble("fontSize", 20.0).toFloat().coerceAtLeast(1f)
3630
4168
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
3631
4169
  val foreground = style.optString("color").ifBlank { props.optString("color") }
3632
- view.setTextColor(color(foreground, defaultTextColor))
4170
+ val tint = color(foreground, defaultTextColor)
4171
+ view.setTextColor(tint)
4172
+ // The app's own vector for it (craft-icons.json, the icon the web
4173
+ // shows), else the closest glyph Android's fonts have.
4174
+ val vector = CraftNativeIcons.drawable(this, props.optString("name"), props.optString("symbol"), tint)
4175
+ view.vector = vector
4176
+ view.text = if (vector != null) "" else androidIconGlyph(symbol)
3633
4177
  val weight = when (style.optString("fontWeight")) {
3634
4178
  "bold", "700", "800", "900" -> Typeface.BOLD
3635
4179
  else -> Typeface.NORMAL
@@ -3916,7 +4460,7 @@ class MainActivity : AppCompatActivity() {
3916
4460
  }
3917
4461
 
3918
4462
  private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
3919
- private fun color(raw: String, fallback: Int) = runCatching { Color.parseColor(raw) }.getOrDefault(fallback)
4463
+ private fun color(raw: String, fallback: Int) = runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
3920
4464
 
3921
4465
  private fun inputTypeFor(props: JSONObject): Int {
3922
4466
  val base = if (props.optBoolean("multiline", false)) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
@@ -4006,15 +4550,28 @@ class MainActivity : AppCompatActivity() {
4006
4550
  .put("code", "TIMEOUT")
4007
4551
  .put("message", "Native API request timed out"), id)
4008
4552
  }
4009
- screen.capabilityTimeouts[token] = timeout
4010
- capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
4011
- capabilities.perform(
4012
- token,
4013
- payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
4014
- payload.optString("module"),
4015
- payload.optString("method"),
4016
- args,
4017
- ) { result ->
4553
+ // A person answers some calls (a permission sheet, a dialog): those
4554
+ // are never timed out (`untimed`, the bridge's callPersonFacingAPI).
4555
+ if (!payload.optBoolean("untimed", false)) {
4556
+ screen.capabilityTimeouts[token] = timeout
4557
+ capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
4558
+ }
4559
+ val module = payload.optString("module")
4560
+ val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (services.handles(module)) {
4561
+ { completion -> services.perform(module, payload.optString("method"), args, completion) }
4562
+ } else {
4563
+ { completion ->
4564
+ capabilities.perform(
4565
+ token,
4566
+ payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
4567
+ module,
4568
+ payload.optString("method"),
4569
+ args,
4570
+ completion,
4571
+ )
4572
+ }
4573
+ }
4574
+ perform { result ->
4018
4575
  if (!screen.active || !screen.pendingCapabilities.remove(token)) return@perform
4019
4576
  screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
4020
4577
  val response = JSONObject()
@@ -4074,8 +4631,9 @@ class MainActivity : AppCompatActivity() {
4074
4631
  uri.queryParameterNames.forEach { name -> put(name, uri.getQueryParameter(name) ?: "") }
4075
4632
  })
4076
4633
 
4634
+ /** App-wide news (the app's state, a link): every screen and the app script hear it. */
4077
4635
  private fun publish(type: String, payload: JSONObject) {
4078
- screens.lastOrNull()?.takeIf { it.active }?.let { send(it, type, payload) }
4636
+ allScreens().filter { it.active }.forEach { send(it, type, payload) }
4079
4637
  }
4080
4638
 
4081
4639
  override fun onStart() {
@@ -4108,7 +4666,43 @@ class MainActivity : AppCompatActivity() {
4108
4666
  override fun onNewIntent(intent: Intent) {
4109
4667
  super.onNewIntent(intent)
4110
4668
  setIntent(intent)
4111
- intent.data?.let { publish("DEEP_LINK", deepLinkData(it).put("initial", false)) }
4669
+ notificationTap(intent)?.let { tap ->
4670
+ val app = appScreen
4671
+ if (app != null && app.active) send(app, "CRAFT_EVENT", JSONObject().put("event", "craftNotificationResponse").put("detail", tap))
4672
+ else pendingNotificationTap = tap
4673
+ }
4674
+ intent.data?.let { link ->
4675
+ if (::services.isInitialized && services.deepLink(link)) return
4676
+ publish("DEEP_LINK", deepLinkData(link).put("initial", false))
4677
+ if (::routes.isInitialized && !routes.isEmpty) routes.appPath(link.toString(), urlSchemes)?.let { navigateOpen(it, fromLink = true) }
4678
+ }
4679
+ }
4680
+
4681
+ /**
4682
+ * The data of a push that was tapped (Firebase puts it in the launch
4683
+ * intent's extras, `google.message_id` among them), in the shape the iOS
4684
+ * host gives `craftNotificationResponse`: the payload's own keys.
4685
+ */
4686
+ private fun notificationTap(intent: Intent?): JSONObject? {
4687
+ val extras = intent?.extras ?: return null
4688
+ if (!extras.containsKey("google.message_id") && !extras.containsKey("craft.notification")) return null
4689
+ val detail = JSONObject()
4690
+ extras.keySet().forEach { key ->
4691
+ if (key.startsWith("google.") || key.startsWith("gcm.") || key == "from" || key == "collapse_key" || key == "craft.notification") return@forEach
4692
+ val value = extras.get(key)
4693
+ if (value is String || value is Number || value is Boolean) detail.put(key, value)
4694
+ }
4695
+ return detail
4696
+ }
4697
+
4698
+ override fun onResume() {
4699
+ super.onResume()
4700
+ if (::services.isInitialized) services.resumed()
4701
+ }
4702
+
4703
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
4704
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
4705
+ if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
4112
4706
  }
4113
4707
 
4114
4708
  internal fun consumeInitialDeepLink(): Uri? {
@@ -4174,15 +4768,18 @@ class MainActivity : AppCompatActivity() {
4174
4768
  /** Back to the first screen in one move. */
4175
4769
  private fun popToRoot() {
4176
4770
  while (screens.size > 1) closeScreen(screens.removeAt(screens.lastIndex))
4177
- show(screens.last())
4771
+ screens.lastOrNull()?.let(::show)
4178
4772
  }
4179
4773
 
4774
+ /** Back: the screen below in this tab, then the first tab, then out of the app. */
4180
4775
  private fun back() {
4181
4776
  if (screens.size > 1) {
4182
4777
  closeScreen(screens.removeAt(screens.lastIndex))
4183
4778
  show(screens.last())
4779
+ } else if (chrome != null && currentTab != 0) {
4780
+ selectTab(0)
4184
4781
  } else {
4185
- finish()
4782
+ moveTaskToBack(true)
4186
4783
  }
4187
4784
  }
4188
4785
 
@@ -4245,6 +4842,7 @@ class MainActivity : AppCompatActivity() {
4245
4842
  inputHintDefaults.remove(it)
4246
4843
  inputHighlightDefaults.remove(it)
4247
4844
  screen.suppressedTextChanges.remove(it)
4845
+ screen.inputEchoes.remove(it)
4248
4846
  val identity = screen.inputIdentities.remove(it)
4249
4847
  if (!preservingInputDrafts) identity?.let(screen.drafts::remove)
4250
4848
  }
@@ -4281,6 +4879,10 @@ class MainActivity : AppCompatActivity() {
4281
4879
  }
4282
4880
 
4283
4881
  private fun closeScreen(screen: Screen) {
4882
+ CraftNativeHub.unregister(screen.capabilityScope)
4883
+ screen.timers.values.forEach(capabilityHandler::removeCallbacks)
4884
+ screen.timers.clear()
4885
+ if (visibleScreen === screen) visibleScreen = null
4284
4886
  screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)
4285
4887
  screen.capabilityTimeouts.clear()
4286
4888
  screen.pendingCapabilities.forEach(capabilities::cancel)
@@ -4328,6 +4930,7 @@ class MainActivity : AppCompatActivity() {
4328
4930
 
4329
4931
  override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
4330
4932
  if (::capabilities.isInitialized && capabilities.onActivityResult(requestCode, resultCode, data)) return
4933
+ if (::services.isInitialized && services.onActivityResult(requestCode, resultCode, data)) return
4331
4934
  super.onActivityResult(requestCode, resultCode, data)
4332
4935
  }
4333
4936
 
@@ -4344,8 +4947,14 @@ class MainActivity : AppCompatActivity() {
4344
4947
 
4345
4948
  override fun onDestroy() {
4346
4949
  if (animationScaleObserverRegistered) contentResolver.unregisterContentObserver(animationScaleObserver)
4347
- screens.forEach { closeScreen(it) }
4950
+ CraftNativeBackground.foreground = null
4951
+ backgroundWaiters.values.forEach { it(false) }
4952
+ backgroundWaiters.clear()
4953
+ allScreens().forEach { closeScreen(it) }
4348
4954
  screens.clear()
4955
+ tabStacks.clear()
4956
+ appScreen = null
4957
+ if (::services.isInitialized) services.close()
4349
4958
  imageJobs.values.forEach { it.cancel(true) }
4350
4959
  imageJobs.clear()
4351
4960
  imageExecutor.shutdownNow()
@@ -4355,8 +4964,5 @@ class MainActivity : AppCompatActivity() {
4355
4964
 
4356
4965
  companion object {
4357
4966
  private const val TAG = "CraftNativeAndroid"
4358
- private val SANDBOX_LOCK = Any()
4359
- private var sharedSandbox: JavaScriptSandbox? = null
4360
- private var sharedSandboxConnection: ListenableFuture<JavaScriptSandbox>? = null
4361
4967
  }
4362
4968
  }