From 0d78546164b53bccebb1353c9bb5529b1a590699 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Sun, 30 Aug 2026 15:07:37 +0300 Subject: [PATCH 1/5] Improve shared accessibility navigation Place page titles first within each top-app-bar traversal group, hide redundant drawer branding, and provide localized names for clear-search, App Picker More, and Logcat clipboard actions. --- .../v2ray/ang/ui/apppicker/AppPickerActivity.kt | 2 +- .../v2ray/ang/ui/compose/AccessibilityModifiers.kt | 14 ++++++++++++++ .../java/com/v2ray/ang/ui/compose/Components.kt | 11 +++++++++-- .../main/java/com/v2ray/ang/ui/main/MainDrawer.kt | 3 +++ V2rayNG/app/src/main/res/values-ar/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-bn/strings.xml | 3 ++- .../app/src/main/res/values-bqi-rIR/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-fa/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-ru/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-vi/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-zh-rCN/strings.xml | 3 ++- V2rayNG/app/src/main/res/values-zh-rTW/strings.xml | 3 ++- V2rayNG/app/src/main/res/values/strings.xml | 3 ++- 13 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt index aa82941393..052cebe664 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt @@ -159,7 +159,7 @@ fun AppPickerScreen( IconButton(onClick = { showMenu = true }) { Icon( painterResource(R.drawable.ic_more_vert_24dp), - contentDescription = null + contentDescription = stringResource(R.string.acc_more) ) } DropdownMenu( diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt new file mode 100644 index 0000000000..8b9e3ff93f --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt @@ -0,0 +1,14 @@ +package com.v2ray.ang.ui.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex + +internal fun Modifier.accessibilityTraversalGroup(): Modifier = semantics { + isTraversalGroup = true +} + +internal fun Modifier.accessibilityTraversalIndex(index: Float): Modifier = semantics { + traversalIndex = index +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt index 86027f1c57..2302153500 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt @@ -77,6 +77,7 @@ fun AppTopBar( ) { Column { TopAppBar( + modifier = Modifier.accessibilityTraversalGroup(), title = { if (isSearchActive) { SearchInputField( @@ -85,7 +86,10 @@ fun AppTopBar( placeholder = searchPlaceholder ) } else { - Text(text = title) + Text( + text = title, + modifier = Modifier.accessibilityTraversalIndex(-1f) + ) } }, navigationIcon = { @@ -151,7 +155,10 @@ private fun SearchInputField( ) if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { - Icon(painterResource(android.R.drawable.ic_menu_close_clear_cancel), "Clear") + Icon( + painter = painterResource(android.R.drawable.ic_menu_close_clear_cancel), + contentDescription = stringResource(R.string.acc_clear_search) + ) } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt index 1bd2fadc6b..b709d9c7c1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.v2ray.ang.R import com.v2ray.ang.ui.compose.AppDivider @@ -102,6 +104,7 @@ fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) -> ) Text( text = stringResource(R.string.app_name), + modifier = Modifier.semantics { hideFromAccessibility() }, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface ) diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index 378b71ae35..348ba7cb6d 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -512,13 +512,14 @@ إضافة مصدر موارد تنزيل ملفات الموارد رمز QR - نسخ السجل + نسخ السجل إلى الحافظة مسح السجل إضافة قاعدة توجيه بدء الخدمة إيقاف الخدمة رجوع بحث + مسح البحث إضافة تكوين المزيد من الخيارات Selected diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index 78effa02ea..c3b8e87085 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -512,13 +512,14 @@ অ্যাসেট উৎস যোগ করুন অ্যাসেট ফাইল ডাউনলোড করুন QR কোড - লগ কপি করুন + ক্লিপবোর্ডে লগ কপি করুন লগ মুছুন রাউটিং নিয়ম যোগ করুন সার্ভিস শুরু করুন সার্ভিস বন্ধ করুন ফিরে যান অনুসন্ধান করুন + অনুসন্ধান মুছুন কনফিগারেশন যোগ করুন আরও বিকল্প Selected diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml index f85a9418ff..873a5f1fbe 100644 --- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -512,13 +512,14 @@ ٱووردن بونچک فایلا جغرافیایی دانلود فایلا بونچک جغرافیایی کود QR - لف گیری گوزارش + لف گیری گوزارش من ویرگا روفتن گوزارش ٱووردن قانووݩ ره وستن سرویس واڌاشتن سرویس برگشت پیتینیڌن + روفتن پیتینیڌن ٱووردن کانفیگ امکووݩا بؽتر Selected diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index 116846f34c..4b0c3ad04c 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -512,13 +512,14 @@ افزودن منبع فایل‌های جغرافیایی دانلود فایل‌های منبع کد QR - کپی گزارش + کپی گزارش در کلیپ‌بورد پاک کردن گزارش افزودن قانون مسیریابی شروع سرویس توقف سرویس بازگشت جستجو + پاک کردن جستجو افزودن کانفیگ گزینه‌های بیشتر انتخاب شده diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml index ee93da422d..43d244acee 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -512,13 +512,14 @@ Добавить источник ресурсов Загрузить файлы ресурсов QR-код - Копировать журнал + Копировать журнал в буфер обмена Очистить журнал Добавить правило маршрутизации Запустить службу Остановить службу Назад Поиск + Очистить поиск Добавить профиль Дополнительные параметры Selected diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml index 4e912e424c..0b769f4344 100644 --- a/V2rayNG/app/src/main/res/values-vi/strings.xml +++ b/V2rayNG/app/src/main/res/values-vi/strings.xml @@ -512,13 +512,14 @@ Thêm nguồn tệp tài nguyên Tải xuống tệp tài nguyên Mã QR - Sao chép nhật ký + Sao chép nhật ký vào bộ nhớ tạm Xóa nhật ký Thêm quy tắc định tuyến Khởi động dịch vụ Dừng dịch vụ Quay lại Tìm kiếm + Xóa tìm kiếm Thêm cấu hình Tùy chọn khác Selected diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index 0ceb0b8820..e26423449b 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -512,13 +512,14 @@ 添加资源来源 下载资源文件 二维码 - 复制日志 + 将日志复制到剪贴板 清除日志 添加路由规则 启动服务 停止服务 返回 搜索 + 清除搜索 添加配置 更多选项 Selected diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index cb7bae5511..0f53e398fd 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -512,13 +512,14 @@ 新增資源來源 下載資源檔案 QR Code - 複製日誌 + 將日誌複製到剪貼簿 清除日誌 新增路由規則 啟動服務 停止服務 返回 搜尋 + 清除搜尋 新增設定 更多選項 Selected diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index b96ce5f2e6..0983f4af2f 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -512,13 +512,14 @@ Add asset source Download asset files QR code - Copy log + Copy log to clipboard Clear log Add routing rule Start service Stop service Back Search + Clear search Add configuration More options Selected From 81844ed779cbdf8fa1d28ea994fb20687e4cc906 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Mon, 31 Aug 2026 15:03:01 +0300 Subject: [PATCH 2/5] Hide the empty decorative drawer header from accessibility The branding text was already hidden, but its non-interactive Material Surface remained exposed as a silent accessibility stop. Hide that container as well while preserving the existing text exclusion, native drawer dismissal, item roles, and visual layout. No focus requests or locale-specific labels are introduced. Add a device regression test for hidden branding/header semantics and the native dismiss action. It fails on the previous APK and passes with the fix on API 30. All 57 unit tests and Play Store debug assembly pass. Not run: spoken TalkBack traversal, because TalkBack is not installed on the emulator. --- .../ui/main/MainDrawerAccessibilityTest.kt | 75 +++++++++++++++++++ .../java/com/v2ray/ang/ui/main/MainDrawer.kt | 2 + 2 files changed, 77 insertions(+) create mode 100644 V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt diff --git a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt new file mode 100644 index 0000000000..5720f57679 --- /dev/null +++ b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt @@ -0,0 +1,75 @@ +package com.v2ray.ang.ui.main + +import android.app.UiAutomation +import android.os.SystemClock +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.v2ray.ang.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainDrawerAccessibilityTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val automation: UiAutomation + get() = instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) + + @Test + fun decorativeHeaderIsHiddenAndNativeDismissActionStillWorks() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + lateinit var menuLabel: String + lateinit var subscriptionsLabel: String + lateinit var branding: String + scenario.onActivity { activity -> + menuLabel = activity.getString(R.string.acc_open_menu) + subscriptionsLabel = activity.getString(R.string.title_sub_setting) + branding = activity.getString(R.string.app_name) + } + + val menu = awaitNode { it.isClickable && containsLabel(it, menuLabel) } + assertTrue(menu.performAction(AccessibilityNodeInfo.ACTION_CLICK)) + + val drawerList = awaitNode { + it.className == "android.widget.ScrollView" && containsLabel(it, subscriptionsLabel) + } + val firstVisibleChild = (0 until drawerList.childCount) + .mapNotNull(drawerList::getChild) + .first { it.isVisibleToUser } + assertTrue("The decorative header must not precede the drawer actions", + firstVisibleChild.isClickable && containsLabel(firstVisibleChild, subscriptionsLabel)) + assertFalse("Branding must stay hidden from accessibility", nodes(drawerList).any { + it.isVisibleToUser && (it.text?.toString() == branding || it.contentDescription?.toString() == branding) + }) + + val dismiss = awaitNode { node -> + node.actionList.any { it.id == AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS.id } + } + assertTrue(dismiss.performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS.id)) + awaitNode { it.isClickable && containsLabel(it, menuLabel) } + } + } + + private fun containsLabel(node: AccessibilityNodeInfo, label: String): Boolean = nodes(node).any { + it.text?.toString() == label || it.contentDescription?.toString() == label + } + + private fun awaitNode(predicate: (AccessibilityNodeInfo) -> Boolean): AccessibilityNodeInfo { + val deadline = SystemClock.uptimeMillis() + 5_000L + while (SystemClock.uptimeMillis() < deadline) { + nodes(automation.rootInActiveWindow).firstOrNull { it.isVisibleToUser && predicate(it) } + ?.let { return it } + SystemClock.sleep(50) + } + throw AssertionError("Expected drawer accessibility node was not found") + } + + private fun nodes(root: AccessibilityNodeInfo?): Sequence = sequence { + if (root == null) return@sequence + yield(root) + for (index in 0 until root.childCount) yieldAll(nodes(root.getChild(index))) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt index b709d9c7c1..e8a371ca40 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainDrawer.kt @@ -83,6 +83,8 @@ fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) -> modifier = Modifier .fillMaxWidth() .height(180.dp) + // The decorative Surface must not become an empty accessibility stop. + .semantics { hideFromAccessibility() } ) { Column( modifier = Modifier From 543f6d0aa48b836fa0dcd4bcf21ebc838ed4b673 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Wed, 2 Sep 2026 19:15:32 +0300 Subject: [PATCH 3/5] Name service-button click actions Connect and Disconnect Set a state-dependent native click label on the existing start/stop floating action button. TalkBack can describe double-tap Connect or Disconnect while retaining the existing accessible button name, native button semantics and MainAction.ToggleService dispatch. Add concise translations in all nine existing locale catalogs. Do not add a second action, custom announcement, focus request or service lifecycle change. Validation: 57 Play Store debug JVM tests, compilation and app/test APK assembly; focused button/drawer emulator tests and the nine-test combined main-screen suite. The button test verifies both labels, stable accessibility focus and unchanged accessibility-click, touch, Enter, Space and D-pad activation, waiting for asynchronous native action metadata updates. Not run: listening verification in every locale. --- .../ui/main/ServiceButtonAccessibilityTest.kt | 126 ++++++++++++++++++ .../com/v2ray/ang/ui/main/MainBottomBar.kt | 5 +- .../app/src/main/res/values-ar/strings.xml | 2 + .../app/src/main/res/values-bn/strings.xml | 2 + .../src/main/res/values-bqi-rIR/strings.xml | 2 + .../app/src/main/res/values-fa/strings.xml | 2 + .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values-vi/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + V2rayNG/app/src/main/res/values/strings.xml | 2 + 11 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt diff --git a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt new file mode 100644 index 0000000000..c4d9a9d377 --- /dev/null +++ b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt @@ -0,0 +1,126 @@ +package com.v2ray.ang.ui.main + +import android.app.UiAutomation +import android.graphics.Rect +import android.os.SystemClock +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.accessibility.AccessibilityNodeInfo +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.v2ray.ang.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ServiceButtonAccessibilityTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val automation = instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) + + @Test + fun clickLabelTracksConnectionStateWithoutReplacingNativeActivation() { + val running = mutableStateOf(false) + val dispatched = mutableListOf() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + MaterialTheme { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + MainBottomBar( + displayText = "Status", + isRunning = running.value, + isDarkTheme = false, + onAction = { + dispatched.add(it) + running.value = !running.value + }, + ) + } + } + } + } + val original = awaitButton(false) + original.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) + assertTrue(awaitButton(false).isAccessibilityFocused) + assertClickLabel(original, running = false) + assertTrue(original.performAction(AccessibilityNodeInfo.ACTION_CLICK)) + instrumentation.waitForIdleSync() + assertClickLabel(awaitButton(true), running = true) + assertEquals(original, awaitButton(true)) + assertTrue(awaitButton(true).isAccessibilityFocused) + + val bounds = Rect().also { awaitButton(true).getBoundsInScreen(it) } + val time = SystemClock.uptimeMillis() + for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) { + val event = MotionEvent.obtain( + time, SystemClock.uptimeMillis(), action, + bounds.exactCenterX(), bounds.exactCenterY(), 0, + ) + automation.injectInputEvent(event, true) + event.recycle() + } + instrumentation.waitForIdleSync() + assertClickLabel(awaitButton(false), running = false) + for (keyCode in listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_DPAD_CENTER)) { + val wasRunning = running.value + val expectedRunning = !wasRunning + assertTrue(awaitButton(wasRunning).performAction(AccessibilityNodeInfo.ACTION_FOCUS)) + instrumentation.sendKeyDownUpSync(keyCode) + instrumentation.waitForIdleSync() + assertClickLabel(awaitButton(expectedRunning), expectedRunning) + } + assertEquals(List(5) { MainAction.ToggleService }, dispatched) + } + } + + private fun assertClickLabel(node: AccessibilityNodeInfo, running: Boolean) { + assertTrue(node.isClickable) + assertTrue(nodes(node).any { it.className.toString() == "android.widget.Button" }) + assertEquals( + instrumentation.targetContext.getString(if (running) R.string.acc_disconnect else R.string.acc_connect), + node.actionList.single { it.id == AccessibilityNodeInfo.ACTION_CLICK }.label?.toString(), + ) + assertEquals(1, nodes(node).count { it.isClickable }) + } + + private fun awaitButton(running: Boolean): AccessibilityNodeInfo { + val name = instrumentation.targetContext.getString(if (running) R.string.acc_stop else R.string.acc_start) + val clickLabel = instrumentation.targetContext.getString(if (running) R.string.acc_disconnect else R.string.acc_connect) + val deadline = SystemClock.uptimeMillis() + 5_000L + while (SystemClock.uptimeMillis() < deadline) { + val root = automation.rootInActiveWindow + // Wait for the fixture, not the activity's preceding real service button. + if (nodes(root).none { it.text?.toString() == "Status" || it.contentDescription?.toString() == "Status" }) { + SystemClock.sleep(50L) + continue + } + nodes(root).firstOrNull { + it.isClickable && + nodes(it).any { child -> child.contentDescription?.toString() == name } && + it.actionList.any { action -> + action.id == AccessibilityNodeInfo.ACTION_CLICK && action.label?.toString() == clickLabel + } + }?.let { return it } + SystemClock.sleep(50L) + } + throw AssertionError("Service button not found: $name\n" + nodes(automation.rootInActiveWindow).joinToString("\n") { + "${it.className}: text=${it.text}, description=${it.contentDescription}, clickable=${it.isClickable}, actions=${it.actionList}" + }) + } + + private fun nodes(node: AccessibilityNodeInfo?): Sequence = sequence { + if (node == null) return@sequence + yield(node) + repeat(node.childCount) { yieldAll(nodes(node.getChild(it))) } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt index f7c16ea76a..c317d693e6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.v2ray.ang.R @@ -67,13 +68,15 @@ fun MainBottomBar( ) } } + val serviceActionLabel = stringResource(if (isRunning) R.string.acc_disconnect else R.string.acc_connect) FloatingActionButton( onClick = { onAction(MainAction.ToggleService) }, modifier = Modifier .align(Alignment.TopEnd) .padding(end = 24.dp) .offset(y = (-28).dp) - .navigationBarsPadding(), + .navigationBarsPadding() + .semantics { onClick(label = serviceActionLabel, action = null) }, containerColor = if (isRunning) colorFabActive else if (isDarkTheme) colorFabInactiveDark else colorFabInactiveLight diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index 348ba7cb6d..4c3369d4a8 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -517,6 +517,8 @@ إضافة قاعدة توجيه بدء الخدمة إيقاف الخدمة + اتصال + قطع الاتصال رجوع بحث مسح البحث diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index c3b8e87085..4c6a7d8b49 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -517,6 +517,8 @@ রাউটিং নিয়ম যোগ করুন সার্ভিস শুরু করুন সার্ভিস বন্ধ করুন + সংযোগ করুন + সংযোগ বিচ্ছিন্ন করুন ফিরে যান অনুসন্ধান করুন অনুসন্ধান মুছুন diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml index 873a5f1fbe..956726cc30 100644 --- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -517,6 +517,8 @@ ٱووردن قانووݩ ره وستن سرویس واڌاشتن سرویس + منپیز کردن + واڌاشتن منپیز برگشت پیتینیڌن روفتن پیتینیڌن diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index 4b0c3ad04c..c2b4a68908 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -517,6 +517,8 @@ افزودن قانون مسیریابی شروع سرویس توقف سرویس + اتصال + قطع اتصال بازگشت جستجو پاک کردن جستجو diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml index 43d244acee..8bbaa42161 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -517,6 +517,8 @@ Добавить правило маршрутизации Запустить службу Остановить службу + Подключиться + Отключиться Назад Поиск Очистить поиск diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml index 0b769f4344..5d27d63957 100644 --- a/V2rayNG/app/src/main/res/values-vi/strings.xml +++ b/V2rayNG/app/src/main/res/values-vi/strings.xml @@ -517,6 +517,8 @@ Thêm quy tắc định tuyến Khởi động dịch vụ Dừng dịch vụ + Kết nối + Ngắt kết nối Quay lại Tìm kiếm Xóa tìm kiếm diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index e26423449b..d8ea08b27b 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -517,6 +517,8 @@ 添加路由规则 启动服务 停止服务 + 连接 + 断开连接 返回 搜索 清除搜索 diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index 0f53e398fd..e6f6ccea9e 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -517,6 +517,8 @@ 新增路由規則 啟動服務 停止服務 + 連線 + 中斷連線 返回 搜尋 清除搜尋 diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 0983f4af2f..bc3e1d6630 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -517,6 +517,8 @@ Add routing rule Start service Stop service + Connect + Disconnect Back Search Clear search From 3d9e6d8ad99c04599e5f8f0ad22c7dfb1465a1a2 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Sat, 5 Sep 2026 20:14:10 +0300 Subject: [PATCH 4/5] Avoid bottom-bar overlap with connection-test feedback The localized service action label and its onClick import were inserted at the same locations as the connection-test PR live region and semantic imports, causing conflicts despite independent behavior. Relocate only those two existing lines into unchanged blocks. Keep the native FAB callback, Connect/Disconnect label, and all connection-test semantics intact without copying features between the focused PRs. Validation: standalone and paired Play Store JVM tests and Kotlin compilation pass; all 45 branch pairs merge in both orders with identical trees. The ten-PR stack also merges textually in both orders; its separate upstream routing-deletion API mismatch remains outside this fix. No runtime behavior or test files changed. --- .../app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt index c317d693e6..bfc4ead66e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainBottomBar.kt @@ -21,12 +21,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.onClick import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.v2ray.ang.R @@ -43,6 +43,7 @@ fun MainBottomBar( onAction: (MainAction) -> Unit ) { Box(modifier = Modifier.fillMaxWidth()) { + val serviceActionLabel = stringResource(if (isRunning) R.string.acc_disconnect else R.string.acc_connect) Column( modifier = Modifier .fillMaxWidth() @@ -68,7 +69,6 @@ fun MainBottomBar( ) } } - val serviceActionLabel = stringResource(if (isRunning) R.string.acc_disconnect else R.string.acc_connect) FloatingActionButton( onClick = { onAction(MainAction.ToggleService) }, modifier = Modifier From 5ab00cd5642518c8382ccc3f191e6f6bf5c552db Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Sun, 6 Sep 2026 00:28:11 +0300 Subject: [PATCH 5/5] Simplify navigation semantics and label the Menu action Give the existing Menu button a localized native click-action label without replacing its callback or role. Rely on Material 3 TopAppBar to provide its traversal group and keep only the lower title traversal index, preserving title-first ordering and the import boundary with the per-app accessibility branch. Remove the two permanent Android-test files from this focused contribution. Preserve their scenarios and unpublished test edits as external local QA. Leave both drawer hiding modifiers and the bottom-bar placement that avoids Part 10 conflicts unchanged. Validation: 57 JVM tests, Play Store debug compilation and x86_64 assembly passed. A TalkBack-enabled emulator check confirmed title-before-navigation traversal links on six screens, native Menu action labeling, accessibility/touch/Enter/Space/D-pad activation, drawer hiding and dismissal, search clearing, and App Picker overflow naming. These native-tree and input checks do not claim recorded TalkBack speech. --- .../ui/main/MainDrawerAccessibilityTest.kt | 75 ----------- .../ui/main/ServiceButtonAccessibilityTest.kt | 126 ------------------ .../ang/ui/compose/AccessibilityModifiers.kt | 5 - .../com/v2ray/ang/ui/compose/Components.kt | 1 - .../java/com/v2ray/ang/ui/main/MainTopBar.kt | 10 +- 5 files changed, 8 insertions(+), 209 deletions(-) delete mode 100644 V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt delete mode 100644 V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt diff --git a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt deleted file mode 100644 index 5720f57679..0000000000 --- a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/MainDrawerAccessibilityTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.v2ray.ang.ui.main - -import android.app.UiAutomation -import android.os.SystemClock -import android.view.accessibility.AccessibilityNodeInfo -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.v2ray.ang.R -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class MainDrawerAccessibilityTest { - private val instrumentation = InstrumentationRegistry.getInstrumentation() - private val automation: UiAutomation - get() = instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) - - @Test - fun decorativeHeaderIsHiddenAndNativeDismissActionStillWorks() { - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - lateinit var menuLabel: String - lateinit var subscriptionsLabel: String - lateinit var branding: String - scenario.onActivity { activity -> - menuLabel = activity.getString(R.string.acc_open_menu) - subscriptionsLabel = activity.getString(R.string.title_sub_setting) - branding = activity.getString(R.string.app_name) - } - - val menu = awaitNode { it.isClickable && containsLabel(it, menuLabel) } - assertTrue(menu.performAction(AccessibilityNodeInfo.ACTION_CLICK)) - - val drawerList = awaitNode { - it.className == "android.widget.ScrollView" && containsLabel(it, subscriptionsLabel) - } - val firstVisibleChild = (0 until drawerList.childCount) - .mapNotNull(drawerList::getChild) - .first { it.isVisibleToUser } - assertTrue("The decorative header must not precede the drawer actions", - firstVisibleChild.isClickable && containsLabel(firstVisibleChild, subscriptionsLabel)) - assertFalse("Branding must stay hidden from accessibility", nodes(drawerList).any { - it.isVisibleToUser && (it.text?.toString() == branding || it.contentDescription?.toString() == branding) - }) - - val dismiss = awaitNode { node -> - node.actionList.any { it.id == AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS.id } - } - assertTrue(dismiss.performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS.id)) - awaitNode { it.isClickable && containsLabel(it, menuLabel) } - } - } - - private fun containsLabel(node: AccessibilityNodeInfo, label: String): Boolean = nodes(node).any { - it.text?.toString() == label || it.contentDescription?.toString() == label - } - - private fun awaitNode(predicate: (AccessibilityNodeInfo) -> Boolean): AccessibilityNodeInfo { - val deadline = SystemClock.uptimeMillis() + 5_000L - while (SystemClock.uptimeMillis() < deadline) { - nodes(automation.rootInActiveWindow).firstOrNull { it.isVisibleToUser && predicate(it) } - ?.let { return it } - SystemClock.sleep(50) - } - throw AssertionError("Expected drawer accessibility node was not found") - } - - private fun nodes(root: AccessibilityNodeInfo?): Sequence = sequence { - if (root == null) return@sequence - yield(root) - for (index in 0 until root.childCount) yieldAll(nodes(root.getChild(index))) - } -} diff --git a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt deleted file mode 100644 index c4d9a9d377..0000000000 --- a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ui/main/ServiceButtonAccessibilityTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.v2ray.ang.ui.main - -import android.app.UiAutomation -import android.graphics.Rect -import android.os.SystemClock -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.accessibility.AccessibilityNodeInfo -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.v2ray.ang.R -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ServiceButtonAccessibilityTest { - private val instrumentation = InstrumentationRegistry.getInstrumentation() - private val automation = instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) - - @Test - fun clickLabelTracksConnectionStateWithoutReplacingNativeActivation() { - val running = mutableStateOf(false) - val dispatched = mutableListOf() - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - activity.setContent { - MaterialTheme { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { - MainBottomBar( - displayText = "Status", - isRunning = running.value, - isDarkTheme = false, - onAction = { - dispatched.add(it) - running.value = !running.value - }, - ) - } - } - } - } - val original = awaitButton(false) - original.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) - assertTrue(awaitButton(false).isAccessibilityFocused) - assertClickLabel(original, running = false) - assertTrue(original.performAction(AccessibilityNodeInfo.ACTION_CLICK)) - instrumentation.waitForIdleSync() - assertClickLabel(awaitButton(true), running = true) - assertEquals(original, awaitButton(true)) - assertTrue(awaitButton(true).isAccessibilityFocused) - - val bounds = Rect().also { awaitButton(true).getBoundsInScreen(it) } - val time = SystemClock.uptimeMillis() - for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) { - val event = MotionEvent.obtain( - time, SystemClock.uptimeMillis(), action, - bounds.exactCenterX(), bounds.exactCenterY(), 0, - ) - automation.injectInputEvent(event, true) - event.recycle() - } - instrumentation.waitForIdleSync() - assertClickLabel(awaitButton(false), running = false) - for (keyCode in listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_DPAD_CENTER)) { - val wasRunning = running.value - val expectedRunning = !wasRunning - assertTrue(awaitButton(wasRunning).performAction(AccessibilityNodeInfo.ACTION_FOCUS)) - instrumentation.sendKeyDownUpSync(keyCode) - instrumentation.waitForIdleSync() - assertClickLabel(awaitButton(expectedRunning), expectedRunning) - } - assertEquals(List(5) { MainAction.ToggleService }, dispatched) - } - } - - private fun assertClickLabel(node: AccessibilityNodeInfo, running: Boolean) { - assertTrue(node.isClickable) - assertTrue(nodes(node).any { it.className.toString() == "android.widget.Button" }) - assertEquals( - instrumentation.targetContext.getString(if (running) R.string.acc_disconnect else R.string.acc_connect), - node.actionList.single { it.id == AccessibilityNodeInfo.ACTION_CLICK }.label?.toString(), - ) - assertEquals(1, nodes(node).count { it.isClickable }) - } - - private fun awaitButton(running: Boolean): AccessibilityNodeInfo { - val name = instrumentation.targetContext.getString(if (running) R.string.acc_stop else R.string.acc_start) - val clickLabel = instrumentation.targetContext.getString(if (running) R.string.acc_disconnect else R.string.acc_connect) - val deadline = SystemClock.uptimeMillis() + 5_000L - while (SystemClock.uptimeMillis() < deadline) { - val root = automation.rootInActiveWindow - // Wait for the fixture, not the activity's preceding real service button. - if (nodes(root).none { it.text?.toString() == "Status" || it.contentDescription?.toString() == "Status" }) { - SystemClock.sleep(50L) - continue - } - nodes(root).firstOrNull { - it.isClickable && - nodes(it).any { child -> child.contentDescription?.toString() == name } && - it.actionList.any { action -> - action.id == AccessibilityNodeInfo.ACTION_CLICK && action.label?.toString() == clickLabel - } - }?.let { return it } - SystemClock.sleep(50L) - } - throw AssertionError("Service button not found: $name\n" + nodes(automation.rootInActiveWindow).joinToString("\n") { - "${it.className}: text=${it.text}, description=${it.contentDescription}, clickable=${it.isClickable}, actions=${it.actionList}" - }) - } - - private fun nodes(node: AccessibilityNodeInfo?): Sequence = sequence { - if (node == null) return@sequence - yield(node) - repeat(node.childCount) { yieldAll(nodes(node.getChild(it))) } - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt index 8b9e3ff93f..2d0c4d96f0 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/AccessibilityModifiers.kt @@ -1,14 +1,9 @@ package com.v2ray.ang.ui.compose import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex -internal fun Modifier.accessibilityTraversalGroup(): Modifier = semantics { - isTraversalGroup = true -} - internal fun Modifier.accessibilityTraversalIndex(index: Float): Modifier = semantics { traversalIndex = index } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt index 2302153500..2b5204ffef 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Components.kt @@ -77,7 +77,6 @@ fun AppTopBar( ) { Column { TopAppBar( - modifier = Modifier.accessibilityTraversalGroup(), title = { if (isSearchActive) { SearchInputField( diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainTopBar.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainTopBar.kt index cc3b4f8467..aba1ea4bc9 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainTopBar.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainTopBar.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.v2ray.ang.R import com.v2ray.ang.ui.compose.AppTopBar @@ -62,8 +64,12 @@ fun MainTopBar( Icon(painterResource(R.drawable.ic_arrow_back_24dp), contentDescription = stringResource(R.string.acc_back)) } } else { - IconButton(onClick = onMenuClick) { - Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = stringResource(R.string.acc_open_menu)) + val menuLabel = stringResource(R.string.acc_open_menu) + IconButton( + onClick = onMenuClick, + modifier = Modifier.semantics { onClick(label = menuLabel, action = null) }, + ) { + Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = menuLabel) } } },