@onekeyfe/react-native-native-overlay 3.0.162-alpha.270 → 6.7.0-8

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.
Files changed (45) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt +37 -0
  3. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +27 -30
  4. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt +21 -0
  5. package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt +7 -2
  6. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt +112 -0
  7. package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayBackTest.kt +157 -0
  8. package/docs/SPEC.md +148 -26
  9. package/ios/NativeOverlayContainerView.swift +20 -7
  10. package/ios/NativeOverlaySheetController.swift +35 -1
  11. package/lib/module/OverlayPageHost.web.js +19 -6
  12. package/lib/module/OverlayPageHost.web.js.map +1 -1
  13. package/lib/module/OverlayStore.js +13 -1
  14. package/lib/module/OverlayStore.js.map +1 -1
  15. package/lib/module/OverlayView.web.js +107 -25
  16. package/lib/module/OverlayView.web.js.map +1 -1
  17. package/lib/module/useOverlayController.js +11 -3
  18. package/lib/module/useOverlayController.js.map +1 -1
  19. package/lib/module/web/animateTransition.js +20 -12
  20. package/lib/module/web/animateTransition.js.map +1 -1
  21. package/lib/module/web/overlayLayers.js +27 -9
  22. package/lib/module/web/overlayLayers.js.map +1 -1
  23. package/lib/module/web/useKeyboardShift.js +1 -1
  24. package/lib/module/web/useKeyboardShift.js.map +1 -1
  25. package/lib/module/web/useSheetDrag.js +22 -2
  26. package/lib/module/web/useSheetDrag.js.map +1 -1
  27. package/lib/typescript/src/OverlayPageHost.web.d.ts.map +1 -1
  28. package/lib/typescript/src/OverlayStore.d.ts +2 -0
  29. package/lib/typescript/src/OverlayStore.d.ts.map +1 -1
  30. package/lib/typescript/src/OverlayView.web.d.ts.map +1 -1
  31. package/lib/typescript/src/useOverlayController.d.ts.map +1 -1
  32. package/lib/typescript/src/web/animateTransition.d.ts +2 -2
  33. package/lib/typescript/src/web/animateTransition.d.ts.map +1 -1
  34. package/lib/typescript/src/web/overlayLayers.d.ts +3 -0
  35. package/lib/typescript/src/web/overlayLayers.d.ts.map +1 -1
  36. package/lib/typescript/src/web/useSheetDrag.d.ts.map +1 -1
  37. package/package.json +2 -1
  38. package/src/OverlayPageHost.web.tsx +24 -9
  39. package/src/OverlayStore.ts +13 -1
  40. package/src/OverlayView.web.tsx +197 -56
  41. package/src/useOverlayController.ts +11 -4
  42. package/src/web/animateTransition.ts +30 -11
  43. package/src/web/overlayLayers.ts +48 -10
  44. package/src/web/useKeyboardShift.ts +1 -1
  45. package/src/web/useSheetDrag.ts +23 -2
@@ -60,6 +60,10 @@ android {
60
60
  ]
61
61
  }
62
62
  }
63
+
64
+ testOptions {
65
+ unitTests.includeAndroidResources = true
66
+ }
63
67
  }
64
68
 
