Просмотр исходного кода

Merge pull request #1959 from a328929/02

修复书源页面无障碍相关问题,修复书源 替换规则管理页面底部操作,读屏软件无法聚焦的问题
Kudomaga 3 месяцев назад
Родитель
Сommit
162eb6e943

+ 32 - 2
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -11,8 +11,11 @@ import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
+import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.view.ViewCompat
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -118,6 +121,7 @@ class ReadView(
     private val upProgressThrottle = throttle(200) { post { upProgress() } }
     val autoPager = AutoPager(this)
     val isAutoPage get() = autoPager.isRunning
+    private var accessibilityPageText = ""
 
     init {
         if (!isInEditMode) {
@@ -130,10 +134,16 @@ class ReadView(
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
+        // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
+        // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
+        listOf(prevPage, curPage, nextPage).forEach {
+            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+        }
         descendantFocusability = FOCUS_BLOCK_DESCENDANTS
         importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
         isClickable = true
         isFocusable = true
+        ViewCompat.setScreenReaderFocusable(this, true)
         prevPage.invisible()
         nextPage.invisible()
         curPage.markAsMainView()
@@ -183,6 +193,9 @@ class ReadView(
         return true
     }
 
+    // Canvas 正文没有子级语义,触摸探索必须命中本页级节点。
+    override fun onInterceptHoverEvent(event: MotionEvent): Boolean = true
+
     /**
      * 触摸事件
      */
@@ -289,7 +302,9 @@ class ReadView(
 
     override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
         super.onInitializeAccessibilityNodeInfo(info)
-        info.className = ReadView::class.java.name
+        // The Canvas-rendered page is exposed as one stable text node.
+        info.className = TextView::class.java.name
+        info.text = accessibilityPageText
         info.isScrollable = true
         info.addAction(
             AccessibilityNodeInfo.AccessibilityAction(
@@ -311,6 +326,13 @@ class ReadView(
         )
     }
 
+    override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
+        super.onInitializeAccessibilityEvent(event)
+        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
+            event.contentChangeTypes = AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT
+        }
+    }
+
     override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
         return when (action) {
             AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
@@ -628,7 +650,7 @@ class ReadView(
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
             val text = pageFactory.curPage.text
-            contentDescription = text
+            updateAccessibilityPageText(text)
             if (BuildConfig.DEBUG) {
                 // Device-independent readiness/page-change signal for the debug
                 // scenario runner (uiautomator does not expose ReadView on all
@@ -661,6 +683,14 @@ class ReadView(
         callBack.screenOffTimerStart()
     }
 
+    private fun updateAccessibilityPageText(text: String) {
+        if (accessibilityPageText == text) return
+        accessibilityPageText = text
+        if (isAttachedToWindow) {
+            sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)
+        }
+    }
+
     private fun upProgress() {
         curPage.setProgress(pageFactory.curPage)
     }

+ 65 - 5
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -4,7 +4,6 @@ import android.content.ClipData
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -13,17 +12,20 @@ import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.selectable
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.minimumInteractiveComponentSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -37,6 +39,10 @@ import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
@@ -233,6 +239,7 @@ fun BookSourceScreen(
         topBarActions = {
             Box {
                 SmallPlainButton(
+                    modifier = Modifier.minimumInteractiveComponentSize(),
                     icon = AppIcons.MoreVert,
                     contentDescription = stringResource(R.string.menu),
                     onClick = { showImportOptions = true },
@@ -247,16 +254,25 @@ fun BookSourceScreen(
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_original_name),
                         isSelected = importSuccess?.keepOriginalName == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalName == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepName(importSuccess?.keepOriginalName != true)) },
                     )
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_group),
                         isSelected = importSuccess?.keepOriginalGroup == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalGroup == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepGroup(importSuccess?.keepOriginalGroup != true)) },
                     )
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.keep_enable),
                         isSelected = importSuccess?.keepOriginalEnable == true,
+                        modifier = Modifier.semantics {
+                            this.selected = importSuccess?.keepOriginalEnable == true
+                        },
                         onClick = { onIntent(BookSourceIntent.SetImportKeepEnable(importSuccess?.keepOriginalEnable != true)) },
                     )
                     RoundDropdownMenuItem(stringResource(R.string.diy_source_group), onClick = {
@@ -489,6 +505,7 @@ fun BookSourceScreen(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.group_sources_by_domain),
                 isSelected = state.groupByDomain,
+                modifier = Modifier.semantics { this.selected = state.groupByDomain },
                 onClick = { dismiss(); onIntent(BookSourceIntent.ToggleGroupByDomain) },
             )
             PillDivider()
@@ -514,6 +531,7 @@ fun BookSourceScreen(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.sort_desc),
                 isSelected = !state.sortAscending,
+                modifier = Modifier.semantics { this.selected = !state.sortAscending },
                 onClick = { dismiss(); onIntent(BookSourceIntent.ToggleSortDirection) },
             )
         }
