@onekeyfe/react-native-native-overlay 3.0.162-alpha.270 → 6.7.0-alpha.335
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +6 -0
- package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolation.kt +37 -0
- package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayHost.kt +22 -28
- package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayPageHostView.kt +21 -0
- package/android/src/main/java/com/onekey/nativeoverlay/NativeOverlayView.kt +7 -2
- package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt +112 -0
- package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayBackTest.kt +122 -0
- package/docs/SPEC.md +91 -10
- package/lib/module/OverlayPageHost.web.js +19 -6
- package/lib/module/OverlayPageHost.web.js.map +1 -1
- package/lib/module/OverlayView.web.js +62 -10
- package/lib/module/OverlayView.web.js.map +1 -1
- package/lib/module/useOverlayController.js +7 -3
- package/lib/module/useOverlayController.js.map +1 -1
- package/lib/module/web/overlayLayers.js +11 -9
- package/lib/module/web/overlayLayers.js.map +1 -1
- package/lib/module/web/useKeyboardShift.js +1 -1
- package/lib/module/web/useKeyboardShift.js.map +1 -1
- package/lib/module/web/useSheetDrag.js +3 -0
- package/lib/module/web/useSheetDrag.js.map +1 -1
- package/lib/typescript/src/OverlayPageHost.web.d.ts.map +1 -1
- package/lib/typescript/src/OverlayView.web.d.ts.map +1 -1
- package/lib/typescript/src/useOverlayController.d.ts.map +1 -1
- package/lib/typescript/src/web/overlayLayers.d.ts +2 -0
- package/lib/typescript/src/web/overlayLayers.d.ts.map +1 -1
- package/lib/typescript/src/web/useSheetDrag.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/OverlayPageHost.web.tsx +24 -9
- package/src/OverlayView.web.tsx +134 -41
- package/src/useOverlayController.ts +7 -4
- package/src/web/overlayLayers.ts +28 -10
- package/src/web/useKeyboardShift.ts +1 -1
- package/src/web/useSheetDrag.ts +3 -0
package/android/build.gradle
CHANGED
|
@@ -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,7 +2,6 @@ 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
|
|
@@ -49,12 +48,12 @@ internal class NativeOverlayHost private constructor(
|
|
|
49
48
|
)
|
|
50
49
|
private val backCallback = object : OnBackPressedCallback(false) {
|
|
51
50
|
override fun handleOnBackPressed() {
|
|
52
|
-
|
|
51
|
+
topBackEntry()?.let { entry ->
|
|
53
52
|
if (entry.dismissOnBackPress) entry.onRequestDismiss?.invoke("back")
|
|
54
53
|
}
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
|
-
private val
|
|
56
|
+
private val accessibilityIsolation = NativeOverlayAccessibilityIsolation()
|
|
58
57
|
|
|
59
58
|
init {
|
|
60
59
|
clipChildren = false
|
|
@@ -67,7 +66,7 @@ internal class NativeOverlayHost private constructor(
|
|
|
67
66
|
* predictive back, so the dispatcher alone never runs first.
|
|
68
67
|
*/
|
|
69
68
|
fun handleBackKey(event: KeyEvent): Boolean {
|
|
70
|
-
val entry =
|
|
69
|
+
val entry = topBackEntry() ?: return false
|
|
71
70
|
if (event.action == KeyEvent.ACTION_UP && !event.isCanceled && entry.dismissOnBackPress) {
|
|
72
71
|
entry.onRequestDismiss?.invoke("back")
|
|
73
72
|
}
|
|
@@ -109,14 +108,14 @@ internal class NativeOverlayHost private constructor(
|
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
fun onEntriesChanged() {
|
|
112
|
-
val
|
|
113
|
-
if (
|
|
111
|
+
val hasBackEntry = topBackEntry() != null
|
|
112
|
+
if (hasBackEntry && !backCallback.isEnabled) {
|
|
114
113
|
// The most recently added callback wins; re-add so ours runs before
|
|
115
114
|
// ReactActivity's and react-native-screens' callbacks.
|
|
116
115
|
backCallback.remove()
|
|
117
116
|
(activity as? ComponentActivity)?.onBackPressedDispatcher?.addCallback(backCallback)
|
|
118
117
|
}
|
|
119
|
-
backCallback.isEnabled =
|
|
118
|
+
backCallback.isEnabled = hasBackEntry
|
|
120
119
|
updateAccessibility()
|
|
121
120
|
}
|
|
122
121
|
|
|
@@ -134,35 +133,30 @@ internal class NativeOverlayHost private constructor(
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/** Global overlays win; otherwise the topmost visible page overlay. */
|
|
137
|
-
private fun
|
|
138
|
-
orderedEntries().lastOrNull { it.blocking && it.isShownForInput }
|
|
139
|
-
?:
|
|
140
|
-
.filter { it.isAttachedToWindow && it.isShown }
|
|
136
|
+
private fun topBackEntry(): NativeOverlayEntryRootView? =
|
|
137
|
+
orderedEntries().lastOrNull { (it.blocking || it.dismissOnBackPress) && it.isShownForInput }
|
|
138
|
+
?: shownPageHosts()
|
|
141
139
|
.flatMap { it.shownEntries() }
|
|
142
|
-
.lastOrNull { it.blocking }
|
|
140
|
+
.lastOrNull { it.blocking || it.dismissOnBackPress }
|
|
141
|
+
|
|
142
|
+
private fun shownPageHosts(): List<NativeOverlayPageHostView> =
|
|
143
|
+
NativeOverlayPageHostView.allHosts().filter {
|
|
144
|
+
it.isAttachedToWindow && it.isShown && it.rootView === activity.window.decorView
|
|
145
|
+
}
|
|
143
146
|
|
|
144
147
|
/** TalkBack only reads the topmost blocking overlay and what is above it. */
|
|
145
148
|
private fun updateAccessibility() {
|
|
146
149
|
val entries = orderedEntries()
|
|
147
150
|
val topIndex = entries.indexOfLast { it.blocking && it.isShownForInput }
|
|
148
|
-
val content =
|
|
149
|
-
val below = mutableListOf<View>()
|
|
151
|
+
val content = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
|
|
150
152
|
if (topIndex >= 0) {
|
|
151
|
-
content
|
|
152
|
-
|
|
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
|
-
}
|
|
153
|
+
accessibilityIsolation.update(content, this, entries.take(topIndex), this)
|
|
154
|
+
return
|
|
165
155
|
}
|
|
156
|
+
val pageEntry = shownPageHosts().flatMap { it.shownEntries() }.lastOrNull { it.blocking }
|
|
157
|
+
val pageHost = pageEntry?.parent as? NativeOverlayPageHostView
|
|
158
|
+
val below = pageHost?.shownEntries()?.takeWhile { it !== pageEntry }.orEmpty()
|
|
159
|
+
accessibilityIsolation.update(content, pageHost, below, this)
|
|
166
160
|
}
|
|
167
161
|
|
|
168
162
|
companion object {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/android/src/test/java/com/onekey/nativeoverlay/NativeOverlayAccessibilityIsolationTest.kt
ADDED
|
@@ -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,122 @@
|
|
|
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 org.junit.Assert.assertEquals
|
|
10
|
+
import org.junit.Assert.assertFalse
|
|
11
|
+
import org.junit.Assert.assertTrue
|
|
12
|
+
import org.junit.Before
|
|
13
|
+
import org.junit.Test
|
|
14
|
+
import org.junit.runner.RunWith
|
|
15
|
+
import org.robolectric.Robolectric
|
|
16
|
+
import org.robolectric.RobolectricTestRunner
|
|
17
|
+
import org.robolectric.annotation.Config
|
|
18
|
+
|
|
19
|
+
@RunWith(RobolectricTestRunner::class)
|
|
20
|
+
@Config(sdk = [34])
|
|
21
|
+
class NativeOverlayBackTest {
|
|
22
|
+
private lateinit var activity: ComponentActivity
|
|
23
|
+
private lateinit var context: ThemedReactContext
|
|
24
|
+
private lateinit var host: NativeOverlayHost
|
|
25
|
+
private val dismissals = mutableListOf<String>()
|
|
26
|
+
|
|
27
|
+
@Before
|
|
28
|
+
fun setUp() {
|
|
29
|
+
activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible().get()
|
|
30
|
+
val reactContext = BridgeReactContext(activity)
|
|
31
|
+
reactContext.onHostResume(activity)
|
|
32
|
+
context = ThemedReactContext(reactContext, activity, "OverlayBackTest", 1)
|
|
33
|
+
host = NativeOverlayHost.of(activity)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private fun entry(name: String, blocking: Boolean, dismissible: Boolean, order: Int = 0) =
|
|
37
|
+
NativeOverlayEntryRootView(context).apply {
|
|
38
|
+
this.blocking = blocking
|
|
39
|
+
dismissOnBackPress = dismissible
|
|
40
|
+
stackOrder = order
|
|
41
|
+
isShownForInput = true
|
|
42
|
+
onRequestDismiss = { dismissals.add("$name:$it") }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private fun back(action: Int = KeyEvent.ACTION_UP, flags: Int = 0) =
|
|
46
|
+
host.handleBackKey(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_BACK, 0, 0, 0, 0, flags))
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun dismissibleNonBlockingGlobalEntryWinsOverLowerBlocker() {
|
|
50
|
+
host.attach(entry("lower", true, true), NativeOverlayLevel.MODAL)
|
|
51
|
+
host.attach(entry("top", false, true), NativeOverlayLevel.TOAST)
|
|
52
|
+
assertTrue(back(KeyEvent.ACTION_DOWN))
|
|
53
|
+
assertEquals(emptyList<String>(), dismissals)
|
|
54
|
+
assertTrue(back())
|
|
55
|
+
assertEquals(listOf("top:back"), dismissals)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun dispatcherHandlesNonBlockingEntryAndKeepsPageAccessible() {
|
|
60
|
+
val page = View(activity).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }
|
|
61
|
+
activity.setContentView(page)
|
|
62
|
+
val top = entry("top", false, true)
|
|
63
|
+
host.attach(top, NativeOverlayLevel.MODAL)
|
|
64
|
+
activity.onBackPressedDispatcher.onBackPressed()
|
|
65
|
+
assertEquals(listOf("top:back"), dismissals)
|
|
66
|
+
assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_YES, page.importantForAccessibility)
|
|
67
|
+
host.detach(top)
|
|
68
|
+
assertFalse(back())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun nonDismissibleBlockerConsumesBackWithoutClosingLowerEntry() {
|
|
73
|
+
host.attach(entry("lower", false, true), NativeOverlayLevel.MODAL)
|
|
74
|
+
host.attach(entry("lock", true, false), NativeOverlayLevel.LOCK)
|
|
75
|
+
assertTrue(back())
|
|
76
|
+
assertEquals(emptyList<String>(), dismissals)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@Test
|
|
80
|
+
fun passiveOrNotInputReadyEntriesDoNotTakeBack() {
|
|
81
|
+
host.attach(entry("passive", false, false), NativeOverlayLevel.TOAST)
|
|
82
|
+
host.attach(entry("hidden", true, true).apply { isShownForInput = false }, NativeOverlayLevel.LOCK)
|
|
83
|
+
assertFalse(back())
|
|
84
|
+
assertEquals(emptyList<String>(), dismissals)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun canceledKeyUpDoesNotDismiss() {
|
|
89
|
+
host.attach(entry("top", false, true), NativeOverlayLevel.MODAL)
|
|
90
|
+
assertTrue(back(flags = KeyEvent.FLAG_CANCELED))
|
|
91
|
+
assertEquals(emptyList<String>(), dismissals)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Test
|
|
95
|
+
fun dismissiblePageEntryWinsOverLowerPageBlockerAndSuspensionSkipsIt() {
|
|
96
|
+
val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-back-test" }
|
|
97
|
+
activity.setContentView(pageHost)
|
|
98
|
+
val lower = entry("lower", true, true).apply { ownerKey = "owner" }
|
|
99
|
+
val top = entry("top", false, true, 1).apply { ownerKey = "owner" }
|
|
100
|
+
pageHost.attach(lower)
|
|
101
|
+
pageHost.attach(top)
|
|
102
|
+
assertTrue(back())
|
|
103
|
+
assertEquals(listOf("top:back"), dismissals)
|
|
104
|
+
pageHost.setSuspendedOwners("owner")
|
|
105
|
+
assertFalse(back())
|
|
106
|
+
pageHost.invalidateHost()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Test
|
|
110
|
+
fun globalEntryTakesBackBeforePageEntryAndDismissalRestoresPageTarget() {
|
|
111
|
+
val pageHost = NativeOverlayPageHostView(context).apply { hostKey = "page-global-test" }
|
|
112
|
+
activity.setContentView(FrameLayout(activity).apply { addView(pageHost) })
|
|
113
|
+
pageHost.attach(entry("page", false, true))
|
|
114
|
+
val global = entry("global", false, true)
|
|
115
|
+
host.attach(global, NativeOverlayLevel.MODAL)
|
|
116
|
+
assertTrue(back())
|
|
117
|
+
host.detach(global)
|
|
118
|
+
assertTrue(back())
|
|
119
|
+
assertEquals(listOf("global:back", "page:back"), dismissals)
|
|
120
|
+
pageHost.invalidateHost()
|
|
121
|
+
}
|
|
122
|
+
}
|
package/docs/SPEC.md
CHANGED
|
@@ -35,12 +35,13 @@ retains its original implementation attribution in source comments.
|
|
|
35
35
|
|
|
36
36
|
### Release boundary
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
workspace
|
|
43
|
-
|
|
38
|
+
The earlier `3.0.162-alpha.272` release remains immutable. This branch now
|
|
39
|
+
includes the current main SDK baseline, with all publishable workspaces aligned
|
|
40
|
+
before release. Use `package-publish` with `npm_dist_tag=next` and an empty
|
|
41
|
+
`only_workspace` to generate a fresh `6.7.0-alpha.N` preview, publish every
|
|
42
|
+
workspace, and verify every registry version. Preserve `latest` and keep both
|
|
43
|
+
repository PRs open. App consumers pin the verified exact preview version and
|
|
44
|
+
refresh dependency and native locks before installed-version acceptance.
|
|
44
45
|
|
|
45
46
|
## 5.1 Purpose, scope, and non-goals
|
|
46
47
|
|
|
@@ -136,6 +137,11 @@ visible)`, `removePage(ownerKey)`.
|
|
|
136
137
|
|
|
137
138
|
- `usePageOverlayHost()` resolves `hostKey` and `ownerKey` from navigation
|
|
138
139
|
context.
|
|
140
|
+
- A declarative page overlay keeps `scope: 'page'` while either key is
|
|
141
|
+
unavailable. It does not request or present an entry until both keys are
|
|
142
|
+
available; becoming ready while visible requests it in the page lane.
|
|
143
|
+
Hiding or unmounting before readiness creates no entry or close callback.
|
|
144
|
+
Direct store requests still throw for missing page keys.
|
|
139
145
|
- `useInPageDialog`, `useInModalDialog`, and `useInTabDialog` become aliases of
|
|
140
146
|
page scope. Tab scope is removed.
|
|
141
147
|
|
|
@@ -208,6 +214,30 @@ request ─► queued ─► active ─► (enter anim) presented
|
|
|
208
214
|
- Threads: store mutations happen on the JS thread. Native animation and layout
|
|
209
215
|
run on the UI thread. Events are delivered as direct events.
|
|
210
216
|
|
|
217
|
+
### Web presentation updates (OK-50653)
|
|
218
|
+
|
|
219
|
+
For an open Web overlay that keeps its portal host, changing `presentation`
|
|
220
|
+
between sheet and a non-sheet frame MUST preserve the mounted content subtree.
|
|
221
|
+
Its props and React context continue to update normally. Changing the optional
|
|
222
|
+
sheet handle MUST NOT move the content into a different React child slot.
|
|
223
|
+
Closing without `keepContentMounted` still unmounts content after exit; reopening
|
|
224
|
+
creates a fresh content lifecycle. Business entity changes and draft resets
|
|
225
|
+
remain caller-owned; the overlay does not cache or persist form values.
|
|
226
|
+
|
|
227
|
+
The stable sheet shell must not change non-sheet animation origins or keyboard
|
|
228
|
+
avoidance geometry. Switching away from a sheet clears any pan offset and its
|
|
229
|
+
backdrop opacity override, and releases an active pointer capture.
|
|
230
|
+
|
|
231
|
+
Conformance: `src/__tests__/draftRetention.web.test.ts` exercises the real Web
|
|
232
|
+
host, portal and store for presentation changes, live props/context/callbacks,
|
|
233
|
+
close/reopen, animation origins, keyboard geometry and an interrupted pan.
|
|
234
|
+
Chrome extension side-panel and ordinary-tab checks use synthetic content;
|
|
235
|
+
this correction does not claim new iOS/Android runtime verification.
|
|
236
|
+
|
|
237
|
+
These source changes require a new published package version before the app
|
|
238
|
+
can consume them from npm. This fix leaves package versions and lockfiles at
|
|
239
|
+
their existing values and does not publish a release.
|
|
240
|
+
|
|
211
241
|
## 5.5 Data, cache, and identity
|
|
212
242
|
|
|
213
243
|
- Identity is the entry `id`: caller-provided, otherwise `overlay-<seq>`.
|
|
@@ -216,6 +246,17 @@ request ─► queued ─► active ─► (enter anim) presented
|
|
|
216
246
|
|
|
217
247
|
## 5.6 Platform contract
|
|
218
248
|
|
|
249
|
+
### Web keyboard focus containment
|
|
250
|
+
|
|
251
|
+
The topmost active, unsuspended blocking Web entry MUST contain sequential
|
|
252
|
+
keyboard focus. Tab from its last tabbable child wraps to the first;
|
|
253
|
+
Shift+Tab from its first wraps to the last. From the entry root, focus enters
|
|
254
|
+
the first/last child respectively. With no tabbable children, focus stays on
|
|
255
|
+
the entry root. Disabled, negative-tabindex, hidden and inert subtrees are
|
|
256
|
+
excluded. Positive tabindex values precede ordinary DOM-order tab stops.
|
|
257
|
+
Non-blocking, lower, closing, suspended and parked entries MUST NOT run a
|
|
258
|
+
focus loop. Existing focus entry/restore and Escape policies are unchanged.
|
|
259
|
+
|
|
219
260
|
| Concern | iOS | Android | Web |
|
|
220
261
|
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
221
262
|
| Global level host | One passthrough `UIWindow` per active level per `UIWindowScene`, ordered by `windowLevel`. `lock` sits above RN Alert (2001). | One `OverlayHost` FrameLayout in `android.R.id.content` above the ReactRootView, with one child container per level. | Sibling `div[data-onekey-layer]` roots under `body`, one z-index band per level (`OVERLAY_WEB_Z_INDEX_BASE`). |
|
|
@@ -224,7 +265,7 @@ request ─► queued ─► active ─► (enter anim) presented
|
|
|
224
265
|
| Page host | Fabric `PageOverlayHost` as the last child of the root-route screen. Custom-drawn sheet, because UIKit sheets cannot be page-scoped. | The same host inside the root-route screen. The ReactRootView already dispatches touches, so no nested RootView. | `position: absolute` host inside the root-route card. |
|
|
225
266
|
| Header / tab bar coverage (page) | Covered: the host is above the tab controller and navigation bars. | Covered. | Covered. |
|
|
226
267
|
| Touch passthrough (toast, debug, box-none) | `hitTest` returns nil on the window or root view. | A non-blocking entry hit-tests its React content with `TouchTargetHelper` (honoring `box-none`) and falls through to the FrameLayout sibling on a miss. | `pointer-events: none` root, `auto` per entry. |
|
|
227
|
-
| Back / Escape | Escape via `accessibilityPerformEscape`. The interactive pop gesture is disabled while a blocking page overlay is shown. | A `Window.Callback` wrapper consumes `KEYCODE_BACK`
|
|
268
|
+
| Back / Escape | Escape via `accessibilityPerformEscape`. The interactive pop gesture is disabled while a blocking page overlay is shown. | A `Window.Callback` wrapper consumes `KEYCODE_BACK` for the topmost input-ready blocking or dismissible overlay. `ReactActivity.onBackPressed` hands back to JS BackHandler (react-navigation) before the `OnBackPressedDispatcher`, and the app opts out of predictive back, so a dispatcher callback alone never runs first. The dispatcher callback is kept for the predictive-back path. The IME still gets back first to close the keyboard. | Capture-phase `keydown` that ignores IME composition. |
|
|
228
269
|
| Modality / accessibility | `accessibilityViewIsModal` on the blocking level window; `.screenChanged` posted on present. | `IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS` on the content below. | `inert` on the app root and lower layers, `role=dialog`, `aria-modal`, focus trap and restore. |
|
|
229
270
|
| Keyboard | System sheet avoidance; `keyboardLayoutGuide` for custom presentations. The main window regains key status on dismiss. | `WindowInsetsAnimationCompat` per sheet (the app uses `adjustPan`). | `visualViewport`. |
|
|
230
271
|
| Animation engine | `UIViewPropertyAnimator` + `UISpringTimingParameters(mass:stiffness:damping:)`, which maps 1:1 and supports damping ratio > 1. | `SpringAnimation` with `stiffness = k/m` and `dampingRatio`; `PathInterpolator` for timing curves. | WAAPI `element.animate`. Timing presets use `cubic-bezier`; custom springs use `linear()`. |
|
|
@@ -273,10 +314,47 @@ Known divergences:
|
|
|
273
314
|
such as the DatePicker month-row interpolation, are clipped inside the
|
|
274
315
|
content.
|
|
275
316
|
|
|
317
|
+
### Page interaction and accessibility isolation (review correction)
|
|
318
|
+
|
|
319
|
+
The existing ordering and suspension contract also applies when a page entry
|
|
320
|
+
mounts after its owner was suspended. Such entries MUST remain hidden and inert
|
|
321
|
+
until that owner resumes. Resuming an owner MUST NOT clear isolation imposed by
|
|
322
|
+
a higher blocking entry. Web page isolation compares level first, then request
|
|
323
|
+
order within that level; dismissal restores entries no longer below a blocker.
|
|
324
|
+
|
|
325
|
+
On Android, a blocking page entry MUST hide underlying page/navigation siblings
|
|
326
|
+
from accessibility while preserving the page host and the blocking entry. Entries
|
|
327
|
+
above it, including non-blocking global overlays, remain accessible. Global
|
|
328
|
+
blockers still win over page entries. Attachment, detachment, suspension,
|
|
329
|
+
resumption, input-readiness and blocking changes MUST refresh isolation and
|
|
330
|
+
restore each view's original accessibility flag when it is no longer covered.
|
|
331
|
+
|
|
332
|
+
These are corrections within the existing contract, not new public props. DOM
|
|
333
|
+
and Robolectric tests will verify lifecycle and native flags; manual TalkBack
|
|
334
|
+
navigation remains a separate runtime acceptance item.
|
|
335
|
+
|
|
276
336
|
## 5.9 Conformance and acceptance
|
|
277
337
|
|
|
278
338
|
Automated (P0):
|
|
279
339
|
|
|
340
|
+
- `src/__tests__/focusTrap.web.test.ts` mounts real Web overlays and checks
|
|
341
|
+
both Tab boundaries, root/empty fallback, inaccessible children, positive
|
|
342
|
+
tabindex order, top-entry precedence, page scope, suspension and parking.
|
|
343
|
+
- `src/__tests__/useOverlayController.test.ts` exercises the actual hook and
|
|
344
|
+
page scope providers: missing/empty keys, delayed readiness, one request,
|
|
345
|
+
page suspension/removal, hiding/unmounting before readiness and global scope.
|
|
346
|
+
- `NativeOverlayBackTest` exercises the Android host's key interceptor and
|
|
347
|
+
Back dispatcher with actual entry/page views: dismissible non-blockers,
|
|
348
|
+
blockers, input readiness, canceled key-up, global/page precedence,
|
|
349
|
+
suspension and blocking-only accessibility isolation.
|
|
350
|
+
- `src/__tests__/pageIsolation.web.test.ts` mounts a real React portal in a
|
|
351
|
+
DOM host and verifies late suspension, level ordering and restoration after
|
|
352
|
+
the blocking entry is removed.
|
|
353
|
+
- `NativeOverlayAccessibilityIsolationTest` runs six Robolectric cases against
|
|
354
|
+
Android views: page/navigation siblings, entries above the blocker, original
|
|
355
|
+
flags, global/page transitions, detached hosts and changing blockers. These
|
|
356
|
+
tests do not replace manual TalkBack navigation.
|
|
357
|
+
|
|
280
358
|
- `src/__tests__/OverlayStore.test.ts` covers:
|
|
281
359
|
- level ordering regardless of request order;
|
|
282
360
|
- page below global;
|
|
@@ -304,7 +382,10 @@ Runtime matrix, required per platform before migrating callers (P1–P3):
|
|
|
304
382
|
- switch tab and come back: it restores;
|
|
305
383
|
- remove the page: it closes with `page-removed`;
|
|
306
384
|
- the native header and tab bar are covered.
|
|
307
|
-
5. Back/Escape reaches the topmost
|
|
385
|
+
5. Back/Escape reaches the topmost input-ready overlay that is blocking or
|
|
386
|
+
explicitly dismissible, global before page. A non-dismissible blocker
|
|
387
|
+
consumes Back without closing; a passive non-blocking entry passes through.
|
|
388
|
+
Accessibility isolation remains blocking-only. The lock swallows Back.
|
|
308
389
|
6. Keyboard: an input in a global sheet, a page sheet and a secure dialog.
|
|
309
390
|
7. iOS: a system picker opened from an overlay appears above it.
|
|
310
391
|
8. Split view: page overlays stay in their own pane.
|
|
@@ -335,8 +416,8 @@ P3 page scope notes:
|
|
|
335
416
|
their global sheet implementation inside the page host.
|
|
336
417
|
- Android back also resolves page entries: global overlays first, then the
|
|
337
418
|
topmost visible page overlay.
|
|
338
|
-
- A page
|
|
339
|
-
scope.
|
|
419
|
+
- A declarative page overlay waits for a resolvable host and owner; it never
|
|
420
|
+
falls back to global scope. Direct store requests require both keys.
|
|
340
421
|
|
|
341
422
|
P2 sheet notes:
|
|
342
423
|
|