@apollohg/react-native-prose-editor 0.5.20 → 0.5.21

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.
@@ -12,7 +12,9 @@ import android.util.Log
12
12
  import android.view.Gravity
13
13
  import android.view.MotionEvent
14
14
  import android.view.View
15
+ import android.view.ViewConfiguration
15
16
  import android.view.ViewGroup
17
+ import android.view.ViewTreeObserver
16
18
  import android.view.Window
17
19
  import android.view.inputmethod.InputMethodManager
18
20
  import android.widget.FrameLayout
@@ -32,6 +34,13 @@ import java.util.concurrent.atomic.AtomicReference
32
34
  import uniffi.editor_core.*
33
35
 
34
36
  private const val DESTROY_INVALIDATION_AWAIT_TIMEOUT_MS = 250L
37
+ private const val OUTSIDE_TAP_GESTURE_CONFIRM_DELAY_MS = 150L
38
+
39
+ internal enum class NativeEditorOutsideTapDecision {
40
+ IGNORE,
41
+ PRESERVE_FOCUS,
42
+ OUTSIDE_EDITOR
43
+ }
35
44
 
36
45
  private class WeakNativeEditorExpoView private constructor(
37
46
  val view: WeakReference<NativeEditorExpoView?>
@@ -258,44 +267,80 @@ internal object NativeEditorViewRegistry {
258
267
  }
259
268
 
260
269
  private object NativeEditorOutsideTapDispatcher {
261
- private val dispatchers = WeakHashMap<Window, OutsideTapWindowCallback>()
270
+ private val dispatchers = WeakHashMap<Window, OutsideTapTouchDispatcher>()
262
271
 
263
- fun register(window: Window, view: NativeEditorExpoView) {
264
- val currentCallback = window.callback ?: return
272
+ fun register(window: Window, view: NativeEditorExpoView): Boolean {
273
+ val host = contentRootFor(window)
274
+ if (host == null) {
275
+ view.traceOutsideTap("register skipped missing content root")
276
+ return false
277
+ }
265
278
  val previousDispatcher = dispatchers[window]
266
- val dispatcher = if (currentCallback is OutsideTapWindowCallback) {
279
+ val dispatcher = if (previousDispatcher?.host === host) {
267
280
  previousDispatcher
268
- ?.takeIf { it !== currentCallback }
269
- ?.transferViewsTo(currentCallback)
270
- currentCallback
271
281
  } else {
272
- OutsideTapWindowCallback(window, currentCallback).also { nextDispatcher ->
282
+ OutsideTapTouchDispatcher(host).also { nextDispatcher ->
273
283
  previousDispatcher?.transferViewsTo(nextDispatcher)
274
- window.callback = nextDispatcher
284
+ previousDispatcher?.detach()
285
+ dispatchers[window] = nextDispatcher
275
286
  }
276
287
  }
277
288
  dispatchers[window] = dispatcher
278
289
  dispatcher.add(view)
290
+ view.traceOutsideTap(
291
+ "register overlayAttached=${dispatcher.isAttached()} " +
292
+ "host=${host.javaClass.name} " +
293
+ "activeViews=${dispatcher.liveViews().size}"
294
+ )
295
+ return dispatcher.isAttached()
279
296
  }
280
297
 
281
298
  fun unregister(window: Window, view: NativeEditorExpoView) {
282
299
  val dispatcher = dispatchers[window] ?: return
283
300
  if (!dispatcher.remove(view)) return
301
+ dispatcher.detach()
284
302
  dispatchers.remove(window)
285
- if (window.callback === dispatcher) {
286
- window.callback = dispatcher.baseCallback
287
- }
288
303
  }
289
304
 
290
- private class OutsideTapWindowCallback(
291
- private val window: Window,
292
- val baseCallback: Window.Callback
293
- ) : Window.Callback by baseCallback {
305
+ private fun contentRootFor(window: Window): ViewGroup? {
306
+ val decorView = window.decorView
307
+ return decorView.findViewById<View>(android.R.id.content) as? ViewGroup
308
+ ?: decorView as? ViewGroup
309
+ }
310
+
311
+ private class OutsideTapTouchDispatcher(
312
+ val host: ViewGroup
313
+ ) : View.OnTouchListener {
314
+ private data class OutsideTapCandidate(
315
+ val view: WeakReference<NativeEditorExpoView>,
316
+ val downRawX: Float,
317
+ val downRawY: Float,
318
+ val editorRectOnDown: Rect?,
319
+ val confirm: Runnable
320
+ )
321
+
294
322
  private val views = mutableListOf<WeakReference<NativeEditorExpoView>>()
295
- private var disabled = false
323
+ private val pendingOutsideTapCandidates = mutableListOf<OutsideTapCandidate>()
324
+ private val touchSlopPx = ViewConfiguration.get(host.context).scaledTouchSlop
325
+ private val scrollChangedListener = ViewTreeObserver.OnScrollChangedListener {
326
+ cancelPendingOutsideTapCandidates("scroll")
327
+ }
328
+ private var scrollListenerTreeObserver: ViewTreeObserver? = null
329
+ private val observerView = View(host.context).apply {
330
+ isClickable = false
331
+ isFocusable = false
332
+ isFocusableInTouchMode = false
333
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
334
+ }
335
+
336
+ init {
337
+ observerView.setOnTouchListener(this)
338
+ attach()
339
+ }
296
340
 
297
341
  fun add(view: NativeEditorExpoView) {
298
342
  prune()
343
+ attach()
299
344
  if (views.any { it.get() === view }) return
300
345
  views.add(WeakReference(view))
301
346
  }
@@ -305,48 +350,201 @@ private object NativeEditorOutsideTapDispatcher {
305
350
  return views.mapNotNull { it.get() }
306
351
  }
307
352
 
308
- fun transferViewsTo(target: OutsideTapWindowCallback) {
353
+ fun transferViewsTo(target: OutsideTapTouchDispatcher) {
309
354
  liveViews().forEach { target.add(it) }
310
355
  views.clear()
311
- disabled = true
356
+ cancelPendingOutsideTapCandidates("transfer")
312
357
  }
313
358
 
314
359
  fun remove(view: NativeEditorExpoView): Boolean {
360
+ cancelPendingOutsideTapCandidatesFor(view, "remove view")
315
361
  views.removeAll { it.get()?.let { candidate -> candidate === view } != false }
316
362
  return views.isEmpty()
317
363
  }
318
364
 
319
- override fun dispatchTouchEvent(event: MotionEvent): Boolean {
320
- if (disabled) {
321
- return baseCallback.dispatchTouchEvent(event)
322
- }
365
+ override fun onTouch(view: View, event: MotionEvent): Boolean {
323
366
  val activeViews = liveViews()
324
- if (event.action != MotionEvent.ACTION_DOWN || activeViews.isEmpty()) {
325
- return baseCallback.dispatchTouchEvent(event)
367
+ if (activeViews.isEmpty()) {
368
+ return false
326
369
  }
327
370
 
371
+ when (event.actionMasked) {
372
+ MotionEvent.ACTION_DOWN -> handleActionDown(activeViews, event)
373
+ MotionEvent.ACTION_MOVE -> {
374
+ if (hasMovedBeyondTapSlop(event)) {
375
+ cancelPendingOutsideTapCandidates("move")
376
+ }
377
+ }
378
+ MotionEvent.ACTION_UP -> {
379
+ if (hasMovedBeyondTapSlop(event)) {
380
+ cancelPendingOutsideTapCandidates("up moved")
381
+ } else {
382
+ confirmPendingOutsideTapCandidates("up")
383
+ }
384
+ }
385
+ MotionEvent.ACTION_CANCEL -> cancelPendingOutsideTapCandidates("cancel")
386
+ }
387
+ return false
388
+ }
389
+
390
+ private fun handleActionDown(activeViews: List<NativeEditorExpoView>, event: MotionEvent) {
391
+ cancelPendingOutsideTapCandidates("new down")
328
392
  val decisions = activeViews.map { view ->
329
- view to view.shouldScheduleOutsideTapBlurForWindowEvent(event)
393
+ view to view.prepareOutsideTapDecisionForWindowEvent(event)
330
394
  }
331
- val result = baseCallback.dispatchTouchEvent(event)
332
- decisions.forEach { (view, shouldBlur) ->
333
- if (shouldBlur) {
334
- view.scheduleOutsideTapBlurFromWindowDispatcher()
395
+ decisions.forEach { (view, decision) ->
396
+ view.traceOutsideTap(
397
+ "dispatch overlay action=${event.action} raw=${event.rawX.toInt()},${event.rawY.toInt()} decision=$decision"
398
+ )
399
+ if (decision == NativeEditorOutsideTapDecision.OUTSIDE_EDITOR) {
400
+ scheduleOutsideTapCandidate(view, event)
335
401
  } else {
336
- view.cancelOutsideTapBlurFromWindowDispatcher()
402
+ view.handleOutsideTapDecisionFromWindowDispatcher(decision)
337
403
  }
338
404
  }
339
- return result
340
405
  }
341
406
 
342
- private fun prune() {
343
- views.removeAll { it.get() == null }
344
- if (views.isEmpty() && window.callback === this) {
345
- window.callback = baseCallback
346
- if (dispatchers[window] === this) {
347
- dispatchers.remove(window)
407
+ private fun scheduleOutsideTapCandidate(view: NativeEditorExpoView, event: MotionEvent) {
408
+ val editorRect = Rect()
409
+ val editorRectOnDown = if (
410
+ view.richTextView.editorEditText.getGlobalVisibleRect(editorRect) &&
411
+ !editorRect.isEmpty
412
+ ) {
413
+ editorRect
414
+ } else {
415
+ null
416
+ }
417
+ val viewRef = WeakReference(view)
418
+ lateinit var candidate: OutsideTapCandidate
419
+ val confirm = Runnable {
420
+ confirmOutsideTapCandidate(candidate, "delay")
421
+ }
422
+ candidate = OutsideTapCandidate(
423
+ view = viewRef,
424
+ downRawX = event.rawX,
425
+ downRawY = event.rawY,
426
+ editorRectOnDown = editorRectOnDown,
427
+ confirm = confirm
428
+ )
429
+ pendingOutsideTapCandidates.add(candidate)
430
+ ensureScrollListener()
431
+ view.traceOutsideTap("candidate outside tap")
432
+ observerView.postDelayed(confirm, OUTSIDE_TAP_GESTURE_CONFIRM_DELAY_MS)
433
+ }
434
+
435
+ private fun confirmPendingOutsideTapCandidates(reason: String) {
436
+ val candidates = pendingOutsideTapCandidates.toList()
437
+ candidates.forEach { candidate ->
438
+ confirmOutsideTapCandidate(candidate, reason)
439
+ }
440
+ }
441
+
442
+ private fun confirmOutsideTapCandidate(candidate: OutsideTapCandidate, reason: String) {
443
+ if (!pendingOutsideTapCandidates.remove(candidate)) return
444
+ removeScrollListenerIfIdle()
445
+ observerView.removeCallbacks(candidate.confirm)
446
+ val view = candidate.view.get() ?: return
447
+ if (editorMovedBeyondTapSlop(view, candidate)) {
448
+ view.traceOutsideTap("cancel outside tap candidate reason=$reason moved")
449
+ return
450
+ }
451
+ view.traceOutsideTap("confirm outside tap candidate reason=$reason")
452
+ view.handleOutsideTapDecisionFromWindowDispatcher(NativeEditorOutsideTapDecision.OUTSIDE_EDITOR)
453
+ }
454
+
455
+ private fun hasMovedBeyondTapSlop(event: MotionEvent): Boolean =
456
+ pendingOutsideTapCandidates.any { candidate ->
457
+ val dx = event.rawX - candidate.downRawX
458
+ val dy = event.rawY - candidate.downRawY
459
+ dx * dx + dy * dy > touchSlopPx * touchSlopPx
460
+ }
461
+
462
+ private fun editorMovedBeyondTapSlop(
463
+ view: NativeEditorExpoView,
464
+ candidate: OutsideTapCandidate
465
+ ): Boolean {
466
+ val editorRectOnDown = candidate.editorRectOnDown ?: return false
467
+ val currentRect = Rect()
468
+ if (!view.richTextView.editorEditText.getGlobalVisibleRect(currentRect)) {
469
+ return true
470
+ }
471
+ val dx = currentRect.left - editorRectOnDown.left
472
+ val dy = currentRect.top - editorRectOnDown.top
473
+ return dx * dx + dy * dy > touchSlopPx * touchSlopPx
474
+ }
475
+
476
+ private fun cancelPendingOutsideTapCandidatesFor(view: NativeEditorExpoView, reason: String) {
477
+ val candidates = pendingOutsideTapCandidates.toList()
478
+ candidates.forEach { candidate ->
479
+ if (candidate.view.get() === view) {
480
+ pendingOutsideTapCandidates.remove(candidate)
481
+ observerView.removeCallbacks(candidate.confirm)
482
+ view.traceOutsideTap("cancel outside tap candidate reason=$reason")
348
483
  }
349
484
  }
485
+ removeScrollListenerIfIdle()
486
+ }
487
+
488
+ private fun cancelPendingOutsideTapCandidates(reason: String) {
489
+ val candidates = pendingOutsideTapCandidates.toList()
490
+ pendingOutsideTapCandidates.clear()
491
+ removeScrollListener()
492
+ candidates.forEach { candidate ->
493
+ observerView.removeCallbacks(candidate.confirm)
494
+ candidate.view.get()?.traceOutsideTap("cancel outside tap candidate reason=$reason")
495
+ }
496
+ }
497
+
498
+ private fun ensureScrollListener() {
499
+ val activeObserver = scrollListenerTreeObserver
500
+ if (activeObserver?.isAlive == true && activeObserver === host.viewTreeObserver) {
501
+ return
502
+ }
503
+ removeScrollListener()
504
+ val nextObserver = host.viewTreeObserver
505
+ if (nextObserver.isAlive) {
506
+ nextObserver.addOnScrollChangedListener(scrollChangedListener)
507
+ scrollListenerTreeObserver = nextObserver
508
+ }
509
+ }
510
+
511
+ private fun removeScrollListenerIfIdle() {
512
+ if (pendingOutsideTapCandidates.isEmpty()) {
513
+ removeScrollListener()
514
+ }
515
+ }
516
+
517
+ private fun removeScrollListener() {
518
+ val observer = scrollListenerTreeObserver
519
+ if (observer?.isAlive == true) {
520
+ observer.removeOnScrollChangedListener(scrollChangedListener)
521
+ }
522
+ scrollListenerTreeObserver = null
523
+ }
524
+
525
+ fun isAttached(): Boolean = observerView.parent === host
526
+
527
+ fun detach() {
528
+ cancelPendingOutsideTapCandidates("detach")
529
+ (observerView.parent as? ViewGroup)?.removeView(observerView)
530
+ }
531
+
532
+ private fun attach() {
533
+ if (observerView.parent !== host) {
534
+ detach()
535
+ host.addView(
536
+ observerView,
537
+ ViewGroup.LayoutParams(
538
+ ViewGroup.LayoutParams.MATCH_PARENT,
539
+ ViewGroup.LayoutParams.MATCH_PARENT
540
+ )
541
+ )
542
+ }
543
+ observerView.bringToFront()
544
+ }
545
+
546
+ private fun prune() {
547
+ views.removeAll { it.get() == null }
350
548
  }
351
549
  }
352
550
  }
@@ -415,8 +613,12 @@ class NativeEditorExpoView(
415
613
  internal var blockThemePreflightForTesting = false
416
614
  internal var onToolbarActionForTesting: ((Map<String, Any>) -> Unit)? = null
417
615
  internal var onAddonEventForTesting: ((Map<String, Any>) -> Unit)? = null
616
+ internal var onSelectionChangeForTesting: ((Map<String, Any>) -> Unit)? = null
418
617
  internal var onFocusChangeForTesting: ((Map<String, Any>) -> Unit)? = null
618
+ internal var onContentHeightChangeForTesting: ((Map<String, Any>) -> Unit)? = null
619
+ internal var onEditorUpdateForTesting: ((Map<String, Any>) -> Unit)? = null
419
620
  internal var onEditorReadyForTesting: ((Map<String, Any>) -> Unit)? = null
621
+ internal var onOutsideTapTraceForTesting: ((String) -> Unit)? = null
420
622
  internal var onRefreshToolbarStateFromEditorSelectionForTesting: (() -> String?)? = null
421
623
  internal var onBeforePrepareForEditorCommandForTesting: (() -> Unit)? = null
422
624
  private var isAttachedToNativeWindow = false
@@ -424,8 +626,10 @@ class NativeEditorExpoView(
424
626
  private var heightBehavior = EditorHeightBehavior.FIXED
425
627
  private var lastEmittedContentHeight = 0
426
628
  private var outsideTapWindow: Window? = null
629
+ private var pendingOutsideTapHandlerInstallRetry: Runnable? = null
427
630
  private var toolbarFramesInWindow: List<RectF> = emptyList()
428
631
  private var lastToolbarTouchUptimeMs: Long? = null
632
+ private var editorFocusedForOutsideTapOverrideForTesting: Boolean? = null
429
633
  private var pendingOutsideTapBlur: Runnable? = null
430
634
  private var pendingKeyboardDismiss: Runnable? = null
431
635
  private var pendingToolbarRefocus: Runnable? = null
@@ -456,6 +660,12 @@ class NativeEditorExpoView(
456
660
  private var pendingEditorUpdateEditorId: Long? = null
457
661
  private var pendingEditorUpdateRevision = 0
458
662
  private var appliedEditorUpdateRevision = 0
663
+ private var pendingEditorResetUpdateJson: String? = null
664
+ private var pendingEditorResetUpdateEditorId: Long? = null
665
+ private var pendingEditorResetUpdateRevision = 0
666
+ private var appliedEditorResetUpdateRevision = 0
667
+ private var lastEditorResetUpdateJsonProp: String? = null
668
+ private var lastEditorResetUpdateEditorIdProp: Long? = null
459
669
  private var pendingEditorUpdateRetryScheduled = false
460
670
  private var pendingEditorUpdateRetryEditorId: Long? = null
461
671
  private var pendingEditorUpdateRetryGeneration = 0
@@ -512,9 +722,10 @@ class NativeEditorExpoView(
512
722
  if (hasFocus) {
513
723
  cancelPendingToolbarRefocus()
514
724
  installOutsideTapBlurHandlerIfNeeded()
725
+ scheduleOutsideTapBlurHandlerInstallRetry()
515
726
  refreshMentionQuery()
516
727
  } else {
517
- if (shouldPreserveFocusAfterToolbarTouch()) {
728
+ if (consumeToolbarFocusPreservationForBlur()) {
518
729
  scheduleToolbarRefocus()
519
730
  return@setOnFocusChangeListener
520
731
  }
@@ -543,6 +754,7 @@ class NativeEditorExpoView(
543
754
  handleEditorDestroyed(id)
544
755
  return
545
756
  }
757
+ applyPendingEditorResetUpdateIfNeeded()
546
758
  applyPendingEditorUpdateIfNeeded()
547
759
  applyPendingThemeIfNeeded()
548
760
  refreshReadyStateIfSettled()
@@ -564,7 +776,11 @@ class NativeEditorExpoView(
564
776
  if (pendingEditorUpdateEditorId != null && pendingEditorUpdateEditorId != id) {
565
777
  clearPendingEditorUpdateState()
566
778
  }
779
+ if (pendingEditorResetUpdateEditorId != null && pendingEditorResetUpdateEditorId != id) {
780
+ clearPendingEditorResetUpdateState()
781
+ }
567
782
  appliedEditorUpdateRevision = 0
783
+ appliedEditorResetUpdateRevision = 0
568
784
  clearPendingViewCommandUpdateRetry()
569
785
  cancelPendingThemeRetry()
570
786
  if (hasPendingTheme) {
@@ -590,7 +806,7 @@ class NativeEditorExpoView(
590
806
  return
591
807
  }
592
808
 
593
- if (hasPendingEditorUpdateForEditor(id)) {
809
+ if (hasPendingEditorResetUpdateForEditor(id) || hasPendingEditorUpdateForEditor(id)) {
594
810
  richTextView.setEditorIdWhileDetached(id)
595
811
  richTextView.rebindEditorIfNeeded(notifyListener = false)
596
812
  } else {
@@ -605,6 +821,7 @@ class NativeEditorExpoView(
605
821
  toolbarState = NativeToolbarState.empty
606
822
  keyboardToolbarView.applyState(toolbarState)
607
823
  }
824
+ applyPendingEditorResetUpdateIfNeeded()
608
825
  applyPendingEditorUpdateIfNeeded()
609
826
  applyPendingThemeIfNeeded()
610
827
  refreshReadyStateIfSettled()
@@ -807,15 +1024,48 @@ class NativeEditorExpoView(
807
1024
  pendingEditorUpdateRevision = editorUpdateRevision
808
1025
  }
809
1026
 
1027
+ fun setPendingEditorResetUpdateJson(editorResetUpdateJson: String?) {
1028
+ lastEditorResetUpdateJsonProp = editorResetUpdateJson
1029
+ pendingEditorResetUpdateJson = editorResetUpdateJson
1030
+ }
1031
+
1032
+ fun setPendingEditorResetUpdateEditorId(editorResetUpdateEditorId: Long?) {
1033
+ lastEditorResetUpdateEditorIdProp = editorResetUpdateEditorId
1034
+ pendingEditorResetUpdateEditorId = editorResetUpdateEditorId
1035
+ }
1036
+
1037
+ fun setPendingEditorResetUpdateRevision(editorResetUpdateRevision: Int) {
1038
+ if (pendingEditorResetUpdateRevision != editorResetUpdateRevision) {
1039
+ pendingEditorUpdateRetryAttempts = 0
1040
+ pendingEditorUpdateForcedRecoveryAttempted = false
1041
+ }
1042
+ if (editorResetUpdateRevision != 0 && pendingEditorResetUpdateJson == null) {
1043
+ pendingEditorResetUpdateJson = lastEditorResetUpdateJsonProp
1044
+ }
1045
+ if (editorResetUpdateRevision != 0 && pendingEditorResetUpdateEditorId == null) {
1046
+ pendingEditorResetUpdateEditorId = lastEditorResetUpdateEditorIdProp
1047
+ }
1048
+ pendingEditorResetUpdateRevision = editorResetUpdateRevision
1049
+ }
1050
+
810
1051
  private fun hasPendingEditorUpdateForEditor(editorId: Long): Boolean =
811
1052
  pendingEditorUpdateJson != null &&
812
1053
  pendingEditorUpdateRevision != 0 &&
813
1054
  pendingEditorUpdateRevision != appliedEditorUpdateRevision &&
814
1055
  pendingEditorUpdateEditorId == editorId
815
1056
 
1057
+ private fun hasPendingEditorResetUpdateForEditor(editorId: Long): Boolean =
1058
+ pendingEditorResetUpdateJson != null &&
1059
+ pendingEditorResetUpdateRevision != 0 &&
1060
+ pendingEditorResetUpdateRevision != appliedEditorResetUpdateRevision &&
1061
+ pendingEditorResetUpdateEditorId == editorId
1062
+
816
1063
  private fun hasPendingEditorUpdateForCurrentEditor(): Boolean =
817
1064
  hasPendingEditorUpdateForEditor(richTextView.editorId)
818
1065
 
1066
+ private fun hasPendingEditorResetUpdateForCurrentEditor(): Boolean =
1067
+ hasPendingEditorResetUpdateForEditor(richTextView.editorId)
1068
+
819
1069
  private fun pendingEditorUpdateCommandPreparationJSON(): String =
820
1070
  NativeEditorViewRegistry.commandPreparationJSON(
821
1071
  ready = false,
@@ -823,10 +1073,11 @@ class NativeEditorExpoView(
823
1073
  )
824
1074
 
825
1075
  private fun shouldBlockEditorCommandForPendingUpdate(): Boolean =
826
- hasPendingEditorUpdateForCurrentEditor()
1076
+ hasPendingEditorResetUpdateForCurrentEditor() || hasPendingEditorUpdateForCurrentEditor()
827
1077
 
828
1078
  private fun refreshReadyStateIfSettled() {
829
1079
  if (handleDestroyedCurrentEditorIfNeeded()) return
1080
+ if (hasPendingEditorResetUpdateForCurrentEditor()) return
830
1081
  if (hasPendingEditorUpdateForCurrentEditor()) return
831
1082
  if (!isAttachedToNativeWindow) return
832
1083
  if (richTextView.editorEditText.editorId != richTextView.editorId) return
@@ -835,6 +1086,54 @@ class NativeEditorExpoView(
835
1086
  emitEditorReadyIfNeeded()
836
1087
  }
837
1088
 
1089
+ fun applyPendingEditorResetUpdateIfNeeded() {
1090
+ if (handleDestroyedCurrentEditorIfNeeded()) return
1091
+ if (pendingEditorResetUpdateRevision == 0) return
1092
+ val revision = pendingEditorResetUpdateRevision
1093
+ val editorId = richTextView.editorId
1094
+ val expectedEditorId = pendingEditorResetUpdateEditorId
1095
+ if (expectedEditorId == null) return
1096
+ if (expectedEditorId != editorId) return
1097
+ if (pendingEditorResetUpdateJson == null) {
1098
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1099
+ refreshReadyStateIfSettled()
1100
+ return
1101
+ }
1102
+ val updateJson = pendingEditorResetUpdateJson ?: return
1103
+ if (revision == appliedEditorResetUpdateRevision) {
1104
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1105
+ emitEditorReady(editorUpdateRevision = revision)
1106
+ refreshReadyStateIfSettled()
1107
+ return
1108
+ }
1109
+ if (editorId != 0L && !isAttachedToNativeWindow) return
1110
+ val apply = Runnable {
1111
+ if (editorId != richTextView.editorId) return@Runnable
1112
+ if (expectedEditorId != richTextView.editorId) return@Runnable
1113
+ if (editorId != 0L && !isAttachedToNativeWindow) return@Runnable
1114
+ if (revision != pendingEditorResetUpdateRevision) return@Runnable
1115
+ if (revision == appliedEditorResetUpdateRevision) {
1116
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1117
+ emitEditorReady(editorUpdateRevision = revision)
1118
+ refreshReadyStateIfSettled()
1119
+ return@Runnable
1120
+ }
1121
+ if (applyEditorResetUpdate(updateJson)) {
1122
+ appliedEditorResetUpdateRevision = revision
1123
+ clearPendingEditorResetUpdateState(resetAppliedRevision = false)
1124
+ emitEditorReady(editorUpdateRevision = revision)
1125
+ refreshReadyStateIfSettled()
1126
+ } else {
1127
+ schedulePendingEditorUpdateRetry()
1128
+ }
1129
+ }
1130
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1131
+ apply.run()
1132
+ } else if (!post(apply)) {
1133
+ richTextView.post(apply)
1134
+ }
1135
+ }
1136
+
838
1137
  fun applyPendingEditorUpdateIfNeeded() {
839
1138
  if (handleDestroyedCurrentEditorIfNeeded()) return
840
1139
  if (pendingEditorUpdateRevision == 0) return
@@ -898,6 +1197,15 @@ class NativeEditorExpoView(
898
1197
  cancelPendingEditorUpdateRetry()
899
1198
  }
900
1199
 
1200
+ private fun clearPendingEditorResetUpdateState(resetAppliedRevision: Boolean = true) {
1201
+ pendingEditorResetUpdateJson = null
1202
+ pendingEditorResetUpdateEditorId = null
1203
+ pendingEditorResetUpdateRevision = 0
1204
+ if (resetAppliedRevision) {
1205
+ appliedEditorResetUpdateRevision = 0
1206
+ }
1207
+ }
1208
+
901
1209
  private fun cancelPendingEditorUpdateRetry() {
902
1210
  pendingEditorUpdateRetryScheduled = false
903
1211
  pendingEditorUpdateRetryEditorId = null
@@ -939,6 +1247,7 @@ class NativeEditorExpoView(
939
1247
  }
940
1248
  pendingEditorUpdateRetryScheduled = false
941
1249
  pendingEditorUpdateRetryEditorId = null
1250
+ applyPendingEditorResetUpdateIfNeeded()
942
1251
  applyPendingEditorUpdateIfNeeded()
943
1252
  }
944
1253
  mainHandler.postDelayed(retry, delayMs)
@@ -1065,6 +1374,9 @@ class NativeEditorExpoView(
1065
1374
  return
1066
1375
  }
1067
1376
  if (handleDestroyedCurrentEditorIfNeeded()) return
1377
+ if (pendingEditorResetUpdateJson != null) {
1378
+ applyPendingEditorResetUpdateIfNeeded()
1379
+ }
1068
1380
  if (pendingEditorUpdateJson != null) {
1069
1381
  pendingEditorUpdateRetryAttempts = 0
1070
1382
  pendingEditorUpdateForcedRecoveryAttempted = false
@@ -1277,8 +1589,14 @@ class NativeEditorExpoView(
1277
1589
  }
1278
1590
 
1279
1591
  fun focus() {
1592
+ focusInternal(cancelPendingOutsideTapBlur = true)
1593
+ }
1594
+
1595
+ private fun focusInternal(cancelPendingOutsideTapBlur: Boolean) {
1280
1596
  if (!canFocusCurrentEditor()) return
1281
- cancelPendingOutsideTapBlur()
1597
+ if (cancelPendingOutsideTapBlur) {
1598
+ cancelPendingOutsideTapBlur()
1599
+ }
1282
1600
  cancelPendingKeyboardDismiss()
1283
1601
  cancelPendingBlurRetry()
1284
1602
  richTextView.editorEditText.requestFocus()
@@ -1292,6 +1610,7 @@ class NativeEditorExpoView(
1292
1610
  fun blur() {
1293
1611
  cancelPendingOutsideTapBlur()
1294
1612
  cancelPendingKeyboardDismiss()
1613
+ cancelPendingToolbarRefocus()
1295
1614
  clearRecentToolbarTouch()
1296
1615
  performBlur(deferKeyboardDismiss = false, allowRetry = true)
1297
1616
  }
@@ -1311,7 +1630,11 @@ class NativeEditorExpoView(
1311
1630
 
1312
1631
  private fun completeBlur(deferKeyboardDismiss: Boolean) {
1313
1632
  cancelPendingBlurRetry()
1633
+ traceOutsideTap(
1634
+ "complete blur deferKeyboardDismiss=$deferKeyboardDismiss focusedBefore=${richTextView.editorEditText.hasFocus()}"
1635
+ )
1314
1636
  richTextView.editorEditText.clearFocus()
1637
+ traceOutsideTap("complete blur focusedAfter=${richTextView.editorEditText.hasFocus()}")
1315
1638
  if (deferKeyboardDismiss) {
1316
1639
  val dismiss = Runnable {
1317
1640
  pendingKeyboardDismiss = null
@@ -1355,6 +1678,7 @@ class NativeEditorExpoView(
1355
1678
 
1356
1679
  private fun blurWithDeferredKeyboardDismiss() {
1357
1680
  cancelPendingKeyboardDismiss()
1681
+ cancelPendingToolbarRefocus()
1358
1682
  clearRecentToolbarTouch()
1359
1683
  performBlur(deferKeyboardDismiss = true, allowRetry = true)
1360
1684
  }
@@ -1370,7 +1694,7 @@ class NativeEditorExpoView(
1370
1694
  if (refocusGeneration != pendingToolbarRefocusGeneration) return@Runnable
1371
1695
  if (pendingToolbarRefocusEditorId != richTextView.editorId) return@Runnable
1372
1696
  pendingToolbarRefocusEditorId = null
1373
- focus()
1697
+ focusInternal(cancelPendingOutsideTapBlur = false)
1374
1698
  }
1375
1699
  pendingToolbarRefocus = refocus
1376
1700
  richTextView.editorEditText.post(refocus)
@@ -1387,8 +1711,10 @@ class NativeEditorExpoView(
1387
1711
 
1388
1712
  private fun scheduleOutsideTapBlur() {
1389
1713
  cancelPendingOutsideTapBlur()
1714
+ traceOutsideTap("schedule outside blur focused=${richTextView.editorEditText.hasFocus()}")
1390
1715
  val blur = Runnable {
1391
1716
  pendingOutsideTapBlur = null
1717
+ traceOutsideTap("run outside blur focused=${richTextView.editorEditText.hasFocus()}")
1392
1718
  if (richTextView.editorEditText.hasFocus()) {
1393
1719
  blurWithDeferredKeyboardDismiss()
1394
1720
  }
@@ -1399,6 +1725,7 @@ class NativeEditorExpoView(
1399
1725
 
1400
1726
  private fun cancelPendingOutsideTapBlur() {
1401
1727
  pendingOutsideTapBlur?.let {
1728
+ traceOutsideTap("cancel outside blur")
1402
1729
  richTextView.editorEditText.removeCallbacks(it)
1403
1730
  pendingOutsideTapBlur = null
1404
1731
  }
@@ -1469,6 +1796,12 @@ class NativeEditorExpoView(
1469
1796
  pendingEditorUpdateEditorId = null
1470
1797
  pendingEditorUpdateRevision = 0
1471
1798
  appliedEditorUpdateRevision = 0
1799
+ pendingEditorResetUpdateJson = null
1800
+ pendingEditorResetUpdateEditorId = null
1801
+ pendingEditorResetUpdateRevision = 0
1802
+ appliedEditorResetUpdateRevision = 0
1803
+ lastEditorResetUpdateJsonProp = null
1804
+ lastEditorResetUpdateEditorIdProp = null
1472
1805
  lastDocumentVersion = null
1473
1806
  lastReadyEditorId = null
1474
1807
  toolbarState = NativeToolbarState.empty
@@ -1501,11 +1834,13 @@ class NativeEditorExpoView(
1501
1834
  return
1502
1835
  }
1503
1836
  richTextView.rebindEditorIfNeeded(
1504
- notifyListener = !hasPendingEditorUpdateForEditor(editorId)
1837
+ notifyListener = !hasPendingEditorResetUpdateForEditor(editorId) &&
1838
+ !hasPendingEditorUpdateForEditor(editorId)
1505
1839
  )
1506
1840
  if (hasPendingTheme) {
1507
1841
  pendingThemeRetryEditorId = editorId
1508
1842
  }
1843
+ applyPendingEditorResetUpdateIfNeeded()
1509
1844
  applyPendingEditorUpdateIfNeeded()
1510
1845
  applyPendingThemeIfNeeded()
1511
1846
  refreshReadyStateIfSettled()
@@ -1517,6 +1852,7 @@ class NativeEditorExpoView(
1517
1852
  if (editorId == 0L) return false
1518
1853
  if (!isAttachedToNativeWindow) return false
1519
1854
  if (richTextView.editorEditText.editorId != editorId) return false
1855
+ if (hasPendingEditorResetUpdateForCurrentEditor()) return false
1520
1856
  if (hasPendingEditorUpdateForCurrentEditor()) return false
1521
1857
  lastReadyEditorId = editorId
1522
1858
  val payload = mutableMapOf<String, Any>("editorId" to editorId)
@@ -1688,18 +2024,58 @@ class NativeEditorExpoView(
1688
2024
  if (contentHeight <= 0) return
1689
2025
  if (!force && contentHeight == lastEmittedContentHeight) return
1690
2026
  lastEmittedContentHeight = contentHeight
1691
- onContentHeightChange(
1692
- mapOf(
1693
- "contentHeight" to contentHeight,
1694
- "editorId" to richTextView.editorId
1695
- )
2027
+ val event = mapOf(
2028
+ "contentHeight" to contentHeight,
2029
+ "editorId" to richTextView.editorId
1696
2030
  )
2031
+ onContentHeightChangeForTesting?.invoke(event) ?: onContentHeightChange(event)
1697
2032
  }
1698
2033
 
1699
2034
  /** Applies an editor update from JS without echoing it back through events. */
1700
2035
  fun applyEditorUpdate(updateJson: String): Boolean =
1701
2036
  applyEditorUpdate(updateJson, scheduleViewCommandRetry = true)
1702
2037
 
2038
+ /** Applies a reset-style update from JS, discarding pending native composition. */
2039
+ fun applyEditorResetUpdate(updateJson: String): Boolean {
2040
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2041
+ val postedEditorId = richTextView.editorId
2042
+ val apply = Runnable {
2043
+ if (postedEditorId != richTextView.editorId) return@Runnable
2044
+ applyEditorResetUpdate(updateJson)
2045
+ }
2046
+ if (!post(apply)) {
2047
+ richTextView.post(apply)
2048
+ }
2049
+ return false
2050
+ }
2051
+ if (handleDestroyedCurrentEditorIfNeeded()) {
2052
+ return false
2053
+ }
2054
+ if (!isEditorReadyForNativeUpdate()) {
2055
+ return false
2056
+ }
2057
+ clearPendingEditorUpdateState(resetAppliedRevision = false)
2058
+ clearPendingViewCommandUpdateRetry()
2059
+ isApplyingJSUpdate = true
2060
+ val applied = try {
2061
+ richTextView.editorEditText.applyUpdateJSON(
2062
+ updateJson,
2063
+ refreshInputConnectionForExternalUpdate = true
2064
+ )
2065
+ clearPendingEditorUpdateDispatchQueue("jsResetUpdate")
2066
+ true
2067
+ } catch (error: Throwable) {
2068
+ Log.w(LOG_TAG, "Failed to apply JS editor reset update", error)
2069
+ false
2070
+ } finally {
2071
+ isApplyingJSUpdate = false
2072
+ }
2073
+ if (applied) {
2074
+ refreshReadyStateIfSettled()
2075
+ }
2076
+ return applied
2077
+ }
2078
+
1703
2079
  private fun isEditorReadyForNativeUpdate(): Boolean {
1704
2080
  val editorId = richTextView.editorId
1705
2081
  return editorId == 0L || (isAttachedToNativeWindow && richTextView.editorEditText.editorId == editorId)
@@ -1820,7 +2196,7 @@ class NativeEditorExpoView(
1820
2196
  if (stateJson != null) {
1821
2197
  event["stateJson"] = stateJson
1822
2198
  }
1823
- onSelectionChange(event)
2199
+ onSelectionChangeForTesting?.invoke(event) ?: onSelectionChange(event)
1824
2200
  }
1825
2201
 
1826
2202
  override fun onEditorUpdate(updateJSON: String) {
@@ -1925,7 +2301,7 @@ class NativeEditorExpoView(
1925
2301
  "updateJson" to updateJSON,
1926
2302
  "editorId" to event.editorId
1927
2303
  )
1928
- onEditorUpdate(payload)
2304
+ onEditorUpdateForTesting?.invoke(payload) ?: onEditorUpdate(payload)
1929
2305
  }
1930
2306
  val totalNanos = System.nanoTime() - startedAt
1931
2307
  richTextView.editorEditText.recordImeTraceForTesting(
@@ -1936,23 +2312,81 @@ class NativeEditorExpoView(
1936
2312
 
1937
2313
  private fun installOutsideTapBlurHandlerIfNeeded() {
1938
2314
  val window = resolveActivity(context)?.window ?: return
1939
- if (outsideTapWindow === window) return
1940
- uninstallOutsideTapBlurHandler()
1941
- NativeEditorOutsideTapDispatcher.register(window, this)
1942
- outsideTapWindow = window
2315
+ if (outsideTapWindow !== window) {
2316
+ uninstallOutsideTapBlurHandler()
2317
+ }
2318
+ if (NativeEditorOutsideTapDispatcher.register(window, this)) {
2319
+ outsideTapWindow = window
2320
+ } else if (outsideTapWindow === window) {
2321
+ outsideTapWindow = null
2322
+ }
2323
+ }
2324
+
2325
+ private fun scheduleOutsideTapBlurHandlerInstallRetry() {
2326
+ cancelPendingOutsideTapBlurHandlerInstallRetry()
2327
+ val retry = Runnable {
2328
+ pendingOutsideTapHandlerInstallRetry = null
2329
+ if (richTextView.editorEditText.hasFocus()) {
2330
+ installOutsideTapBlurHandlerIfNeeded()
2331
+ }
2332
+ }
2333
+ pendingOutsideTapHandlerInstallRetry = retry
2334
+ richTextView.editorEditText.postDelayed(retry, OUTSIDE_TAP_HANDLER_INSTALL_RETRY_DELAY_MS)
2335
+ }
2336
+
2337
+ private fun cancelPendingOutsideTapBlurHandlerInstallRetry() {
2338
+ pendingOutsideTapHandlerInstallRetry?.let {
2339
+ richTextView.editorEditText.removeCallbacks(it)
2340
+ pendingOutsideTapHandlerInstallRetry = null
2341
+ }
1943
2342
  }
1944
2343
 
1945
2344
  private fun uninstallOutsideTapBlurHandler() {
2345
+ cancelPendingOutsideTapBlurHandlerInstallRetry()
1946
2346
  val window = outsideTapWindow ?: return
1947
2347
  NativeEditorOutsideTapDispatcher.unregister(window, this)
1948
2348
  outsideTapWindow = null
1949
2349
  }
1950
2350
 
1951
- internal fun shouldScheduleOutsideTapBlurForWindowEvent(event: MotionEvent): Boolean =
1952
- isAttachedToNativeWindow &&
1953
- event.action == MotionEvent.ACTION_DOWN &&
1954
- richTextView.editorEditText.hasFocus() &&
1955
- isTouchOutsideEditor(event)
2351
+ internal fun prepareOutsideTapDecisionForWindowEvent(event: MotionEvent): NativeEditorOutsideTapDecision {
2352
+ if (!isAttachedToNativeWindow) {
2353
+ traceOutsideTap("decision ignored detached")
2354
+ return NativeEditorOutsideTapDecision.IGNORE
2355
+ }
2356
+ if (event.action != MotionEvent.ACTION_DOWN) {
2357
+ traceOutsideTap("decision ignored action=${event.action}")
2358
+ return NativeEditorOutsideTapDecision.IGNORE
2359
+ }
2360
+ if (!isEditorFocusedForOutsideTapDecision()) {
2361
+ traceOutsideTap("decision ignored not focused")
2362
+ return NativeEditorOutsideTapDecision.IGNORE
2363
+ }
2364
+
2365
+ val decision = if (isTouchOutsideEditor(event)) {
2366
+ NativeEditorOutsideTapDecision.OUTSIDE_EDITOR
2367
+ } else {
2368
+ NativeEditorOutsideTapDecision.PRESERVE_FOCUS
2369
+ }
2370
+ traceOutsideTap("decision raw=${event.rawX.toInt()},${event.rawY.toInt()} value=$decision")
2371
+ return decision
2372
+ }
2373
+
2374
+ internal fun handleOutsideTapDecisionFromWindowDispatcher(decision: NativeEditorOutsideTapDecision) {
2375
+ traceOutsideTap("handle decision=$decision")
2376
+ when (decision) {
2377
+ NativeEditorOutsideTapDecision.IGNORE -> {
2378
+ if (!richTextView.editorEditText.hasFocus()) {
2379
+ cancelPendingOutsideTapBlur()
2380
+ }
2381
+ }
2382
+ NativeEditorOutsideTapDecision.PRESERVE_FOCUS -> cancelPendingOutsideTapBlur()
2383
+ NativeEditorOutsideTapDecision.OUTSIDE_EDITOR -> {
2384
+ clearRecentToolbarTouch()
2385
+ cancelPendingToolbarRefocus()
2386
+ scheduleOutsideTapBlur()
2387
+ }
2388
+ }
2389
+ }
1956
2390
 
1957
2391
  internal fun scheduleOutsideTapBlurFromWindowDispatcher() {
1958
2392
  scheduleOutsideTapBlur()
@@ -1962,6 +2396,9 @@ class NativeEditorExpoView(
1962
2396
  cancelPendingOutsideTapBlur()
1963
2397
  }
1964
2398
 
2399
+ private fun isEditorFocusedForOutsideTapDecision(): Boolean =
2400
+ editorFocusedForOutsideTapOverrideForTesting ?: richTextView.editorEditText.hasFocus()
2401
+
1965
2402
  private fun isTouchOutsideEditor(event: MotionEvent): Boolean {
1966
2403
  if (isTouchInsideKeyboardToolbar(event)) {
1967
2404
  markRecentToolbarTouch()
@@ -1973,7 +2410,11 @@ class NativeEditorExpoView(
1973
2410
  }
1974
2411
  val rect = Rect()
1975
2412
  richTextView.editorEditText.getGlobalVisibleRect(rect)
1976
- return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
2413
+ val isOutside = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
2414
+ if (isOutside) {
2415
+ clearRecentToolbarTouch()
2416
+ }
2417
+ return isOutside
1977
2418
  }
1978
2419
 
1979
2420
  private fun markRecentToolbarTouch() {
@@ -1990,6 +2431,14 @@ class NativeEditorExpoView(
1990
2431
  return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
1991
2432
  }
1992
2433
 
2434
+ private fun consumeToolbarFocusPreservationForBlur(): Boolean {
2435
+ if (!shouldPreserveFocusAfterToolbarTouch()) {
2436
+ return false
2437
+ }
2438
+ clearRecentToolbarTouch()
2439
+ return true
2440
+ }
2441
+
1993
2442
  internal fun markRecentToolbarTouchForTesting() {
1994
2443
  markRecentToolbarTouch()
1995
2444
  }
@@ -1997,6 +2446,10 @@ class NativeEditorExpoView(
1997
2446
  internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
1998
2447
  shouldPreserveFocusAfterToolbarTouch()
1999
2448
 
2449
+ internal fun setEditorFocusedForOutsideTapDecisionForTesting(isFocused: Boolean?) {
2450
+ editorFocusedForOutsideTapOverrideForTesting = isFocused
2451
+ }
2452
+
2000
2453
  internal fun setAttachedToNativeWindowForTesting(isAttached: Boolean) {
2001
2454
  isAttachedToNativeWindow = isAttached
2002
2455
  }
@@ -2005,6 +2458,10 @@ class NativeEditorExpoView(
2005
2458
  handleAttachedToWindow()
2006
2459
  }
2007
2460
 
2461
+ internal fun traceOutsideTap(message: String) {
2462
+ onOutsideTapTraceForTesting?.invoke(message)
2463
+ }
2464
+
2008
2465
  internal fun handleDetachedFromWindowForTesting() {
2009
2466
  prepareForDetachFromWindow()
2010
2467
  handleDetachedFromWindow()
@@ -2021,6 +2478,8 @@ class NativeEditorExpoView(
2021
2478
 
2022
2479
  internal fun hasPendingOutsideTapBlurForTesting(): Boolean = pendingOutsideTapBlur != null
2023
2480
 
2481
+ internal fun isOutsideTapBlurHandlerInstalledForTesting(): Boolean = outsideTapWindow != null
2482
+
2024
2483
  internal fun hasPendingKeyboardDismissForTesting(): Boolean = pendingKeyboardDismiss != null
2025
2484
 
2026
2485
  internal fun hasPendingPreflightWakeForTesting(): Boolean = pendingPreflightWakeScheduled
@@ -2043,6 +2502,10 @@ class NativeEditorExpoView(
2043
2502
  scheduleToolbarRefocus()
2044
2503
  }
2045
2504
 
2505
+ internal fun focusFromToolbarPreserveForTesting() {
2506
+ focusInternal(cancelPendingOutsideTapBlur = false)
2507
+ }
2508
+
2046
2509
  internal fun applyAutoFocusForTesting() {
2047
2510
  applyAutoFocusIfNeeded()
2048
2511
  }
@@ -2091,12 +2554,20 @@ class NativeEditorExpoView(
2091
2554
 
2092
2555
  internal fun pendingEditorUpdateRevisionForTesting(): Int = pendingEditorUpdateRevision
2093
2556
 
2557
+ internal fun pendingEditorResetUpdateJsonForTesting(): String? = pendingEditorResetUpdateJson
2558
+
2559
+ internal fun pendingEditorResetUpdateRevisionForTesting(): Int =
2560
+ pendingEditorResetUpdateRevision
2561
+
2094
2562
  internal fun setAppliedEditorUpdateRevisionForTesting(editorUpdateRevision: Int) {
2095
2563
  appliedEditorUpdateRevision = editorUpdateRevision
2096
2564
  }
2097
2565
 
2098
2566
  internal fun pendingEditorUpdateEditorIdForTesting(): Long? = pendingEditorUpdateEditorId
2099
2567
 
2568
+ internal fun pendingEditorResetUpdateEditorIdForTesting(): Long? =
2569
+ pendingEditorResetUpdateEditorId
2570
+
2100
2571
  internal fun pendingViewCommandUpdateJsonForTesting(): String? = pendingViewCommandUpdateJson
2101
2572
 
2102
2573
  internal fun pendingViewCommandUpdateRetryAttemptsForTesting(): Int =
@@ -2136,10 +2607,10 @@ class NativeEditorExpoView(
2136
2607
  if (toolbarFramesInWindow.isEmpty()) {
2137
2608
  return false
2138
2609
  }
2139
- // toolbarFrame is in DP from React Native's measureInWindow. On Android
2140
- // that is window-relative after visible-window insets are subtracted,
2141
- // while rawX/rawY are screen pixels. Fabric/newer implementations may
2142
- // differ here, so accept both window-relative and raw-screen comparisons.
2610
+ // toolbarFrame is in DP from React Native's measureInWindow, while
2611
+ // rawX/rawY are screen pixels. Normalize the event into the visible
2612
+ // window before comparing so shifted fallback rectangles cannot
2613
+ // preserve focus for unrelated outside taps.
2143
2614
  val density = resources.displayMetrics.density
2144
2615
  val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
2145
2616
  val eventX = rawX - visibleWindowFrame.left
@@ -2153,14 +2624,7 @@ class NativeEditorExpoView(
2153
2624
  ).apply {
2154
2625
  inset(-hitSlopPx, -hitSlopPx)
2155
2626
  }
2156
- val screenFrameInPx = RectF(windowFrameInPx).apply {
2157
- offset(visibleWindowFrame.left.toFloat(), visibleWindowFrame.top.toFloat())
2158
- }
2159
- if (
2160
- windowFrameInPx.contains(rawX, rawY) ||
2161
- windowFrameInPx.contains(eventX, eventY) ||
2162
- screenFrameInPx.contains(rawX, rawY)
2163
- ) {
2627
+ if (windowFrameInPx.contains(eventX, eventY)) {
2164
2628
  return true
2165
2629
  }
2166
2630
  }
@@ -2180,6 +2644,7 @@ class NativeEditorExpoView(
2180
2644
  private const val TOOLBAR_HIT_SLOP_DP = 8f
2181
2645
  private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
2182
2646
  private const val OUTSIDE_TAP_BLUR_DELAY_MS = 100L
2647
+ private const val OUTSIDE_TAP_HANDLER_INSTALL_RETRY_DELAY_MS = 64L
2183
2648
  private const val NATIVE_ACTION_RETRY_DELAY_MS = 16L
2184
2649
  private const val EDITOR_UPDATE_EVENT_DEBOUNCE_MS = 64L
2185
2650
  private const val PENDING_UPDATE_RECOVERY_RETRY_DELAY_MS = 250L
@@ -2191,6 +2656,7 @@ class NativeEditorExpoView(
2191
2656
  }
2192
2657
 
2193
2658
  private fun resolveActivity(context: Context): Activity? {
2659
+ appContext.currentActivity?.let { return it }
2194
2660
  var current: Context? = context
2195
2661
  while (current is ContextWrapper) {
2196
2662
  if (current is Activity) return current