65
69
  repositories {
@@ -73,6 +77,8 @@ dependencies {
73
77
  implementation "com.facebook.react:react-android"
74
78
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
75
79
  implementation "com.google.android.material:material:1.14.0-alpha05"
80
+ testImplementation "junit:junit:4.13.2"
81
+ testImplementation "org.robolectric:robolectric:4.17"
76
82
  }
77
83
 
78
84
  react {
@@ -0,0 +1,37 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.View
4
+ import android.view.ViewGroup
5
+
6
+ /** Restores the caller's flags when a blocking overlay no longer covers it. */
7
+ internal class NativeOverlayAccessibilityIsolation {
8
+ private val hidden = mutableMapOf<View, Int>()
9
+
10
+ fun update(
11
+ root: ViewGroup,
12
+ activeContainer: View?,
13
+ entriesBelow: List<View>,
14
+ globalHost: View,
15
+ ) {
16
+ val below = mutableSetOf<View>()
17
+ if (activeContainer != null) {
18
+ var node: View = activeContainer
19
+ while (node !== root && node.parent is ViewGroup) {
20
+ val parent = node.parent as ViewGroup
21
+ for (index in 0 until parent.childCount) {
22
+ val sibling = parent.getChildAt(index)
23
+ if (sibling !== node && sibling !== globalHost) below.add(sibling)
24
+ }
25
+ node = parent
26
+ }
27
+ if (node === root) below.addAll(entriesBelow) else below.clear()
28
+ }
29
+ hidden.keys.filter { it !in below }.forEach { view ->
30
+ view.importantForAccessibility = hidden.remove(view) ?: View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
31
+ }
32
+ below.forEach { view ->
33
+ if (view !in hidden) hidden[view] = view.importantForAccessibility
34
+ view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
35
+ }
36
+ }
37
+ }
@@ -2,13 +2,13 @@ package com.onekey.nativeoverlay
2
2
 
3
3
  import android.app.Activity
4
4
  import android.view.KeyEvent
5
- import android.view.View
6
5
  import android.view.ViewGroup
7
6
  import android.view.Window
8
7
  import android.widget.FrameLayout
9
8
  import androidx.activity.ComponentActivity
10
9
  import androidx.activity.OnBackPressedCallback
11
10
  import java.util.WeakHashMap
11
+ import java.lang.ref.WeakReference
12
12
 
13
13
  /** Mirrors `OVERLAY_LEVEL_ORDER`. */
14
14
  internal enum class NativeOverlayLevel(val order: Int) {
@@ -49,12 +49,12 @@ internal class NativeOverlayHost private constructor(
49
49
  )
50
50
  private val backCallback = object : OnBackPressedCallback(false) {
51
51
  override fun handleOnBackPressed() {
52
- topBlockingEntry()?.let { entry ->
52
+ topBackEntry()?.let { entry ->
53
53
  if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
54
54
  }
55
55
  }
56
56
  }
57
- private val hiddenForAccessibility = mutableMapOf<View, Int>()
57
+ private val accessibilityIsolation = NativeOverlayAccessibilityIsolation()
58
58
 
59
59
  init {
60
60
  clipChildren = false
@@ -67,7 +67,7 @@ internal class NativeOverlayHost private constructor(
67
67
  * predictive back, so the dispatcher alone never runs first.
68
68
  */
69
69
  fun handleBackKey(event: KeyEvent): Boolean {
70
- val entry = topBlockingEntry() ?: return false
70
+ val entry = topBackEntry() ?: return false
71
71
  if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) {
72
72
  entry.onRequestDismiss?.invoke("back")
73
73
  }
@@ -109,14 +109,14 @@ internal class NativeOverlayHost private constructor(
109
109
  }
110
110
 
111
111
  fun onEntriesChanged() {
112
- val hasBlocking = topBlockingEntry() != null
113
- if (hasBlocking && !backCallback.isEnabled) {
112
+ val hasBackEntry = topBackEntry() != null
113
+ if (hasBackEntry && !backCallback.isEnabled) {
114
114
  // The most recently added callback wins; re-add so ours runs before
115
115
  // ReactActivity's and react-native-screens' callbacks.
116
116
  backCallback.remove()
117
117
  (activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback)
118
118
  }
119
- backCallback.isEnabled = hasBlocking
119
+ backCallback.isEnabled = hasBackEntry
120
120
  updateAccessibility()
121
121
  }
122
122
 
@@ -134,41 +134,38 @@ internal class NativeOverlayHost private constructor(
134
134
  }
135
135
 
136
136
  /** Global overlays win; otherwise the topmost visible page overlay. */
137
- private fun topBlockingEntry(): NativeOverlayEntryRootView? =
138
- orderedEntries().lastOrNull { it.blocking && it.isShownForInput }
139
- ?: NativeOverlayPageHostView.allHosts()
140
- .filter { it.isAttachedToWindow && it.isShown }
137
+ private fun topBackEntry(): NativeOverlayEntryRootView? =
138
+ orderedEntries().lastOrNull { (it.blocking || it.dismissOnBackPress) && it.isShownForInput }
139
+ ?: shownPageHosts()
141
140
  .flatMap { it.shownEntries() }
142
- .lastOrNull { it.blocking }
141
+ .lastOrNull { it.blocking || it.dismissOnBackPress }
142
+
143
+ private fun shownPageHosts(): List<NativeOverlayPageHostView> =
144
+ NativeOverlayPageHostView.allHosts().filter {
145
+ it.isAttachedToWindow && it.isShown && it.rootView === activity.window.decorView
146
+ }
143
147
 
144
148
  /** TalkBack only reads the topmost blocking overlay and what is above it. */
145
149
  private fun updateAccessibility() {
146
150
  val entries = orderedEntries()
147
151
  val topIndex = entries.indexOfLast { it.blocking && it.isShownForInput }
148
- val content = parent as? ViewGroup
149
- val below = mutableListOf<View>()
152
+ val content = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
150
153
  if (topIndex >= 0) {
151
- content?.let { group ->
152
- (0 until group.childCount).map(group::getChildAt).filter { it !== this }.forEach(below::add)
153
- }
154
- below.addAll(entries.subList(0, topIndex))
155
- }
156
- val restore = hiddenForAccessibility.keys.filter { it !in below }
157
- restore.forEach { view ->
158
- view.importantForAccessibility = hiddenForAccessibility.remove(view) ?: IMPORTANT_FOR_ACCESSIBILITY_AUTO
159
- }
160
- below.forEach { view ->
161
- if (view !in hiddenForAccessibility) {
162
- hiddenForAccessibility[view] = view.importantForAccessibility
163
- view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
164
- }
154
+ accessibilityIsolation.update(content, this, entries.take(topIndex), this)
155
+ return
165
156
  }
157
+ val pageEntry = shownPageHosts().flatMap { it.shownEntries() }.lastOrNull { it.blocking }
158
+ val pageHost = pageEntry?.parent as? NativeOverlayPageHostView
159
+ val below = pageHost?.shownEntries()?.takeWhile { it !== pageEntry }.orEmpty()
160
+ accessibilityIsolation.update(content, pageHost, below, this)
166
161
  }
167
162
 
168
163
  companion object {
169
- private val hosts = WeakHashMap<Activity, NativeOverlayHost>()
164
+ private val hosts = WeakHashMap<Activity, WeakReference<NativeOverlayHost>>()
170
165
 
171
166
  fun of(activity: Activity): NativeOverlayHost =
172
- hosts.getOrPut(activity) { NativeOverlayHost(activity) }
167
+ hosts[activity]?.get() ?: NativeOverlayHost(activity).also {
168
+ hosts[activity] = WeakReference(it)
169
+ }
173
170
  }
174
171
  }
@@ -3,6 +3,7 @@ package com.onekey.nativeoverlay
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
5
  import android.view.ViewGroup
6
+ import com.facebook.react.bridge.ReactContext
6
7
  import com.facebook.react.uimanager.PointerEvents
7
8
  import com.facebook.react.uimanager.ReactPointerEventsView
8
9
  import java.lang.ref.WeakReference
@@ -33,6 +34,7 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
33
34
  if (owners == suspendedOwners) return
34
35
  suspendedOwners = owners
35
36
  entries().forEach(::applySuspension)
37
+ onEntriesChanged()
36
38
  }
37
39
 
38
40
  internal fun attach(entry: NativeOverlayEntryRootView) {
@@ -44,10 +46,12 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
44
46
  }
45
47
  layoutEntries()
46
48
  applySuspension(entry)
49
+ onEntriesChanged()
47
50
  }
48
51
 
49
52
  internal fun detach(entry: NativeOverlayEntryRootView) {
50
53
  removeView(entry)
54
+ onEntriesChanged()
51
55
  }
52
56
 
53
57
  /** Visible, input-ready entries in bottom-to-top order. */
@@ -56,6 +60,23 @@ class NativeOverlayPageHostView(context: Context) : ViewGroup(context), ReactPoi
56
60
 
57
61
  fun invalidateHost() {
58
62
  if (hostKey.isNotEmpty() && registry[hostKey]?.get() === this) registry.remove(hostKey)
63
+ onEntriesChanged()
64
+ }
65
+
66
+ override fun onAttachedToWindow() {
67
+ super.onAttachedToWindow()
68
+ onEntriesChanged()
69
+ }
70
+
71
+ override fun onDetachedFromWindow() {
72
+ super.onDetachedFromWindow()
73
+ onEntriesChanged()
74
+ }
75
+
76
+ private fun onEntriesChanged() {
77
+ (context as? ReactContext)?.currentActivity?.let {
78
+ NativeOverlayHost.of(it).onEntriesChanged()
79
+ }
59
80
  }
60
81
 
61
82
  private var layoutScheduled = false
@@ -135,6 +135,7 @@ class NativeOverlayView(
135
135
  config = OverlayAnimationConfig.parse(animationConfig)
136
136
  }
137
137
  entry?.let(::applyEntryConfiguration)
138
+ notifyHostEntriesChanged()
138
139
  if (visible) {
139
140
  cycleOpen = true
140
141
  if (phase == Phase.HIDDEN || phase == Phase.EXITING) present()
@@ -200,7 +201,7 @@ class NativeOverlayView(
200
201
  cancelAnimators()
201
202
  phase = Phase.ENTERING
202
203
  target.isShownForInput = true
203
- host?.onEntriesChanged()
204
+ notifyHostEntriesChanged()
204
205
  if (blocking) {
205
206
  target.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
206
207
  }
@@ -226,7 +227,7 @@ class NativeOverlayView(
226
227
  cancelAnimators()
227
228
  phase = Phase.EXITING
228
229
  target.isShownForInput = false
229
- host?.onEntriesChanged()
230
+ notifyHostEntriesChanged()
230
231
  val transition = config.exit.reduceMotionAdjusted(isReduceMotionEnabled())
231
232
  if (transition.kind == "none") return finishDismiss("programmatic")
232
233
  animate(target, hiddenState(target, transition), 0f, transition.motion) {
@@ -389,6 +390,10 @@ class NativeOverlayView(
389
390
  }
390
391
  }
391
392
 
393
+ private fun notifyHostEntriesChanged() {
394
+ reactContext.currentActivity?.let { NativeOverlayHost.of(it).onEntriesChanged() }
395
+ }
396
+
392
397
  /**
393
398
  * While hidden, keep the React child inside this invisible view. Fabric only
394
399
  * reports onLayout for attached views, and fitted sheets size themselves
@@ -0,0 +1,112 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.View
4
+ import android.widget.FrameLayout
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.RuntimeEnvironment
10
+ import org.robolectric.annotation.Config
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [34])
14
+ class NativeOverlayAccessibilityIsolationTest {
15
+ private val context get() = RuntimeEnvironment.getApplication()
16
+
17
+ private class Tree(context: android.content.Context) {
18
+ val root = FrameLayout(context)
19
+ val reactRoot = FrameLayout(context)
20
+ val card = FrameLayout(context)
21
+ val content = View(context).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
22
+ val chrome = View(context)
23
+ val pageHost = FrameLayout(context)
24
+ val lower = View(context)
25
+ val blocker = View(context)
26
+ val above = View(context)
27
+ val globalHost = FrameLayout(context)
28
+
29
+ init {
30
+ root.addView(reactRoot)
31
+ root.addView(globalHost)
32
+ reactRoot.addView(card)
33
+ reactRoot.addView(chrome)
34
+ card.addView(content)
35
+ card.addView(pageHost)
36
+ pageHost.addView(lower)
37
+ pageHost.addView(blocker)
38
+ pageHost.addView(above)
39
+ }
40
+ }
41
+
42
+ @Test
43
+ fun pageBlockerHidesPageAndNavigationWithoutHidingTheHost() {
44
+ val tree = Tree(context)
45
+ val isolation = NativeOverlayAccessibilityIsolation()
46
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
47
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility)
48
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.chrome.importantForAccessibility)
49
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.pageHost.importantForAccessibility)
50
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility)
51
+ }
52
+
53
+ @Test
54
+ fun entriesAbovePageBlockerAndGlobalHostStayAccessible() {
55
+ val tree = Tree(context)
56
+ NativeOverlayAccessibilityIsolation().update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
57
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility)
58
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.above.importantForAccessibility)
59
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.globalHost.importantForAccessibility)
60
+ }
61
+
62
+ @Test
63
+ fun dismissalOrSuspensionRestoresOriginalFlags() {
64
+ val tree = Tree(context)
65
+ tree.lower.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
66
+ val isolation = NativeOverlayAccessibilityIsolation()
67
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
68
+ isolation.update(tree.root, null, emptyList(), tree.globalHost)
69
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
70
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, tree.lower.importantForAccessibility)
71
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility)
72
+ }
73
+
74
+ @Test
75
+ fun globalBlockerHidesReactRootThenPageIsolationReturns() {
76
+ val tree = Tree(context)
77
+ val globalLower = View(context)
78
+ tree.globalHost.addView(globalLower)
79
+ val isolation = NativeOverlayAccessibilityIsolation()
80
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
81
+ isolation.update(tree.root, tree.globalHost, listOf(globalLower), tree.globalHost)
82
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.reactRoot.importantForAccessibility)
83
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, globalLower.importantForAccessibility)
84
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
85
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
86
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.reactRoot.importantForAccessibility)
87
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, globalLower.importantForAccessibility)
88
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.content.importantForAccessibility)
89
+ }
90
+
91
+ @Test
92
+ fun detachedHostRestoresIsolationInsteadOfHidingAnotherTree() {
93
+ val tree = Tree(context)
94
+ val isolation = NativeOverlayAccessibilityIsolation()
95
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
96
+ tree.card.removeView(tree.pageHost)
97
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
98
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, tree.content.importantForAccessibility)
99
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.lower.importantForAccessibility)
100
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.chrome.importantForAccessibility)
101
+ }
102
+
103
+ @Test
104
+ fun changingTheBlockingEntryRestoresEntriesNoLongerBelowIt() {
105
+ val tree = Tree(context)
106
+ val isolation = NativeOverlayAccessibilityIsolation()
107
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower, tree.blocker), tree.globalHost)
108
+ isolation.update(tree.root, tree.pageHost, listOf(tree.lower), tree.globalHost)
109
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, tree.blocker.importantForAccessibility)
110
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, tree.lower.importantForAccessibility)
111
+ }
112
+ }
@@ -0,0 +1,157 @@
1
+ package com.onekey.nativeoverlay
2
+
3
+ import android.view.KeyEvent
4
+ import android.view.View
5
+ import android.widget.FrameLayout
6
+ import androidx.activity.ComponentActivity
7
+ import com.facebook.react.bridge.BridgeReactContext
8
+ import com.facebook.react.uimanager.ThemedReactContext
9
+ import java.lang.ref.WeakReference
10
+ import java.util.WeakHashMap
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertNotSame
14
+ import org.junit.Assert.assertSame
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Before
17
+ import org.junit.Test
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.Robolectric
20
+ import org.robolectric.RobolectricTestRunner
21
+ import org.robolectric.annotation.Config
22
+
23
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [34])
25
+ class NativeOverlayBackTest {
26
+ private lateinit var activity: ComponentActivity
27
+ private lateinit var context: ThemedReactContext
28
+ private lateinit var host: NativeOverlayHost
29
+ private val dismissals = mutableListOf<String>()
30
+
31
+ @Before
32
+ fun setUp() {
33
+ activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
34
+ val reactContext = BridgeReactContext(activity)
35
+ reactContext.onHostResume(activity)
36
+ context = ThemedReactContext(reactContext, activity, "OverlayBackTest", 1)
37
+ host = NativeOverlayHost.of(activity)
38
+ }
39
+
40
+ private fun entry(name: String, blocking: Boolean, dismissible: Boolean, order: Int = 0) =
41
+ NativeOverlayEntryRootView(context).apply {
42
+ this.blocking = blocking
43
+ dismissOnBackPress = dismissible
44
+ stackOrder = order
45
+ isShownForInput = true
46
+ onRequestDismiss = { dismissals.add("$name:$it") }
47
+ }
48
+
49
+ private fun back(action: Int = KeyEvent.ACTION_UP, flags: Int = 0) =
50
+ host.handleBackKey(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_BACK, 0, 0, 0, 0, flags))
51
+
52
+ @Test
53
+ fun dismissibleNonBlockingGlobalEntryWinsOverLowerBlocker() {
54
+ host.attach(entry("lower", true, true), NativeOverlayLevel.MODAL)
55
+ host.attach(entry("top", false, true), NativeOverlayLevel.TOAST)
56
+ assertTrue(back(KeyEvent.ACTION_DOWN))
57
+ assertEquals(emptyList<String>(), dismissals)
58
+ assertTrue(back())
59
+ assertEquals(listOf("top:back"), dismissals)
60
+ }
61
+
62
+ @Test
63
+ fun dispatcherHandlesNonBlockingEntryAndKeepsPageAccessible() {
64
+ val page = View(activity).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
65
+ activity.setContentView(page)
66
+ val top = entry("top", false, true)
67
+ host.attach(top, NativeOverlayLevel.MODAL)
68
+ activity.onBackPressedDispatcher.onBackPressed()
69
+ assertEquals(listOf("top:back"), dismissals)
70
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, page.importantForAccessibility)
71
+ host.detach(top)
72
+ assertFalse(back())
73
+ }
74
+
75
+ @Test
76
+ fun nonDismissibleBlockerConsumesBackWithoutClosingLowerEntry() {
77
+ host.attach(entry("lower", false, true), NativeOverlayLevel.MODAL)
78
+ host.attach(entry("lock", true, false), NativeOverlayLevel.LOCK)
79
+ assertTrue(back())
80
+ assertEquals(emptyList<String>(), dismissals)
81
+ }
82
+
83
+ @Test
84
+ fun passiveOrNotInputReadyEntriesDoNotTakeBack() {
85
+ host.attach(entry("passive", false, false), NativeOverlayLevel.TOAST)
86
+ host.attach(entry("hidden", true, true).apply { isShownForInput = false }, NativeOverlayLevel.LOCK)
87
+ assertFalse(back())
88
+ assertEquals(emptyList<String>(), dismissals)
89
+ }
90
+
91
+ @Test
92
+ fun canceledKeyUpDoesNotDismiss() {
93
+ host.attach(entry("top", false, true), NativeOverlayLevel.MODAL)
94
+ assertTrue(back(flags = KeyEvent.FLAG_CANCELED))
95
+ assertEquals(emptyList<String>(), dismissals)
96
+ }
97
+
98
+ @Test
99
+ fun dismissiblePageEntryWinsOverLowerPageBlockerAndSuspensionSkipsIt() {
100
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-back-test" }
101
+ activity.setContentView(pageHost)
102
+ val lower = entry("lower", true, true).apply { ownerKey = "owner" }
103
+ val top = entry("top", false, true, 1).apply { ownerKey = "owner" }
104
+ pageHost.attach(lower)
105
+ pageHost.attach(top)
106
+ assertTrue(back())
107
+ assertEquals(listOf("top:back"), dismissals)
108
+ pageHost.setSuspendedOwners("owner")
109
+ assertFalse(back())
110
+ pageHost.invalidateHost()
111
+ }
112
+
113
+ @Test
114
+ fun globalEntryTakesBackBeforePageEntryAndDismissalRestoresPageTarget() {
115
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-global-test" }
116
+ activity.setContentView(FrameLayout(activity).apply { addView(pageHost) })
117
+ pageHost.attach(entry("page", false, true))
118
+ val global = entry("global", false, true)
119
+ host.attach(global, NativeOverlayLevel.MODAL)
120
+ assertTrue(back())
121
+ host.detach(global)
122
+ assertTrue(back())
123
+ assertEquals(listOf("global:back", "page:back"), dismissals)
124
+ pageHost.invalidateHost()
125
+ }
126
+
127
+ @Test
128
+ fun hostCacheReusesLiveHostAndSeparatesActivities() {
129
+ assertSame(host, NativeOverlayHost.of(activity))
130
+ val otherActivity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
131
+ assertNotSame(host, NativeOverlayHost.of(otherActivity))
132
+ }
133
+
134
+ @Test
135
+ fun hostCacheRebuildsAfterWeakValueIsCleared() {
136
+ val field = NativeOverlayHost::class.java.getDeclaredField("hosts").apply {
137
+ isAccessible = true
138
+ }
139
+ val cache = field.get(null) as WeakHashMap<*, *>
140
+ (cache[activity] as WeakReference<*>).clear()
141
+ val replacement = NativeOverlayHost.of(activity)
142
+ assertNotSame(host, replacement)
143
+ assertSame(replacement, NativeOverlayHost.of(activity))
144
+ }
145
+
146
+ @Test
147
+ fun pageOnlyBackInterceptorRetainsItsHost() {
148
+ val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-only" }
149
+ activity.setContentView(pageHost)
150
+ pageHost.attach(entry("page", false, true))
151
+ host.installBackInterceptor()
152
+ assertTrue(activity.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)))
153
+ assertEquals(listOf("page:back"), dismissals)
154
+ assertSame(host, NativeOverlayHost.of(activity))
155
+ pageHost.invalidateHost()
156
+ }
157
+ }