@@ -537,11 +555,38 @@ fun BookSourceScreen(
                                 color = LegadoTheme.colorScheme.primary,
                                 modifier = Modifier
                                     .fillMaxWidth()
+                                    .semantics { heading() }
                                     .padding(horizontal = 16.dp, vertical = 8.dp),
                             )
                         }
                     }
                     item(key = item.id, contentType = "book-source") {
+                        val canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain
+                        val enabledState = stringResource(
+                            if (item.enabled) R.string.enabled else R.string.disabled
+                        )
+                        val discoveryState = if (item.hasExploreUrl) {
+                            stringResource(
+                                if (item.enabledExplore) R.string.enabled_explore
+                                else R.string.disabled_explore
+                            )
+                        } else {
+                            null
+                        }
+                        val reorderHint = if (canReorder) {
+                            stringResource(R.string.a11y_long_press_reorder)
+                        } else {
+                            null
+                        }
+                        val itemDescription = listOfNotNull(
+                            item.name,
+                            item.domain.takeUnless { it == "#" },
+                            item.group,
+                            enabledState,
+                            discoveryState,
+                            item.checkMessage,
+                            reorderHint,
+                        ).joinToString()
                         ReorderableSelectionItem(
                             state = reorderState,
                             key = item.id,
@@ -567,7 +612,7 @@ fun BookSourceScreen(
                             ).joinToString(" · ").ifBlank { null },
                             isEnabled = item.enabled,
                             isSelected = item.id in selectedIds,
-                            canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                            canReorder = canReorder,
                             inSelectionMode = selectedIds.isNotEmpty(),
                             onToggleSelection = { onIntent(BookSourceIntent.ToggleSelection(item.id)) },
                             onEnabledChange = {
@@ -578,11 +623,17 @@ fun BookSourceScreen(
                                     )
                                 )
                             },
-                            onClickEdit = { onEditSource(item.id) },
+                            contentDescription = itemDescription,
                             trailingAction = {
+                                SmallPlainButton(
+                                    modifier = Modifier.minimumInteractiveComponentSize(),
+                                    icon = AppIcons.Edit,
+                                    contentDescription = stringResource(R.string.edit),
+                                    onClick = { onEditSource(item.id) },
+                                )
                                 BookSourceItemMenu(
                                     item = item,
-                                    canMoveToEdge = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                                    canMoveToEdge = canReorder,
                                     onMoveToEdge = { toTop ->
                                         onIntent(BookSourceIntent.MoveToEdge(setOf(item.id), toTop))
                                     },
@@ -697,6 +748,7 @@ private fun CheckBookSourceSheet(
 
 private enum class CheckSheet { Run, Settings }
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 private fun BookSourceItemMenu(
     item: BookSourceItemUi,
@@ -711,6 +763,7 @@ private fun BookSourceItemMenu(
     var expanded by remember { mutableStateOf(false) }
     Box {
         SmallPlainButton(
+            modifier = Modifier.minimumInteractiveComponentSize(),
             icon = AppIcons.MoreVert,
             contentDescription = stringResource(R.string.menu),
             onClick = { expanded = true },
@@ -765,6 +818,7 @@ private fun SortMenuItem(
 ) = RoundDropdownMenuItem(
     text = stringResource(textRes),
     isSelected = state.sort == sort,
+    modifier = Modifier.semantics { this.selected = state.sort == sort },
     onClick = { dismiss(); onIntent(BookSourceIntent.SetSort(sort)) },
 )
 
@@ -848,6 +902,7 @@ private fun GroupFilterSectionTitle(text: String) {
         color = LegadoTheme.colorScheme.onSurfaceVariant,
         modifier = Modifier
             .fillMaxWidth()
+            .semantics { heading() }
             .padding(horizontal = 4.dp, vertical = 6.dp),
     )
 }
@@ -864,12 +919,17 @@ private fun GroupFilterItem(
         contentAlignment = Alignment.Center,
         modifier = Modifier
             .fillMaxWidth()
+            .heightIn(min = 48.dp)
             .clip(shape = RoundedCornerShape(12.dp))
             .background(
                 color = if (selected) LegadoTheme.colorScheme.primaryContainer
                 else LegadoTheme.colorScheme.onSheetContent
             )
-            .clickable { onSelect(value) }
+            .selectable(
+                selected = selected,
+                role = Role.RadioButton,
+                onClick = { onSelect(value) },
+            )
             .padding(horizontal = 12.dp, vertical = 8.dp),
     ) {
         AppText(

+ 7 - 2
app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.widget.components
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -105,7 +106,9 @@ fun AppScaffold(
                                 )
                                 .then(
                                     if (contentDrawsBehindBars) Modifier
-                                    else Modifier.padding(scaffoldPadding)
+                                    else Modifier
+                                        .padding(scaffoldPadding)
+                                        .consumeWindowInsets(scaffoldPadding)
                                 )
                         ) {
                             content(
@@ -147,7 +150,9 @@ fun AppScaffold(
                                 )
                                 .then(
                                     if (contentDrawsBehindBars) Modifier
-                                    else Modifier.padding(scaffoldPadding)
+                                    else Modifier
+                                        .padding(scaffoldPadding)
+                                        .consumeWindowInsets(scaffoldPadding)
                                 )
                         ) {
                             content(

+ 13 - 2
app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt

@@ -3,8 +3,14 @@ package io.legado.app.ui.widget.components
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.tappableElement
+import androidx.compose.foundation.layout.union
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.ExpandMore
 import androidx.compose.material.icons.filled.Refresh
@@ -72,10 +78,15 @@ fun SelectionBottomBar(
     val selectAllDescription = stringResource(R.string.select_all)
     val invertSelectionDescription = stringResource(R.string.invert_selection)
     val moreActionsDescription = stringResource(R.string.more_menu)
+    val safeModifier = modifier.windowInsetsPadding(
+        WindowInsets.navigationBars
+            .union(WindowInsets.tappableElement)
+            .union(WindowInsets.ime)
+    )
 
     if (isMiuix) {
         MiuixFloatingToolbar(
-            modifier = modifier
+            modifier = safeModifier
         ) {
             Row(
                 verticalAlignment = Alignment.CenterVertically,
@@ -135,7 +146,7 @@ fun SelectionBottomBar(
         }
     } else {
         HorizontalFloatingToolbar(
-            modifier = modifier,
+            modifier = safeModifier,
             expanded = true,
             leadingContent = {
                 IconButton(onClick = onSelectAll) {

+ 33 - 0
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

@@ -30,6 +30,39 @@ class ReadViewBoundaryTest {
         )
     }
 
+    @Test
+    fun `ReadView 正文保持为可刷新的读屏文本节点`() {
+        val source = stripComments(readViewSource())
+        val contracts = mapOf(
+            "读屏焦点" to Regex(
+                """ViewCompat\.setScreenReaderFocusable\(\s*this\s*,\s*true\s*\)"""
+            ),
+            "绘制子树排除" to Regex(
+                """importantForAccessibility\s*=\s*View\.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS"""
+            ),
+            "正文文本" to Regex("""info\.text\s*=\s*accessibilityPageText"""),
+            "当前页文本同步" to Regex(
+                """val\s+text\s*=\s*pageFactory\.curPage\.text\s+updateAccessibilityPageText\(\s*text\s*\)"""
+            ),
+            "触摸探索归属" to Regex(
+                """override\s+fun\s+onInterceptHoverEvent\s*\([^)]*\)\s*:\s*Boolean\s*=\s*true"""
+            ),
+            "翻页内容通知" to Regex(
+                """sendAccessibilityEvent\(\s*AccessibilityEvent\.TYPE_WINDOW_CONTENT_CHANGED\s*\)"""
+            ),
+            "文本变更类型" to Regex(
+                """event\.contentChangeTypes\s*=\s*AccessibilityEvent\.CONTENT_CHANGE_TYPE_TEXT"""
+            ),
+        )
+        val missing = contracts.filterValues { !it.containsMatchIn(source) }.keys
+        assertTrue(
+            "ReadView 的页级读屏契约缺失:${missing.joinToString()}。\n" +
+                "Canvas 正文没有独立文本节点;必须由 ReadView 保持可聚焦、提供当前页文本," +
+                "并在翻页时通知无障碍服务刷新。",
+            missing.isEmpty(),
+        )
+    }
+
     @Test
     fun `TextPageFactory 不引用业务单例`() {
         val violations = businessSingletonViolations(