craft-native 0.0.168 → 0.0.170
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 +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1028 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +15 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
666
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2210
|
-
|
|
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
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
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) }.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
token
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|