craft-native 0.0.201 → 0.0.203

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.
@@ -2020,6 +2020,9 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2020
2020
 
2021
2021
  private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
2022
2022
  val content = CraftNativeFlexLayout(context)
2023
+ // Keep one refresh target across axis changes. Replacing all children of
2024
+ // SwipeRefreshLayout would also detach its own animated indicator.
2025
+ private val scrollContainer = FrameLayout(context)
2023
2026
  private val vertical = ScrollView(context)
2024
2027
  private val horizontal = HorizontalScrollView(context)
2025
2028
  private var horizontalMode: Boolean? = null
@@ -2029,12 +2032,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2029
2032
  private var alwaysBounceHorizontal = true
2030
2033
  private var keyboardShouldPersistTaps = "never"
2031
2034
  private var refreshHandler: (() -> Unit)? = null
2035
+ private var authoredRefreshing: Boolean? = null
2036
+ private val defaultProgressStart = progressViewStartOffset
2037
+ private val defaultProgressEnd = progressViewEndOffset
2038
+ private var configuredProgressOffset: Int? = null
2032
2039
 
2033
2040
  init {
2034
2041
  vertical.isNestedScrollingEnabled = true
2035
2042
  horizontal.isNestedScrollingEnabled = true
2036
2043
  vertical.isFillViewport = true
2037
2044
  horizontal.isFillViewport = true
2045
+ addView(scrollContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
2046
+ setOnChildScrollUpCallback { _, _ -> horizontalMode != true && vertical.canScrollVertically(-1) }
2038
2047
  setOnRefreshListener { refreshHandler?.invoke() }
2039
2048
  // The outer refresh host must stay touch-enabled so its real scroll
2040
2049
  // child can receive gestures even when no pull-to-refresh handler is
@@ -2103,8 +2112,13 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2103
2112
  fun setRefreshHandler(handler: (() -> Unit)?, refreshing: Boolean) {
2104
2113
  refreshHandler = handler
2105
2114
  isEnabled = true
2106
- isRefreshing = handler != null && refreshing
2107
- if (handler == null) isRefreshing = false
2115
+ val requested = handler != null && refreshing
2116
+ // An unchanged false prop must not cancel a native pull before its
2117
+ // onRefresh callback has reached the script.
2118
+ if (handler == null || authoredRefreshing != requested) {
2119
+ authoredRefreshing = requested
2120
+ isRefreshing = requested
2121
+ }
2108
2122
  }
2109
2123
 
2110
2124
  fun setRefreshAppearance(props: JSONObject, density: Float) {
@@ -2115,21 +2129,28 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2115
2129
  if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
2116
2130
  else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
2117
2131
  val offset = props.optDouble("progressViewOffset", Double.NaN)
2118
- if (offset.isFinite()) setProgressViewOffset(true, 0, (offset * density).roundToInt())
2119
- else setProgressViewOffset(false, 0, 0)
2132
+ val requested = if (offset.isFinite()) (offset * density).roundToInt() else null
2133
+ if (configuredProgressOffset != requested) {
2134
+ val wasRefreshing = isRefreshing
2135
+ val shift = requested ?: 0
2136
+ setProgressViewOffset(false, defaultProgressStart + shift, defaultProgressEnd + shift)
2137
+ configuredProgressOffset = requested
2138
+ if (wasRefreshing) isRefreshing = true
2139
+ }
2120
2140
  }
2121
2141
 
2122
- override fun onDetachedFromWindow() {
2123
- refreshHandler = null
2124
- isRefreshing = false
2125
- super.onDetachedFromWindow()
2142
+ override fun onAttachedToWindow() {
2143
+ super.onAttachedToWindow()
2144
+ // Navigation may detach and reattach a retained screen. Its handler
2145
+ // belongs to the screen; releaseViewState clears it on disposal.
2146
+ if (authoredRefreshing == true) isRefreshing = true
2126
2147
  }
2127
2148
 
2128
2149
  fun setHorizontal(value: Boolean) {
2129
2150
  if (horizontalMode == value) return
2130
2151
  horizontalMode = value
2131
2152
  (content.parent as? ViewGroup)?.removeView(content)
2132
- removeAllViews()
2153
+ scrollContainer.removeAllViews()
2133
2154
  val scroller: ViewGroup = if (value) horizontal else vertical
2134
2155
  scroller.removeAllViews()
2135
2156
  content.orientation = if (value) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
@@ -2137,7 +2158,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2137
2158
  scroller.addView(content, if (value)
2138
2159
  ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
2139
2160
  else ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
2140
- addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
2161
+ scrollContainer.addView(scroller, FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
2141
2162
  }
2142
2163
 
2143
2164
  /**
@@ -3337,28 +3358,59 @@ class MainActivity : AppCompatActivity() {
3337
3358
  val capabilityTimeouts = mutableMapOf<String, Runnable>()
3338
3359
  val audio = CraftNativeAudio()
3339
3360
  var active = true
3340
-
3341
- fun close() {
3342
- if (!active) return
3361
+ var closing = false
3362
+ private set
3363
+ private var closed = false
3364
+ private val closeWaiters = mutableListOf<() -> Unit>()
3365
+ private val closeHandler = Handler(Looper.getMainLooper())
3366
+ private val closeDeadline = Runnable { finishClose() }
3367
+
3368
+ fun close(done: () -> Unit = {}) {
3369
+ if (closed) { done(); return }
3370
+ closeWaiters.add(done)
3371
+ if (closing) return
3343
3372
  active = false
3373
+ closing = true
3344
3374
  audio.close()
3345
- val finish = {
3346
- port?.close()
3347
- isolate.close()
3348
- }
3375
+ // Evaluation completion does not mean its outbound messages have
3376
+ // reached the host. Close only after the ordered channel barrier,
3377
+ // so onDestroy's final writes run before navigation continues.
3378
+ closeHandler.postDelayed(closeDeadline, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
3349
3379
  val teardown = runCatching {
3350
3380
  isolate.evaluateJavaScriptAsync(
3351
- "globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount()"
3381
+ """
3382
+ (function() {
3383
+ var bridge = globalThis.__stxNativeBridge;
3384
+ if (!bridge || typeof bridge.postMessage !== 'function') return 'unavailable';
3385
+ try { globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount(); }
3386
+ catch (error) { console.error('Screen teardown failed: ' + error); }
3387
+ bridge.postMessage(JSON.stringify({ type: 'SCREEN_DISPOSED', payload: {} }));
3388
+ return 'sent';
3389
+ })()
3390
+ """.trimIndent()
3352
3391
  )
3353
3392
  }.getOrNull()
3354
3393
  if (teardown == null) {
3355
- finish()
3394
+ finishClose()
3356
3395
  return
3357
3396
  }
3358
3397
  teardown.addListener({
3359
3398
  runCatching { teardown.get() }
3360
- finish()
3361
- }, java.util.concurrent.Executor { command -> command.run() })
3399
+ .onSuccess { if (it == "unavailable") finishClose() }
3400
+ .onFailure { finishClose() }
3401
+ }, ContextCompat.getMainExecutor(view.context))
3402
+ }
3403
+
3404
+ fun finishClose() {
3405
+ if (!closing) return
3406
+ closing = false
3407
+ closed = true
3408
+ closeHandler.removeCallbacks(closeDeadline)
3409
+ port?.close()
3410
+ isolate.close()
3411
+ val waiters = closeWaiters.toList()
3412
+ closeWaiters.clear()
3413
+ waiters.forEach { it() }
3362
3414
  }
3363
3415
  }
3364
3416
 
@@ -3745,9 +3797,15 @@ class MainActivity : AppCompatActivity() {
3745
3797
  // Already in this stack (perhaps under others): back to it, with the params it was opened with now.
3746
3798
  val existing = screens.lastOrNull { it.path == match.path }
3747
3799
  if (existing != null) {
3748
- while (screens.lastOrNull() !== existing) closeScreen(screens.removeAt(screens.lastIndex))
3749
- show(existing)
3750
- send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params))
3800
+ val stack = screens
3801
+ val removed = mutableListOf<Screen>()
3802
+ while (stack.lastOrNull() !== existing) removed.add(stack.removeAt(stack.lastIndex))
3803
+ closeScreens(removed) {
3804
+ if (screens === stack && stack.lastOrNull() === existing) {
3805
+ show(existing)
3806
+ send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params))
3807
+ }
3808
+ }
3751
3809
  return
3752
3810
  }
3753
3811
  openScreen(match.screen, match.params)?.path = match.path
@@ -3772,14 +3830,14 @@ class MainActivity : AppCompatActivity() {
3772
3830
  val port = if (useMessagePorts) {
3773
3831
  isolate.createMessageChannel("craft", executor, MessagePortClient { message ->
3774
3832
  val current = screen ?: return@MessagePortClient
3775
- if (current.active && message.type == Message.TYPE_STRING) receiveSafely(current, message.string)
3833
+ if ((current.active || current.closing) && message.type == Message.TYPE_STRING) receiveSafely(current, message.string)
3776
3834
  })
3777
3835
  } else {
3778
3836
  isolate.setConsoleCallback(executor, JavaScriptConsoleCallback { entry ->
3779
3837
  val current = screen ?: return@JavaScriptConsoleCallback
3780
- if (current.active && entry.message.startsWith(prefix)) {
3838
+ if ((current.active || current.closing) && entry.message.startsWith(prefix)) {
3781
3839
  current.chunks.accept(entry.message.removePrefix(prefix))?.let { receiveSafely(current, it) }
3782
- } else if (current.active) {
3840
+ } else if (current.active || current.closing) {
3783
3841
  Log.i(TAG, "[${current.route ?: "home"}] ${entry.message}")
3784
3842
  }
3785
3843
  })
@@ -4228,11 +4286,13 @@ class MainActivity : AppCompatActivity() {
4228
4286
  }
4229
4287
  val payload = message.optJSONObject("payload") ?: JSONObject()
4230
4288
  val type = message.optString("type")
4289
+ if (!screen.active && (!screen.closing || type !in setOf("API_REQUEST", "API_CANCEL", "SYNC_WRITE", "SCREEN_DISPOSED"))) return
4231
4290
  // Timers and frames come many times a second: not worth a log line each.
4232
4291
  if (type != "TIMER" && type != "TIMER_CLEAR" && type != "FRAME" && Log.isLoggable(TAG, Log.DEBUG)) {
4233
4292
  Log.d(TAG, "Native STX $type (${raw.length} chars) for ${screen.route ?: "home"}")
4234
4293
  }
4235
4294
  when (message.optString("type")) {
4295
+ "SCREEN_DISPOSED" -> if (screen.closing) screen.finishClose()
4236
4296
  "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
4237
4297
  "MUTATE" -> try {
4238
4298
  val result = applyMutation(screen, payload)
@@ -4290,9 +4350,13 @@ class MainActivity : AppCompatActivity() {
4290
4350
  if (visibleScreen !== screen) return
4291
4351
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
4292
4352
  if (message.optString("type") == "NAVIGATE_REPLACE") {
4293
- closeScreen(screens.removeAt(screens.lastIndex))
4294
- }
4295
- openScreen(route, payload.optJSONObject("params") ?: JSONObject())
4353
+ val stack = screens
4354
+ val removed = stack.removeAt(stack.lastIndex)
4355
+ val previous = stack.lastOrNull()
4356
+ closeScreen(removed) {
4357
+ if (screens === stack && stack.lastOrNull() === previous) openScreen(route, payload.optJSONObject("params") ?: JSONObject())
4358
+ }
4359
+ } else openScreen(route, payload.optJSONObject("params") ?: JSONObject())
4296
4360
  }
4297
4361
  "NAVIGATE_BACK" -> if (visibleScreen === screen) back()
4298
4362
  "NAVIGATE_POP_TO_ROOT" -> if (visibleScreen === screen) popToRoot()
@@ -4301,9 +4365,9 @@ class MainActivity : AppCompatActivity() {
4301
4365
  val id = payload.optString("requestId")
4302
4366
  if (id.isNotBlank()) {
4303
4367
  val token = "${screen.capabilityScope}/$id"
4304
- screen.pendingCapabilities.remove(token)
4368
+ val pending = screen.pendingCapabilities.remove(token)
4305
4369
  screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
4306
- capabilities.cancel(token)
4370
+ if (pending) capabilities.cancel(token)
4307
4371
  }
4308
4372
  }
4309
4373
  "AUDIO" -> screen.audio.handle(payload)
@@ -4483,6 +4547,8 @@ class MainActivity : AppCompatActivity() {
4483
4547
  val children = node.optJSONArray("children") ?: JSONArray()
4484
4548
  val protocolId = node.optString("id").takeIf { it.isNotBlank() }
4485
4549
  val explicitKey = authoredKey(node, props)
4550
+ // Descendants are scoped to this retained identity, not its sibling
4551
+ // position. Moving a keyed parent must retain its nested inputs too.
4486
4552
  val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
4487
4553
  ?: protocolId?.let { "id:$it" }
4488
4554
  ?: path
@@ -4581,8 +4647,7 @@ class MainActivity : AppCompatActivity() {
4581
4647
  }
4582
4648
  }
4583
4649
  }
4584
- "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
4585
- ?: EditText(this)).apply {
4650
+ "TextInput" -> (reusableInput ?: EditText(this)).apply {
4586
4651
  // Setting filters, the input type or single-line mode makes
4587
4652
  // TextView set its text again, which puts the caret back at
4588
4653
  // the start: typing then came out backwards. Each is set only
@@ -4762,7 +4827,7 @@ class MainActivity : AppCompatActivity() {
4762
4827
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
4763
4828
  transparent = props.optBoolean("transparent", false)
4764
4829
  content.configure(style, resources.displayMetrics.density)
4765
- reconcileChildren(screen, content, children, path, next, style, previousControls)
4830
+ reconcileChildren(screen, content, children, identity, next, style, previousControls)
4766
4831
  // Dynamic :visible bindings arrive as a display style mutation;
4767
4832
  // static visible props remain authoritative when present.
4768
4833
  val visible = if (props.has("visible") && !props.isNull("visible")) {
@@ -4807,7 +4872,7 @@ class MainActivity : AppCompatActivity() {
4807
4872
  configureContainer(this, style)
4808
4873
  screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
4809
4874
  screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
4810
- reconcileChildren(screen, this, children, path, next, style, previousControls)
4875
+ reconcileChildren(screen, this, children, identity, next, style, previousControls)
4811
4876
  }
4812
4877
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
4813
4878
  setOnCheckedChangeListener(null)
@@ -4956,7 +5021,7 @@ class MainActivity : AppCompatActivity() {
4956
5021
  if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
4957
5022
  updateImage(screen, image, props.opt("source"), props.opt("defaultSource"))
4958
5023
  configureContainer(content, style)
4959
- reconcileChildren(screen, content, children, path, next, style, previousControls)
5024
+ reconcileChildren(screen, content, children, identity, next, style, previousControls)
4960
5025
  }
4961
5026
  "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
4962
5027
  handlers = events
@@ -5077,7 +5142,7 @@ class MainActivity : AppCompatActivity() {
5077
5142
  val child = children.opt(index)
5078
5143
  if (child !is JSONObject || canonicalType(child.optString("type")) != "RefreshControl") contentChildren.put(child)
5079
5144
  }
5080
- reconcileChildren(screen, content, contentChildren, path, next, contentStyle, previousControls)
5145
+ reconcileChildren(screen, content, contentChildren, identity, next, contentStyle, previousControls)
5081
5146
  }
5082
5147
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
5083
5148
  val nativeList = list
@@ -5091,21 +5156,21 @@ class MainActivity : AppCompatActivity() {
5091
5156
  ?.hideSoftInputFromWindow(windowToken, 0)
5092
5157
  }
5093
5158
  nativeList.overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
5094
- reconcileFlatList(screen, nativeList, children, path, protocolId ?: path, props, events)
5159
+ reconcileFlatList(screen, nativeList, children, identity, protocolId ?: identity, props, events)
5095
5160
  }
5096
5161
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5097
5162
  configureContainer(this, style)
5098
5163
  if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
5099
- reconcileChildren(screen, this, children, path, next, style, previousControls)
5164
+ reconcileChildren(screen, this, children, identity, next, style, previousControls)
5100
5165
  }
5101
5166
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
5102
5167
  configure(props, style, resources.displayMetrics.density)
5103
5168
  setKeyboardInset(keyboardInset)
5104
- reconcileChildren(screen, content, children, path, next, style, previousControls)
5169
+ reconcileChildren(screen, content, children, identity, next, style, previousControls)
5105
5170
  }
5106
5171
  else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5107
5172
  configureContainer(this, style)
5108
- reconcileChildren(screen, this, children, path, next, style, previousControls)
5173
+ reconcileChildren(screen, this, children, identity, next, style, previousControls)
5109
5174
  }
5110
5175
  }
5111
5176
  if (control is CraftNativeFlexLayout) {
@@ -5286,6 +5351,8 @@ class MainActivity : AppCompatActivity() {
5286
5351
  "sectionlist" -> "SectionList"
5287
5352
  "flatlist" -> "FlatList"
5288
5353
  "svg" -> "Svg"
5354
+ "g" -> "G"
5355
+ "svgtext", "svg-text" -> "SvgText"
5289
5356
  "touchableopacity" -> "TouchableOpacity"
5290
5357
  "touchablehighlight" -> "TouchableHighlight"
5291
5358
  "pressable" -> "Pressable"
@@ -5974,15 +6041,17 @@ class MainActivity : AppCompatActivity() {
5974
6041
  private fun inputTypeFor(props: JSONObject): Int {
5975
6042
  val base = if (props.optBoolean("multiline", false)) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
5976
6043
  else InputType.TYPE_CLASS_TEXT
6044
+ // Text variations do not carry their input class or multiline flag.
6045
+ // Preserve both when selecting an email, URL, or search keyboard.
5977
6046
  val keyboard = when (props.optString("keyboardType")) {
5978
- "email-address" -> InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
5979
- "url" -> InputType.TYPE_TEXT_VARIATION_URI
6047
+ "email-address" -> base or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
6048
+ "url" -> base or InputType.TYPE_TEXT_VARIATION_URI
5980
6049
  "numeric", "number-pad" -> InputType.TYPE_CLASS_NUMBER
5981
6050
  "decimal-pad" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
5982
6051
  "phone-pad" -> InputType.TYPE_CLASS_PHONE
5983
- "web-search" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT
5984
- "visible-password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
5985
- else -> 0
6052
+ "web-search" -> base or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT
6053
+ "visible-password" -> base or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
6054
+ else -> base
5986
6055
  }
5987
6056
  val capitalization = when (props.optString("autoCapitalize")) {
5988
6057
  "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
@@ -5992,7 +6061,7 @@ class MainActivity : AppCompatActivity() {
5992
6061
  }
5993
6062
  val correction = if (props.optBoolean("autoCorrect", true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
5994
6063
  val secure = if (props.optBoolean("secureTextEntry", false)) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
5995
- return (if (keyboard == 0) base else keyboard) or capitalization or correction or secure
6064
+ return keyboard or capitalization or correction or secure
5996
6065
  }
5997
6066
 
5998
6067
  /// The autofill hint for `textContentType` (iOS's names) or `autoComplete`
@@ -6081,7 +6150,7 @@ class MainActivity : AppCompatActivity() {
6081
6150
  }
6082
6151
  }
6083
6152
  perform { result ->
6084
- if (!screen.active || !screen.pendingCapabilities.remove(token)) return@perform
6153
+ if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
6085
6154
  screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
6086
6155
  val response = JSONObject()
6087
6156
  .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
@@ -6316,15 +6385,26 @@ class MainActivity : AppCompatActivity() {
6316
6385
 
6317
6386
  /** Back to the first screen in one move. */
6318
6387
  private fun popToRoot() {
6319
- while (screens.size > 1) closeScreen(screens.removeAt(screens.lastIndex))
6320
- screens.lastOrNull()?.let(::show)
6388
+ val stack = screens
6389
+ val removed = mutableListOf<Screen>()
6390
+ while (stack.size > 1) removed.add(stack.removeAt(stack.lastIndex))
6391
+ val target = stack.lastOrNull()
6392
+ closeScreens(removed) {
6393
+ if (target != null && screens === stack && stack.lastOrNull() === target) show(target)
6394
+ }
6321
6395
  }
6322
6396
 
6323
6397
  /** Back: the screen below in this tab, then the first tab, then out of the app. */
6324
6398
  private fun back() {
6399
+ // A previous back is still draining its screen's teardown messages.
6400
+ if (visibleScreen == null) return
6325
6401
  if (screens.size > 1) {
6326
- closeScreen(screens.removeAt(screens.lastIndex))
6327
- show(screens.last())
6402
+ val stack = screens
6403
+ val removed = stack.removeAt(stack.lastIndex)
6404
+ val target = stack.last()
6405
+ closeScreen(removed) {
6406
+ if (screens === stack && stack.lastOrNull() === target) show(target)
6407
+ }
6328
6408
  } else if (chrome != null && currentTab != 0) {
6329
6409
  selectTab(0)
6330
6410
  } else {
@@ -6542,7 +6622,17 @@ class MainActivity : AppCompatActivity() {
6542
6622
  screen.modalCloseHandlers.keys.removeAll(released)
6543
6623
  }
6544
6624
 
6545
- private fun closeScreen(screen: Screen) {
6625
+ private fun closeScreens(screens: List<Screen>, done: () -> Unit) {
6626
+ val iterator = screens.iterator()
6627
+ fun next() {
6628
+ if (iterator.hasNext()) closeScreen(iterator.next()) { next() }
6629
+ else done()
6630
+ }
6631
+ next()
6632
+ }
6633
+
6634
+ private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
6635
+ if (!screen.active) { screen.close(done); return }
6546
6636
  CraftNativeHub.unregister(screen.capabilityScope)
6547
6637
  screen.timers.values.forEach(capabilityHandler::removeCallbacks)
6548
6638
  screen.timers.clear()
@@ -6578,7 +6668,13 @@ class MainActivity : AppCompatActivity() {
6578
6668
  screen.flatListRows.clear()
6579
6669
  screen.flatListOwners.clear()
6580
6670
  screen.flatListOwnerByView.clear()
6581
- screen.close()
6671
+ screen.close {
6672
+ screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)
6673
+ screen.capabilityTimeouts.clear()
6674
+ screen.pendingCapabilities.forEach(capabilities::cancel)
6675
+ screen.pendingCapabilities.clear()
6676
+ done()
6677
+ }
6582
6678
  }
6583
6679
 
6584
6680
  @Deprecated("Uses the activity back callback for native route navigation")
@@ -80,6 +80,8 @@ dependencies {
80
80
  implementation("androidx.recyclerview:recyclerview:1.3.2")
81
81
  {{REFRESH_LAYOUT_DEPENDENCY}}
82
82
  implementation("androidx.webkit:webkit:1.12.1")
83
+ // App Link exclusions hand off to a browser in both renderers.
84
+ implementation("androidx.browser:browser:1.8.0")
83
85
  {{JAVASCRIPT_ENGINE_DEPENDENCY}}
84
86
  implementation("com.google.android.material:material:1.11.0")
85
87
  implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.201";
3427
+ var version = "0.0.203";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.201";
312
+ var version = "0.0.203";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.201";
60
+ var version = "0.0.203";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -524,7 +524,7 @@ export interface SimulatorDevice {
524
524
  * Exported because the choice is worth testing without a simulator present -
525
525
  * `pickSimulator` is the policy and this is the data it reads.
526
526
  */
527
- export declare function orderSimulators(devices: SimulatorDevice[]): SimulatorDevice[];
527
+ export declare function orderSimulators(devices: SimulatorDevice[], sdkVersion?: string): SimulatorDevice[];
528
528
  /**
529
529
  * The device to run on.
530
530
  *
@@ -1260,21 +1260,34 @@ async function open(options) {
1260
1260
  console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
1261
1261
  await $`open ${projectPath}`;
1262
1262
  }
1263
- function orderSimulators(devices) {
1264
- return devices.filter((device) => device.runtime.startsWith("iOS")).sort((left, right) => {
1263
+ function orderSimulators(devices, sdkVersion) {
1264
+ const sdk = sdkVersion?.match(/^(\d+)\.(\d+)/);
1265
+ if (sdkVersion !== undefined && !sdk)
1266
+ throw new Error(`Invalid iOS Simulator SDK version: ${sdkVersion}`);
1267
+ return devices.filter((device) => device.runtime.startsWith("iOS")).filter((device) => {
1268
+ if (!sdk)
1269
+ return true;
1270
+ const runtime = device.runtime.match(/^iOS-(\d+)-(\d+)/);
1271
+ if (!runtime)
1272
+ return false;
1273
+ return Number(runtime[1]) < Number(sdk[1]) || Number(runtime[1]) === Number(sdk[1]) && Number(runtime[2]) <= Number(sdk[2]);
1274
+ }).sort((left, right) => {
1265
1275
  const booted = Number(right.state === "Booted") - Number(left.state === "Booted");
1266
1276
  if (booted !== 0)
1267
1277
  return booted;
1268
1278
  const phone = Number(right.name.startsWith("iPhone")) - Number(left.name.startsWith("iPhone"));
1269
1279
  if (phone !== 0)
1270
1280
  return phone;
1271
- return right.runtime.localeCompare(left.runtime);
1281
+ return right.runtime.localeCompare(left.runtime, undefined, { numeric: true });
1272
1282
  });
1273
1283
  }
1274
1284
  async function pickSimulator() {
1275
1285
  const listed = await $`xcrun simctl list devices available --json`.quiet().nothrow();
1276
1286
  if (listed.exitCode !== 0)
1277
1287
  return null;
1288
+ const sdk = await $`xcrun --sdk iphonesimulator --show-sdk-version`.quiet().nothrow();
1289
+ if (sdk.exitCode !== 0)
1290
+ throw new Error("Could not determine the selected Xcode iOS Simulator SDK");
1278
1291
  let parsed;
1279
1292
  try {
1280
1293
  parsed = JSON.parse(listed.stdout.toString());
@@ -1286,7 +1299,7 @@ async function pickSimulator() {
1286
1299
  for (const device of list)
1287
1300
  devices.push({ ...device, runtime: runtime.split(".").pop() ?? runtime });
1288
1301
  }
1289
- return orderSimulators(devices)[0] ?? null;
1302
+ return orderSimulators(devices, sdk.stdout.toString().trim())[0] ?? null;
1290
1303
  }
1291
1304
  async function bootSimulator(device) {
1292
1305
  if (device.state !== "Booted")
@@ -792,10 +792,12 @@ final class CraftChrome: ObservableObject {
792
792
  withAnimation(nil) { selected = id }
793
793
  }
794
794
 
795
- func cancelTabSwipe() {
795
+ func cancelTabSwipe(restoringSelection: Bool = true) {
796
796
  guard let previous = tabSwipePrevious else { return }
797
797
  tabSwipePrevious = nil
798
- withAnimation(nil) { selected = previous }
798
+ if restoringSelection {
799
+ withAnimation(nil) { selected = previous }
800
+ }
799
801
  }
800
802
 
801
803
  func commitTabSwipe(to id: String) {