diff --git a/app/src/main/java/net/megaproxy487/AppNavigation.kt b/app/src/main/java/net/megaproxy487/AppNavigation.kt index f9490f5..f2975dc 100644 --- a/app/src/main/java/net/megaproxy487/AppNavigation.kt +++ b/app/src/main/java/net/megaproxy487/AppNavigation.kt @@ -30,8 +30,7 @@ internal object AppRoute { const val MTU = "mtu" const val FAILOVER = "failover" const val VISIBILITY = "visibility" - const val SPEED = "speed" - const val CONNECTION_TEST = "connection-test" + const val DIAGNOSTICS = "diagnostics" const val DIAGNOSTIC_LOG = "diagnostic-log" const val SSH_HOST_KEY = "ssh-host-key" @@ -47,8 +46,7 @@ internal object AppRoute { MTU, FAILOVER, VISIBILITY, - SPEED, - CONNECTION_TEST, + DIAGNOSTICS, DIAGNOSTIC_LOG, SSH_HOST_KEY, ) @@ -113,8 +111,7 @@ internal fun MegaProxyNavHost( readConnectionStats = readConnectionStats, onOpenSettings = { navController.navigate(AppRoute.SETTINGS) }, onOpenVisibility = { navController.navigate(AppRoute.VISIBILITY) }, - onOpenSpeed = { navController.navigate(AppRoute.SPEED) }, - onOpenConnectionTest = { navController.navigate(AppRoute.CONNECTION_TEST) }, + onOpenDiagnostics = { navController.navigate(AppRoute.DIAGNOSTICS) }, onEditProfile = { navController.navigate(AppRoute.profileEditor(it)) }, ) } } @@ -140,8 +137,7 @@ internal fun MegaProxyNavHost( composable(AppRoute.VISIBILITY) { ScreenDestination(AppRoute.VISIBILITY) { VisibilityScreen(activity, back, runVisibilityNetworkChecks) } } - composable(AppRoute.SPEED) { ScreenDestination(AppRoute.SPEED) { SpeedTestScreen(activity, back) } } - composable(AppRoute.CONNECTION_TEST) { ScreenDestination(AppRoute.CONNECTION_TEST) { ConnectionTestScreen(activity, autoStart = true, onBack = back) } } + composable(AppRoute.DIAGNOSTICS) { ScreenDestination(AppRoute.DIAGNOSTICS) { DiagnosticsScreen(activity, autoStart = true, onBack = back) } } composable(AppRoute.DIAGNOSTIC_LOG) { ScreenDestination(AppRoute.DIAGNOSTIC_LOG) { DiagnosticLogScreen(activity, back) } } composable(AppRoute.SSH_HOST_KEY) { hostKeyPrompt?.let { prompt -> diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticsScreen.kt similarity index 82% rename from app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt rename to app/src/main/java/net/megaproxy487/DiagnosticsScreen.kt index 0ba12cd..fc82aa1 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticsScreen.kt @@ -7,18 +7,13 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.net.VpnService -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.enableEdgeToEdge import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -52,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -65,22 +59,41 @@ import net.megaproxy487.data.ConfigStore import net.megaproxy487.vpn.ProxyVpnService import net.megaproxy487.vpn.TestDiagnosticLog import net.megaproxy487.vpn.TestState -import net.megaproxy487.vpn.SshHostKeyPromptState import net.megaproxy487.vpn.hasOtherProvider import net.megaproxy487.vpn.openAndroidVpnSettings import net.megaproxy487.vpn.readAlwaysOnVpnStatus -import net.megaproxy487.ui.theme.MegaProxyTheme @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack: () -> Unit) { +internal fun DiagnosticsScreen(activity: Activity, autoStart: Boolean, onBack: () -> Unit) { val state by TestDiagnosticLog.state val exitIp by TestDiagnosticLog.exitIp val countryCode by TestDiagnosticLog.countryCode val http3 by TestDiagnosticLog.http3 val http3Fallback by TestDiagnosticLog.http3Fallback var showAlwaysOnConflict by remember { mutableStateOf(false) } + var speedRunning by remember { mutableStateOf(false) } + var exportMessage by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val exportDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) { + val snapshot = TestDiagnosticLog.entries.joinToString("\n") + scope.launch { + try { + withContext(kotlinx.coroutines.Dispatchers.IO) { + activity.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { + it.write(snapshot) + } ?: throw UiException(R.string.error_open_export) + } + exportMessage = activity.uiText(R.string.diagnostic_log_exported) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + exportMessage = error.userMessage(activity, R.string.log_export_failed) + } + } + } + } val permission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (VpnService.prepare(activity) == null) ProxyVpnService.test(activity) else scope.launch { @@ -94,7 +107,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack } } val runTest = runTest@{ - if (TestDiagnosticLog.state.value == TestState.RUNNING) return@runTest + if (TestDiagnosticLog.state.value == TestState.RUNNING || speedRunning) return@runTest TestDiagnosticLog.begin() scope.launch { try { @@ -135,7 +148,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack Scaffold( topBar = { TopAppBar( - title = { ScreenTitle(stringResource(R.string.connection_test)) }, + title = { ScreenTitle(stringResource(R.string.diagnostics)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -201,16 +214,26 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack } } WrappingActions(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(shape = RoundedCornerShape(12.dp), onClick = runTest, enabled = state != TestState.RUNNING) { Text(stringResource(R.string.run_again)) } + Button(shape = RoundedCornerShape(12.dp), onClick = runTest, enabled = state != TestState.RUNNING && !speedRunning) { Text(stringResource(R.string.run_again)) } + } + Text(stringResource(R.string.speed), style = MaterialTheme.typography.titleMedium) + if (state == TestState.SUCCEEDED) { + SpeedTestContent(activity, onRunningChanged = { speedRunning = it }) + } else { + Text(stringResource(R.string.diagnostics_speed_locked), style = MaterialTheme.typography.bodySmall) + } + Text(activity.uiText(R.string.test_privacy_help), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.diagnostic_log), style = MaterialTheme.typography.titleMedium) + WrappingActions(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(shape = RoundedCornerShape(12.dp), onClick = { val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(activity.uiText(R.string.connection_test_clipboard_label), TestDiagnosticLog.entries.joinToString("\n"))) }, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.copy_log)) } + Button(shape = RoundedCornerShape(12.dp), onClick = { + exportDocument.launch("MegaProxy-diagnostics.log") + }, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.export_action)) } } - Text( - activity.uiText(R.string.test_privacy_help), - style = MaterialTheme.typography.bodySmall, - ) + exportMessage?.let { Text(it, style = MaterialTheme.typography.bodySmall) } Box(Modifier.fillMaxWidth().heightIn(min = 160.dp, max = logHeight)) { SelectionContainer { Text( diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 5c6f423..0e04983 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -253,11 +253,10 @@ private fun ProfileSelectorLabel( internal fun MainScreen( activity: Activity, onOpenSettings: () -> Unit, - onOpenConnectionTest: () -> Unit, + onOpenDiagnostics: () -> Unit, onEditProfile: (String) -> Unit, readConnectionStats: () -> NativeConnectionStats? = ConnectionStatsReader::snapshot, onOpenVisibility: () -> Unit = {}, - onOpenSpeed: () -> Unit = {}, ) { val connection by VpnRuntimeState.connection val runtimeAlwaysOn by VpnRuntimeState.alwaysOn @@ -417,13 +416,9 @@ internal fun MainScreen( } DropdownMenu(actionsMenuExpanded, { actionsMenuExpanded = false }) { DropdownMenuItem( - text = { Text(stringResource(R.string.test_connection)) }, + text = { Text(stringResource(R.string.diagnostics)) }, enabled = connection != VpnConnectionState.CONNECTING, - onClick = { actionsMenuExpanded = false; onOpenConnectionTest() }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.speed)) }, - onClick = { actionsMenuExpanded = false; onOpenSpeed() }, + onClick = { actionsMenuExpanded = false; onOpenDiagnostics() }, ) DropdownMenuItem( text = { Text(stringResource(R.string.visibility)) }, diff --git a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt b/app/src/main/java/net/megaproxy487/SpeedTestContent.kt similarity index 55% rename from app/src/main/java/net/megaproxy487/SpeedTestScreen.kt rename to app/src/main/java/net/megaproxy487/SpeedTestContent.kt index c219d34..be01afb 100644 --- a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/SpeedTestContent.kt @@ -9,32 +9,20 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,117 +50,91 @@ private data class RouteSpeedResult( val unavailable: Boolean = false, ) -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable -internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { +internal fun SpeedTestContent(activity: Activity, onRunningChanged: (Boolean) -> Unit = {}) { var results by remember { mutableStateOf(List(2) { RouteSpeedResult() }) } var running by remember { mutableStateOf(false) } - var preferredProtocol by remember { mutableStateOf(SPEED_TEST_PROTOCOLS.first()) } + var preferredProtocol by remember { mutableStateOf("h2") } var megabytes by remember { mutableStateOf(SPEED_TEST_MEGABYTES.first()) } - var log by remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() val store = remember(activity) { net.megaproxy487.data.ConfigStore(activity) } - Scaffold( - topBar = { - TopAppBar( - title = { ScreenTitle(stringResource(R.string.speed)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - ) - }, - contentWindowInsets = WindowInsets.safeDrawing, - ) { padding -> - ScreenContent(Modifier.padding(padding)) { - Column( - Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - stringResource(R.string.cloudflare_speed_explanation, megabytes, megabytes * 4), - style = MaterialTheme.typography.bodyMedium, - ) - val settings = readConfiguration(store) { store.globalConnectionSettings() } - if (settings != null && !settings.routeAllApps && activity.packageName !in settings.selectedPackages) { - Card(colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - )) { - Text(stringResource(R.string.speed_split_tunnel_warning), Modifier.padding(12.dp)) - } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.cloudflare_speed_explanation, megabytes, megabytes * 4), + style = MaterialTheme.typography.bodyMedium, + ) + val settings = readConfiguration(store) { store.globalConnectionSettings() } + if (settings != null && !settings.routeAllApps && activity.packageName !in settings.selectedPackages) { + Card(colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + )) { + Text(stringResource(R.string.speed_split_tunnel_warning), Modifier.padding(12.dp)) + } + } + FlowRow(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + SPEED_TEST_MEGABYTES.forEach { size -> + Row( + Modifier.heightIn(min = 48.dp).selectable( + selected = megabytes == size, + enabled = !running, + role = Role.RadioButton, + onClick = { megabytes = size }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = megabytes == size, onClick = null, enabled = !running) + Text(stringResource(R.string.cloudflare_speed_size, size)) } - FlowRow(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - SPEED_TEST_MEGABYTES.forEach { size -> - Row( - Modifier.heightIn(min = 48.dp).selectable( - selected = megabytes == size, - enabled = !running, - role = Role.RadioButton, - onClick = { megabytes = size }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = megabytes == size, onClick = null, enabled = !running) - Text(stringResource(R.string.cloudflare_speed_size, size)) - } - } + } + } + Text(stringResource(R.string.speed_protocol_preference), style = MaterialTheme.typography.bodyMedium) + FlowRow(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + SPEED_TEST_PROTOCOLS.forEach { protocol -> + Row( + Modifier.heightIn(min = 48.dp).selectable( + selected = preferredProtocol == protocol, enabled = !running, + role = Role.RadioButton, onClick = { preferredProtocol = protocol }, + ), verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = preferredProtocol == protocol, onClick = null, enabled = !running) + Text(protocol) } - Text(stringResource(R.string.speed_protocol_preference), style = MaterialTheme.typography.bodyMedium) - FlowRow(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - SPEED_TEST_PROTOCOLS.forEach { protocol -> - Row( - Modifier.heightIn(min = 48.dp).selectable( - selected = preferredProtocol == protocol, enabled = !running, - role = Role.RadioButton, onClick = { preferredProtocol = protocol }, - ), verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = preferredProtocol == protocol, onClick = null, enabled = !running) - Text(protocol) + } + } + Button( + enabled = !running, + shape = RoundedCornerShape(12.dp), + onClick = { + running = true + onRunningChanged(true) + results = List(2) { RouteSpeedResult(pending = true) } + scope.launch { + try { + withContext(Dispatchers.IO) { + runSpeedChecks(activity, megabytes, preferredProtocol, + update = { index, result -> + withContext(Dispatchers.Main) { + results = results.toMutableList().also { it[index] = result } + } + }, + log = { entry -> + withContext(Dispatchers.Main) { net.megaproxy487.vpn.TestDiagnosticLog.add(entry) } + }, + ) } + } finally { + running = false + onRunningChanged(false) } } - Button( - enabled = !running, - shape = RoundedCornerShape(12.dp), - onClick = { - running = true - results = List(2) { RouteSpeedResult(pending = true) } - log = emptyList() - scope.launch { - try { - withContext(Dispatchers.IO) { - runSpeedChecks(activity, megabytes, preferredProtocol, - update = { index, result -> - withContext(Dispatchers.Main) { - results = results.toMutableList().also { it[index] = result } - } - }, - log = { entry -> - withContext(Dispatchers.Main) { log = log + "${speedLogTime()} $entry" } - }, - ) - } - } finally { running = false } - } - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(if (running) R.string.cloudflare_speed_running else R.string.cloudflare_speed_run)) - } - SpeedResultsTable(results) - SelectionContainer { - Text( - log.joinToString("\n").ifEmpty { - stringResource(if (running) R.string.test_waiting else R.string.test_empty) - }, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth(), - ) - } - } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(if (running) R.string.cloudflare_speed_running else R.string.cloudflare_speed_run)) } + SpeedResultsTable(results) } } @@ -274,7 +236,3 @@ private suspend fun runSpeedChecks( } } } - -private fun speedLogTime(): String = java.time.LocalTime.now().format( - java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss.SSS", systemFormattingLocale()), -) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f13a873..661442d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,7 +35,7 @@ Подключиться Отключиться Подключение… - Тест + Проверка скорости станет доступна после успешного теста соединения. Скорость Настроить Не найдено приложение для открытия ссылки. @@ -202,7 +202,7 @@ Сохранён обезличенный отчёт о сбое. Его можно отправить, чтобы помочь с диагностикой. Отправить отчёт Закрыть - Проверка подключения MegaProxy + Диагностика MegaProxy ОбнаруженоТолько выбранные приложенияНе обнаружено Проверка признаков VPN, видимых приложениям. Репутация IP проверяется внешним сервисом через каждую сеть. Глобально видимые признакиВидно выбранным приложениямПризнаки VPN-сети diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b020000..1e74787 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,7 +35,7 @@ Connect Disconnect Connecting… - Test + Speed checks become available after the connection test passes. Speed Configure No browser is available. @@ -203,7 +203,7 @@ A privacy-filtered crash report was saved. You can send it to help diagnose the problem. Report Close - MegaProxy connection test + MegaProxy diagnostics DetectedSelected apps onlyNot detected Check VPN signals visible to apps. IP reputation checks contact an external service over each network. Globally observable signalsVisible to selected appsVPN network signals diff --git a/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt b/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt index 2f8a75d..9580e2e 100644 --- a/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt +++ b/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt @@ -35,7 +35,7 @@ class AdaptiveScreensUiTest : MainUiTestBase() { fun speedOptionsRemainReadableWithLargeFonts() { content { CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 2f)) { - SpeedTestScreen(activity) {} + SpeedTestContent(activity) } } SPEED_TEST_MEGABYTES.forEach { size -> assertTextFits(activity.uiText(R.string.cloudflare_speed_size, size)) } diff --git a/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt b/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt index ab985d9..e207b3c 100644 --- a/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt +++ b/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt @@ -9,11 +9,55 @@ import org.junit.Assert.* import org.junit.Test class DiagnosticsUiTest : MainUiTestBase() { + @Test fun openingDiagnosticsStartsConnectionTest() { + content { DiagnosticsScreen(activity, autoStart = true, onBack = {}) } + val app = org.robolectric.Shadows.shadowOf(activity.application as android.app.Application) + assertEquals("net.megaproxy487.TEST", app.nextStartedService?.action) + assertNull(app.nextStartedService) + node(R.string.run_again).assertIsNotEnabled() + node(R.string.cloudflare_speed_run).assertDoesNotExist() + } + + @Test fun restoredScreenDoesNotRerunCompletedConnectionTest() { + val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(compose) + restoration.setContent { androidx.compose.material3.MaterialTheme { + DiagnosticsScreen(activity, autoStart = true, onBack = {}) + } } + drainConfigIo() + val app = org.robolectric.Shadows.shadowOf(activity.application as android.app.Application) + assertEquals("net.megaproxy487.TEST", app.nextStartedService?.action) + compose.runOnIdle { TestDiagnosticLog.succeed("203.0.113.7", "DE") } + restoration.emulateSavedInstanceStateRestore() + drainConfigIo() + assertNull(app.nextStartedService) + node(R.string.test_passed).assertExists() + node(R.string.cloudflare_speed_run).assertExists() + } + + @Test fun exportsSharedLogToPickedDocument() { + TestDiagnosticLog.fail() + TestDiagnosticLog.add("event=connection_marker") + TestDiagnosticLog.add("event=speed_test route=tunnel result=success") + val uri = android.net.Uri.parse("content://ui-test/diagnostics.log") + val output = java.io.ByteArrayOutputStream() + org.robolectric.Shadows.shadowOf(activity.contentResolver).registerOutputStream(uri, output) + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } + node(R.string.export_action).performScrollTo().performClick() + val request = org.robolectric.Shadows.shadowOf(activity).nextStartedActivityForResult.intent + assertEquals(android.content.Intent.ACTION_CREATE_DOCUMENT, request.action) + compose.runOnIdle { + org.robolectric.Shadows.shadowOf(activity).receiveResult(request, android.app.Activity.RESULT_OK, + android.content.Intent().setData(uri)) + } + waitForText(text(R.string.diagnostic_log_exported)) + assertEquals(TestDiagnosticLog.entries.joinToString("\n"), output.toString("UTF-8")) + } + @Test fun http3ProvidersShowIndependentResultsAndResetOnAnotherRun() { TestDiagnosticLog.succeed("203.0.113.7", "DE", listOf( Http3ProbeResult("Cloudflare", true), Http3ProbeResult("BrowserLeaks", false), )) - content { ConnectionTestScreen(activity, autoStart = false, onBack = {}) } + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } compose.onNodeWithContentDescription(activity.uiText( R.string.test_http3_provider_a11y, "Cloudflare", activity.uiText(R.string.test_http3_confirmed))).assertIsDisplayed() compose.onNodeWithContentDescription(activity.uiText( @@ -26,16 +70,16 @@ class DiagnosticsUiTest : MainUiTestBase() { @Test fun runningDiagnosticCannotBeStartedAgainAndKeepsItsLog() { TestDiagnosticLog.begin() TestDiagnosticLog.add("event=test_marker") - content { ConnectionTestScreen(activity, autoStart = true, onBack = {}) } + content { DiagnosticsScreen(activity, autoStart = true, onBack = {}) } node(R.string.run_again).assertIsNotEnabled() - node(R.string.copy_log).assertIsEnabled().performClick() + node(R.string.copy_log).performScrollTo().assertIsEnabled().performClick() val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager assertTrue(clipboard.primaryClip!!.getItemAt(0).text.contains("event=test_marker")) } @Test fun completedDiagnosticShowsExitIpAndAllowsAnotherRun() { TestDiagnosticLog.succeed("203.0.113.7", "DE") - content { ConnectionTestScreen(activity, autoStart = false, onBack = {}) } + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } node(R.string.test_passed).assertIsDisplayed() compose.onNodeWithText(activity.uiText(R.string.proxy_exit_ip, "203.0.113.7")).assertIsDisplayed() node(R.string.run_again).assertIsEnabled() @@ -43,12 +87,49 @@ class DiagnosticsUiTest : MainUiTestBase() { @Test fun failureShowsStatusAndEmptyLogCannotBeCopied() { TestDiagnosticLog.fail() - content { ConnectionTestScreen(activity, autoStart = false, onBack = {}) } + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } node(R.string.test_failed).assertIsDisplayed() node(R.string.run_again).assertIsEnabled() node(R.string.copy_log).assertIsNotEnabled() } + @Test fun speedIsUnlockedOnlyAfterSuccessAndRelockedOnRetest() { + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } + node(R.string.cloudflare_speed_run).assertDoesNotExist() + node(R.string.diagnostics_speed_locked).assertExists() + compose.runOnIdle { TestDiagnosticLog.fail() } + node(R.string.cloudflare_speed_run).assertDoesNotExist() + compose.runOnIdle { TestDiagnosticLog.succeed("203.0.113.7", "DE") } + node(R.string.cloudflare_speed_run).performScrollTo().assertIsEnabled() + compose.onNodeWithText("h2").performScrollTo().assertIsSelected() + compose.onNodeWithText("h3").performScrollTo().performClick() + compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_size, 25)).performScrollTo().performClick() + compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_explanation, 25, 100)).assertExists() + compose.runOnIdle { TestDiagnosticLog.begin() } + node(R.string.cloudflare_speed_run).assertDoesNotExist() + node(R.string.diagnostics_speed_locked).assertExists() + } + + @Test fun speedPreservesConnectionLogAndBothCanBeCopied() { + val connectivity = activity.getSystemService(android.net.ConnectivityManager::class.java) + org.robolectric.Shadows.shadowOf(connectivity).clearAllNetworks() + TestDiagnosticLog.succeed("203.0.113.7", "DE") + TestDiagnosticLog.add("event=connection_marker") + content { DiagnosticsScreen(activity, autoStart = false, onBack = {}) } + node(R.string.cloudflare_speed_run).performScrollTo().performClick() + compose.waitUntil(10_000) { + compose.waitForIdle() + TestDiagnosticLog.entries.any { it.contains("event=speed_test route=tunnel result=unavailable") } + } + node(R.string.copy_log).performScrollTo().performClick() + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = clipboard.primaryClip!!.getItemAt(0).text.toString() + assertTrue(text.contains("event=connection_marker")) + assertTrue(text.contains("event=speed_test route=underlying")) + assertTrue(text.contains("event=speed_test route=tunnel")) + node(R.string.export_action).assertIsEnabled() + } + @Test fun clearingDiagnosticLogRequiresConfirmation() { content { DiagnosticLogScreen(activity, {}) } node(R.string.clear_action).performScrollTo().performClick() diff --git a/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt b/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt index 97a7f08..35812e3 100644 --- a/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt +++ b/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt @@ -137,7 +137,7 @@ class MainScreenUiTest : MainUiTestBase() { VpnRuntimeState.update(VpnConnectionState.CONNECTING) screen() icon(R.string.main_actions).performClick() - node(R.string.test_connection).assertIsNotEnabled() + node(R.string.diagnostics).assertIsNotEnabled() compose.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } node(R.string.disconnect).performScrollTo().assertIsEnabled().performClick() command("STOP") diff --git a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt index d9a5e48..f3de022 100644 --- a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt +++ b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt @@ -9,8 +9,8 @@ class NavigationUiTest : MainUiTestBase() { TestDiagnosticLog.begin() content { MegaProxyNavHost(activity, readConnectionStats = { null }) } icon(R.string.main_actions).performClick() - node(R.string.test_connection).performClick() - compose.onNodeWithTag("screen-connection-test").assertIsDisplayed() + node(R.string.diagnostics).performClick() + compose.onNodeWithTag("screen-diagnostics").assertIsDisplayed() node(R.string.run_again).assertIsNotEnabled() icon(R.string.back).performClick() compose.onNodeWithTag("screen-main").assertIsDisplayed() @@ -26,27 +26,11 @@ class NavigationUiTest : MainUiTestBase() { compose.onNodeWithTag("screen-main").assertIsDisplayed() } - @Test fun speedMenuOpensSizeSelectionAndBackReturnsHome() { - content { MegaProxyNavHost(activity, readConnectionStats = { null }) } - icon(R.string.main_actions).performClick() - node(R.string.speed).performClick() - compose.onNodeWithTag("screen-speed").assertIsDisplayed() - drainConfigIo() - node(R.string.speed_split_tunnel_warning).assertDoesNotExist() - compose.onNodeWithText("h3").assertIsDisplayed() - compose.onNodeWithText("h2").performClick() - compose.onNodeWithText("h1").assertIsDisplayed() - compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_size, 25)).performClick() - compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_explanation, 25, 100)).assertIsDisplayed() - icon(R.string.back).performClick() - compose.onNodeWithTag("screen-main").assertIsDisplayed() - } - @Test fun speedWarnsWhenMegaProxyIsExcludedFromSplitTunneling() { store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( routeAllApps = false, selectedPackages = setOf("other.package"), )) - content { SpeedTestScreen(activity) {} } + content { SpeedTestContent(activity) } node(R.string.speed_split_tunnel_warning).assertIsDisplayed() } @@ -54,7 +38,7 @@ class NavigationUiTest : MainUiTestBase() { store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( routeAllApps = false, selectedPackages = setOf(activity.packageName), )) - content { SpeedTestScreen(activity) {} } + content { SpeedTestContent(activity) } node(R.string.speed_split_tunnel_warning).assertDoesNotExist() } diff --git a/app/src/test/java/net/megaproxy487/SpeedResultsLayoutTest.kt b/app/src/test/java/net/megaproxy487/SpeedResultsLayoutTest.kt index eeb60d3..b5217dd 100644 --- a/app/src/test/java/net/megaproxy487/SpeedResultsLayoutTest.kt +++ b/app/src/test/java/net/megaproxy487/SpeedResultsLayoutTest.kt @@ -19,7 +19,7 @@ class SpeedResultsLayoutTest : MainUiTestBase() { content { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, fontScale.value)) { - Box(Modifier.requiredWidth(width.value.dp)) { SpeedTestScreen(activity) {} } + Box(Modifier.requiredWidth(width.value.dp)) { SpeedTestContent(activity) } } } fun assertBlocks(count: Int) { diff --git a/docs/en/connection-metrics.md b/docs/en/connection-metrics.md index c58011c..05d9753 100644 --- a/docs/en/connection-metrics.md +++ b/docs/en/connection-metrics.md @@ -98,12 +98,14 @@ UDP control connections; they do not measure UDP loss or RTT. ## Test, Speed and VPN visibility -These are separate entries in the main menu; they measure different things: +Diagnostics and VPN visibility are available in the main menu. Diagnostics starts the connection test on opening; speed checks unlock only after it passes. Speed checks require an explicit start. Both operations share a bounded log at the bottom, with copying and export to a text file. Running another connection test clears the log and locks speed checks until success. The connection test cannot be restarted during a speed check. + +The checks measure different things: | Screen | Meaning | | --- | --- | -| Test | Proxy connection, HTTPS and exit IP/country; independent UDP/HTTP3 checks for supported transports. IP is selectable/copyable, status icons have spoken labels. If disconnected, a temporary test VPN may be used. | -| Speed | Active Cloudflare transfer over the underlying network and VPN separately; progressively filled table with IP, flag/code, HTTP and TLS on separate lines, latency, download and upload. | +| Diagnostics: connection test | Proxy connection, HTTPS and exit IP/country; independent UDP/HTTP3 checks for supported transports. IP is selectable/copyable, status icons have spoken labels. If disconnected, a temporary test VPN may be used. | +| Diagnostics: speed | Active Cloudflare transfer over the underlying network and VPN separately; progressively filled table with IP, flag/code, HTTP and TLS on separate lines, latency, download and upload. | | VPN visibility | Local Android/interface signals plus ProxyCheck public-IP reputation per available route. Local heuristics and remote reputation are not proof that all apps can detect the VPN. | With split tunneling, select MegaProxy to measure the VPN route. It is selected by default; @@ -113,14 +115,14 @@ On narrow windows, Speed shows the underlying network and VPN results in separat Speed uses 10/25/50 decimal MB per direction per route (10,000,000/25,000,000/50,000,000 bytes). Two routes and both directions can consume up to four times the selected payload, plus overhead. -Default h3 falls back to h2 then h1; h2 permits h1 fallback, h1 stays h1. Fallback applies only to +The default is h2 with h1 fallback. Selecting h3 permits h2 then h1 fallback; h1 stays h1. Fallback applies only to initial metadata negotiation, not certificate errors, HTTP failures or failed payload transfers. Latency is median **time to first byte** from three empty downloads, including request overhead; it is not SSL-handshake time, an ICMP ping or the main screen's passive RTT. Cloudflare's `h3.speed.cloudflare.com` endpoint is used for protocol-capable measurements. The log records DNS/TCP/TLS/QUIC stages, protocol/fallback, HTTP status, connection reuse, bytes and transfer timing; no response payloads or exit IP are added to the log. -Leaving the screen cancels requests and discards its results/log; Home/background alone does not. +Leaving Diagnostics or recreating its UI cancels speed requests and discards speed results. The shared in-memory log remains until the next connection test or process restart. Home/background alone does not cancel requests. ProxyCheck success is cached per public IP for 24 hours. Daily quota exhaustion, network failure and unknown response format are distinct states; unavailable routes are shown separately. diff --git a/docs/ru/connection-metrics.md b/docs/ru/connection-metrics.md index 524879a..0dbf190 100644 --- a/docs/ru/connection-metrics.md +++ b/docs/ru/connection-metrics.md @@ -93,18 +93,20 @@ HTTP выводится числовой статус без произволь Профили SOCKS5 поддерживают TCP CONNECT и UDP ASSOCIATE, с необязательными логином и паролем (по 1–255 байт UTF-8) и портом 1080 по умолчанию. SOCKS5 не добавляет -шифрование или отпечаток TLS. По умолчанию DNS использует DoH через прокси; см. [режимы DNS](dns.md). Кнопка «Тест» также +шифрование или отпечаток TLS. По умолчанию DNS использует DoH через прокси; см. [режимы DNS](dns.md). Тест соединения также проверяет HTTP/3 через UDP SOCKS5. Метрики TCP относятся к TCP-соединениям с прокси, включая управляющие соединения UDP; они не измеряют потери или RTT UDP. ## Тест, Скорость и Видимость VPN -Это разные пункты главного меню и разные измерения: +В главном меню доступны «Диагностика» и «Видимость VPN». При открытии «Диагностики» запускается тест соединения; после его успеха становится доступна проверка скорости, запускаемая вручную. Обе операции пишут в общий ограниченный журнал внизу экрана: его можно копировать и экспортировать в текстовый файл. Повторный тест очищает журнал и блокирует проверку скорости до успеха. Во время замера скорости повторный тест недоступен. + +Проверки измеряют разные вещи: | Экран | Значение | | --- | --- | -| Тест | Подключение прокси, HTTPS и выходной IP/страна; независимые UDP/HTTP3 проверки подходящих transports. IP выделяется/копируется, статусы имеют a11y-подписи. Если VPN отключён, может использоваться временный тестовый VPN. | -| Скорость | Активная передача Cloudflare отдельно через основную сеть и VPN; постепенная таблица IP, флаг/код, HTTP и TLS на отдельных строках, задержка, скачивание и отправка. | +| Диагностика: тест соединения | Подключение прокси, HTTPS и выходной IP/страна; независимые UDP/HTTP3 проверки подходящих transports. IP выделяется/копируется, статусы имеют a11y-подписи. Если VPN отключён, может использоваться временный тестовый VPN. | +| Диагностика: скорость | Активная передача Cloudflare отдельно через основную сеть и VPN; постепенная таблица IP, флаг/код, HTTP и TLS на отдельных строках, задержка, скачивание и отправка. | | Видимость VPN | Локальные Android/interface признаки и репутация публичного IP по ProxyCheck для каждого доступного маршрута. Эвристики и репутация не доказывают обнаружение VPN всеми приложениями. | При раздельном туннелировании для замера VPN-маршрута выберите MegaProxy. По умолчанию @@ -113,15 +115,15 @@ HTTP выводится числовой статус без произволь Скорость использует 10/25/50 десятичных MB в каждом направлении каждого маршрута (10 000 000/25 000 000/50 000 000 байт). Два маршрута и оба направления могут расходовать -до четырёх выбранных объёмов плюс overhead. По умолчанию h3 откатывается на h2, затем h1; -h2 разрешает h1, h1 остаётся h1. Fallback только при начальном согласовании metadata, +до четырёх выбранных объёмов плюс overhead. По умолчанию выбран h2 с переходом на h1. +При выборе h3 разрешён переход на h2, затем h1; h1 остаётся h1. Fallback только при начальном согласовании metadata, не при ошибках сертификата, HTTP или передачи полезных данных. Задержка — медиана **времени до первого байта** трёх пустых скачиваний с накладными расходами запроса; это не время SSL handshake, ICMP ping или пассивный RTT главного экрана. Для измерений с выбором протокола используется `h3.speed.cloudflare.com`. Лог содержит DNS/TCP/TLS/QUIC, протокол/fallback, HTTP status, повторное соединение, байты и время передачи, без ответа сервиса и выходного IP. -Уход с экрана отменяет запросы и удаляет результаты/лог; Home/сворачивание само по себе — нет. +Уход из «Диагностики» или пересоздание её интерфейса отменяет замер скорости и удаляет его результаты. Общий журнал в памяти сохраняется до следующего теста соединения или перезапуска процесса. Home/сворачивание само по себе не отменяет запросы. Успех ProxyCheck кешируется на 24 часа для каждого публичного IP. Дневной лимит, ошибка сети и неизвестный формат — разные состояния; недоступные маршруты показываются отдельно.