diff --git a/AGENTS.md b/AGENTS.md index d6cc15a..1199ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,8 @@ branch names, credentials, signing material, or other secrets. - MegaProxy is selected for split tunneling by default. Existing lists gain it once on upgrade; subsequent explicit exclusion must survive reloads, unrelated edits and profile changes. +- Keep UI labels and explanations concise in both languages. Put implementation details in the + current EN/RU documentation; retain actionable limits, security risks and privacy consequences in UI. - All user-visible UI text must use Android string resources and be supplied in both the default English resources and Russian resources. The app language selects UI words; number, date and other data formatting follows the system locale. Do not localize standardized data-unit symbols. diff --git a/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt b/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt index 690e66d..a996f7a 100644 --- a/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt @@ -99,6 +99,7 @@ internal fun ConfigSubscriptionScreen(activity: Activity, onBack: () -> Unit, dr OutlinedTextField(draft.url, { draft.url = it }, label = { FieldLabel(stringResource(R.string.subscription_url)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !busy) OutlinedTextField(draft.backups, { draft.backups = it }, label = { FieldLabel(stringResource(R.string.subscription_backups)) }, + supportingText = { Text(stringResource(R.string.subscription_backups_hint)) }, modifier = Modifier.fillMaxWidth(), enabled = !busy) Row( Modifier.fillMaxWidth().toggleable( diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index 199e0eb..b1acf93 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -145,80 +145,82 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> - BoxWithConstraints(Modifier.fillMaxSize().padding(padding)) { - val logHeight = maxOf(160.dp, maxHeight * 0.55f) - Column( - Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - val statusLabel = when (state) { - TestState.IDLE -> activity.uiText(R.string.test_ready) - TestState.RUNNING -> activity.uiText(R.string.test_testing) - TestState.SUCCEEDED -> activity.uiText(R.string.test_passed) - TestState.FAILED -> activity.uiText(R.string.test_failed) - } - Card(Modifier.fillMaxWidth().semantics { - liveRegion = LiveRegionMode.Polite - stateDescription = statusLabel - }) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - val statusColor = when (state) { - TestState.SUCCEEDED -> MaterialTheme.colorScheme.primary - TestState.FAILED -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurface - } - Text( - if (state == TestState.RUNNING) activity.uiText(R.string.test_testing_progress) else statusLabel, - style = MaterialTheme.typography.titleLarge, - color = statusColor, - ) - if (state == TestState.RUNNING) { - CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) - } - exitIp?.let { - SelectionContainer { Text(stringResource(R.string.proxy_exit_ip, it)) } + ScreenContent(Modifier.padding(padding)) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val logHeight = maxOf(160.dp, maxHeight * 0.55f) + Column( + Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val statusLabel = when (state) { + TestState.IDLE -> activity.uiText(R.string.test_ready) + TestState.RUNNING -> activity.uiText(R.string.test_testing) + TestState.SUCCEEDED -> activity.uiText(R.string.test_passed) + TestState.FAILED -> activity.uiText(R.string.test_failed) + } + Card(Modifier.fillMaxWidth().semantics { + liveRegion = LiveRegionMode.Polite + stateDescription = statusLabel + }) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val statusColor = when (state) { + TestState.SUCCEEDED -> MaterialTheme.colorScheme.primary + TestState.FAILED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + } + Text( + if (state == TestState.RUNNING) activity.uiText(R.string.test_testing_progress) else statusLabel, + style = MaterialTheme.typography.titleLarge, + color = statusColor, + ) + if (state == TestState.RUNNING) { + CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) + } + exitIp?.let { + SelectionContainer { Text(stringResource(R.string.proxy_exit_ip, it)) } + } + countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it, systemFormattingLocale()))) } + if (http3Fallback) Text(stringResource(R.string.http3_fallback_warning), + color = MaterialTheme.colorScheme.tertiary) + http3.forEach { probe -> + val status = stringResource(if (probe.confirmed) R.string.test_http3_confirmed else R.string.test_http3_unavailable) + Text( + stringResource( + R.string.test_http3_provider, + probe.provider, + stringResource(if (probe.confirmed) R.string.test_status_success_icon else R.string.test_status_failed_icon), + ), + Modifier.clearAndSetSemantics { + contentDescription = activity.uiText(R.string.test_http3_provider_a11y, probe.provider, status) + }, + ) + } + if (http3.isNotEmpty()) { + Text(stringResource(R.string.test_http3_help), style = MaterialTheme.typography.bodySmall) + } } - countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it, systemFormattingLocale()))) } - if (http3Fallback) Text(stringResource(R.string.http3_fallback_warning), - color = MaterialTheme.colorScheme.tertiary) - http3.forEach { probe -> - val status = stringResource(if (probe.confirmed) R.string.test_http3_confirmed else R.string.test_http3_unavailable) + } + 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 = { + 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)) } + } + Text( + activity.uiText(R.string.test_privacy_help), + style = MaterialTheme.typography.bodySmall, + ) + Box(Modifier.fillMaxWidth().heightIn(min = 160.dp, max = logHeight)) { + SelectionContainer { Text( - stringResource( - R.string.test_http3_provider, - probe.provider, - stringResource(if (probe.confirmed) R.string.test_status_success_icon else R.string.test_status_failed_icon), - ), - Modifier.clearAndSetSemantics { - contentDescription = activity.uiText(R.string.test_http3_provider_a11y, probe.provider, status) + TestDiagnosticLog.entries.joinToString("\n").ifEmpty { + if (state == TestState.RUNNING) activity.uiText(R.string.test_waiting) else activity.uiText(R.string.test_empty) }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), ) } - if (http3.isNotEmpty()) { - Text(stringResource(R.string.test_http3_help), style = MaterialTheme.typography.bodySmall) - } - } - } - 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 = { - 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)) } - } - Text( - activity.uiText(R.string.test_privacy_help), - style = MaterialTheme.typography.bodySmall, - ) - Box(Modifier.fillMaxWidth().heightIn(min = 160.dp, max = logHeight)) { - SelectionContainer { - Text( - TestDiagnosticLog.entries.joinToString("\n").ifEmpty { - if (state == TestState.RUNNING) activity.uiText(R.string.test_waiting) else activity.uiText(R.string.test_empty) - }, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - ) } } } diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 646705f..34e3dd2 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -55,6 +55,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import net.megaproxy487.uiStringResource as stringResource @@ -237,27 +239,29 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: }, bottomBar = { Surface(tonalElevation = 3.dp) { - Column(Modifier.fillMaxWidth().navigationBarsPadding().imePadding().padding(horizontal = 12.dp, vertical = 4.dp)) { - SaveStatusBanner() - if (importingKey) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) - error?.takeIf { it != validationRes?.let { id -> activity.uiText(id) } }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - if (!validPorts || validationRes != null) { - Text(stringResource(R.string.correct_fields_before_connecting), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) - } - if (showReconnectPrompt || showAlwaysOnNotice) { - Text(stringResource(if (showAlwaysOnNotice) R.string.always_on_changes_next_connection else R.string.reconnect_apply_changes), - style = MaterialTheme.typography.bodySmall) - WrappingActions { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; showAlwaysOnNotice = false; connectionChangeDeferred = true }) { - Text(stringResource(R.string.next_connection)) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 840.dp).fillMaxWidth().navigationBarsPadding().imePadding().padding(horizontal = 12.dp, vertical = 4.dp)) { + SaveStatusBanner() + if (importingKey) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) + error?.takeIf { it != validationRes?.let { id -> activity.uiText(id) } }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + if (!validPorts || validationRes != null) { + Text(stringResource(R.string.correct_fields_before_connecting), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error) + } + if (showReconnectPrompt || showAlwaysOnNotice) { + Text(stringResource(if (showAlwaysOnNotice) R.string.always_on_changes_next_connection else R.string.reconnect_apply_changes), + style = MaterialTheme.typography.bodySmall) + WrappingActions { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; showAlwaysOnNotice = false; connectionChangeDeferred = true }) { + Text(stringResource(R.string.next_connection)) + } + if (!showAlwaysOnNotice) TextButton(shape = RoundedCornerShape(12.dp), enabled = canReconnect, onClick = { + showReconnectPrompt = false; connectionChangeDeferred = true + ProxyVpnService.reconnect(activity) + }) { Text(stringResource(R.string.reconnect_now)) } } - if (!showAlwaysOnNotice) TextButton(shape = RoundedCornerShape(12.dp), enabled = canReconnect, onClick = { - showReconnectPrompt = false; connectionChangeDeferred = true - ProxyVpnService.reconnect(activity) - }) { Text(stringResource(R.string.reconnect_now)) } } } } @@ -265,306 +269,306 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: }, contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> - LazyColumn( - Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { - OutlinedTextField(profile.name, { name -> acceptText(name, 256) { - profile = profile.copy(name = name) - saveProfile() - } }, label = { FieldLabel(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - item { - ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) { - OutlinedTextField( - profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> - "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" - } ?: activity.uiText(R.string.no_flag), - {}, readOnly = true, label = { FieldLabel(stringResource(R.string.country_flag)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - ) - DropdownMenu(countryExpanded, { countryExpanded = false }) { - DropdownMenuItem(text = { Text(stringResource(R.string.no_flag)) }, onClick = { - profile = profile.copy(countryCode = "") - saveProfile() - countryExpanded = false - }) - countries.forEach { (code, name) -> - val flag = net.megaproxy487.model.ProxyProfile("", colorIndex = 0, countryCode = code).flagEmoji - DropdownMenuItem(text = { Text("$flag $name") }, onClick = { - profile = profile.copy( - countryCode = code, - colorIndex = ProfileColorMatcher.colorIndexForFlag(code, profile.colorIndex), - ) - saveProfile() - countryExpanded = false - }) - } - } - } - } - - item { - Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) - } - if (config.unreadableSecrets.isNotEmpty()) item { - Text(stringResource(R.string.error_credentials_unavailable), color = MaterialTheme.colorScheme.error) - } - if (config.storageUnavailable) item { - Text(stringResource(R.string.error_config_storage), color = MaterialTheme.colorScheme.error) - } - item { - ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { - OutlinedTextField(activity.uiText(config.type.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) - DropdownMenu(typeExpanded, { typeExpanded = false }) { - ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(activity.uiText(type.titleRes)) }, onClick = { - updateConfig(config.withType(type)) - portText = config.port.toString() - if (type.hasJump) jumpPortText = config.jumpPort.toString() - typeExpanded = false - }) } - } - } - } - item { - OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type == ProxyType.SOCKS5) R.string.socks5_proxy_hostname else if (config.type == ProxyType.MASQUE) R.string.masque_proxy_hostname else if (config.type.isHttpProxy) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, isError = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format) != null, supportingText = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - item { - IntegerInputField(portText, { portText = it }, 1..65535, stringResource(R.string.port), - onValidValue = { updateConfig(config.copy(port = it)) }, modifier = Modifier.fillMaxWidth()) - } - item { - OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_username_optional else if (config.type.isHttpProxy) R.string.basic_auth_username else R.string.ssh_username)) }, isError = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth) != null, supportingText = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - item { - PasswordField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it, unreadableSecrets = config.unreadableSecrets - "password")) } }, label = stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_password_optional else if (config.type.isHttpProxy) R.string.password else R.string.ssh_password_optional), - error = fieldError(R.string.validation_basic_password, R.string.validation_socks5_auth), modifier = Modifier.fillMaxWidth()) - } - if (config.type == ProxyType.SOCKS5) item { Text(stringResource(R.string.socks5_description), style = MaterialTheme.typography.bodySmall) } - if (config.type.isHttps) item { SettingSwitchRow( - checked = config.preferHttp3, - title = stringResource(R.string.prefer_http3), - description = stringResource(R.string.prefer_http3_description), - onCheckedChange = { updateConfig(config.copy(preferHttp3 = it)) }, - ) } - if (config.type == ProxyType.MASQUE) { - item { Text(stringResource(R.string.masque_fingerprint_description), style = MaterialTheme.typography.bodySmall) } - if (globalSettings.tlsProfile == TlsProfile.CUSTOM) item { - OutlinedTextField( - config.customJa3, - { value -> acceptText(value, 8 * 1024) { updateConfig(config.copy(customJa3 = it)) } }, - label = { FieldLabel(stringResource(R.string.masque_custom_ja3)) }, - isError = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3) != null, - supportingText = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3)?.let { { Text(it) } }, - minLines = 2, modifier = Modifier.fillMaxWidth(), - ) - } - } - if (config.type.isHttpProxy) item { SettingSwitchRow( - checked = config.allowInvalidProxyCertificate, - title = stringResource(R.string.allow_proxy_certificate), - description = stringResource(R.string.allow_proxy_certificate_description), - onCheckedChange = { checked -> - if (checked) { invalidCertificateIsJump = false; showInvalidCertificateWarning = true } - else updateConfig(config.copy(allowInvalidProxyCertificate = false)) - }, - ) - } - - if (config.type.isSsh) { + ScreenContent(Modifier.padding(padding)) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { item { - OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it, unreadableSecrets = config.unreadableSecrets - "privateKey")) } }, label = { FieldLabel(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(profile.name, { name -> acceptText(name, 256) { + profile = profile.copy(name = name) + saveProfile() + } }, label = { FieldLabel(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedButton(shape = RoundedCornerShape(12.dp), - enabled = !importingKey, onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, - modifier = Modifier.fillMaxWidth(), - ) { Text(stringResource(R.string.import_destination_key)) } - } - if (config.password.isBlank() && config.privateKey.isBlank()) { - item { - Text( - activity.uiText(R.string.ssh_no_credentials), - color = MaterialTheme.colorScheme.tertiary, - style = MaterialTheme.typography.bodySmall, + ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) { + OutlinedTextField( + profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> + "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" + } ?: activity.uiText(R.string.no_flag), + {}, readOnly = true, label = { FieldLabel(stringResource(R.string.country_flag)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) - } - } - item { - SettingSwitchRow(config.acceptAnyHostKey, activity.uiText(R.string.ssh_accept_destination), activity.uiText(R.string.ssh_accept_destination_help)) { checked -> - if (checked) unsafeHostKeyHop = "destination" else updateConfig(config.copy(acceptAnyHostKey = false)) + DropdownMenu(countryExpanded, { countryExpanded = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.no_flag)) }, onClick = { + profile = profile.copy(countryCode = "") + saveProfile() + countryExpanded = false + }) + countries.forEach { (code, name) -> + val flag = net.megaproxy487.model.ProxyProfile("", colorIndex = 0, countryCode = code).flagEmoji + DropdownMenuItem(text = { Text("$flag $name") }, onClick = { + profile = profile.copy( + countryCode = code, + colorIndex = ProfileColorMatcher.colorIndexForFlag(code, profile.colorIndex), + ) + saveProfile() + countryExpanded = false + }) + } + } } } - if (config.trustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_destination_key, config.trustedHostKey), style = MaterialTheme.typography.bodySmall) } - } - if (config.type == ProxyType.HTTPS_JUMP) { - item { Text(stringResource(R.string.https_jump_route), style = MaterialTheme.typography.bodySmall) } - item { HorizontalDivider() } - item { Text(stringResource(R.string.https_jump_proxy), style = MaterialTheme.typography.titleMedium) } - item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } item { - IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), - onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) + Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) } - item { - SettingSwitchRow(config.sameJumpAuthentication, stringResource(R.string.https_jump_same_auth), stringResource(R.string.https_jump_same_auth_description)) { - updateConfig(config.copy(sameJumpAuthentication = it)) - } + if (config.unreadableSecrets.isNotEmpty()) item { + Text(stringResource(R.string.error_credentials_unavailable), color = MaterialTheme.colorScheme.error) } - if (!config.sameJumpAuthentication) { - item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - item { - PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.https_jump_password), - error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) - } + if (config.storageUnavailable) item { + Text(stringResource(R.string.error_config_storage), color = MaterialTheme.colorScheme.error) } item { - SettingSwitchRow(config.jumpAllowInvalidProxyCertificate, stringResource(R.string.allow_jump_proxy_certificate), stringResource(R.string.allow_jump_proxy_certificate_description)) { checked -> - if (checked) { invalidCertificateIsJump = true; showInvalidCertificateWarning = true } - else updateConfig(config.copy(jumpAllowInvalidProxyCertificate = false)) + ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { + OutlinedTextField(activity.uiText(config.type.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(typeExpanded, { typeExpanded = false }) { + ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(activity.uiText(type.titleRes)) }, onClick = { + updateConfig(config.withType(type)) + portText = config.port.toString() + if (type.hasJump) jumpPortText = config.jumpPort.toString() + typeExpanded = false + }) } + } } } - } - - if (config.type == ProxyType.SSH_JUMP) { item { - HorizontalDivider() + OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type == ProxyType.SOCKS5) R.string.socks5_proxy_hostname else if (config.type == ProxyType.MASQUE) R.string.masque_proxy_hostname else if (config.type.isHttpProxy) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, isError = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format) != null, supportingText = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - Text(stringResource(R.string.jump_host), style = MaterialTheme.typography.titleMedium) + IntegerInputField(portText, { portText = it }, 1..65535, stringResource(R.string.port), + onValidValue = { updateConfig(config.copy(port = it)) }, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_username_optional else if (config.type.isHttpProxy) R.string.basic_auth_username else R.string.ssh_username)) }, isError = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth) != null, supportingText = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), - onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) + PasswordField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it, unreadableSecrets = config.unreadableSecrets - "password")) } }, label = stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_password_optional else if (config.type.isHttpProxy) R.string.password else R.string.ssh_password_optional), + error = fieldError(R.string.validation_basic_password, R.string.validation_socks5_auth), modifier = Modifier.fillMaxWidth()) } - item { - SettingSwitchRow(config.sameJumpAuthentication, activity.uiText(R.string.ssh_same_auth), activity.uiText(R.string.ssh_same_auth_help)) { - updateConfig(config.copy(sameJumpAuthentication = it)) + if (config.type == ProxyType.SOCKS5) item { Text(stringResource(R.string.socks5_description), style = MaterialTheme.typography.bodySmall) } + if (config.type.isHttps) item { SettingSwitchRow( + checked = config.preferHttp3, + title = stringResource(R.string.prefer_http3), + description = stringResource(R.string.prefer_http3_description), + onCheckedChange = { updateConfig(config.copy(preferHttp3 = it)) }, + ) } + if (config.type == ProxyType.MASQUE) { + item { Text(stringResource(R.string.masque_fingerprint_description), style = MaterialTheme.typography.bodySmall) } + if (globalSettings.tlsProfile == TlsProfile.CUSTOM) item { + OutlinedTextField( + config.customJa3, + { value -> acceptText(value, 8 * 1024) { updateConfig(config.copy(customJa3 = it)) } }, + label = { FieldLabel(stringResource(R.string.masque_custom_ja3)) }, + isError = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3) != null, + supportingText = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3)?.let { { Text(it) } }, + minLines = 2, modifier = Modifier.fillMaxWidth(), + ) } } - if (!config.sameJumpAuthentication) { - item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - item { - PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.jump_ssh_password_optional), - error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) - } + if (config.type.isHttpProxy) item { SettingSwitchRow( + checked = config.allowInvalidProxyCertificate, + title = stringResource(R.string.allow_proxy_certificate), + description = stringResource(R.string.allow_proxy_certificate_description), + onCheckedChange = { checked -> + if (checked) { invalidCertificateIsJump = false; showInvalidCertificateWarning = true } + else updateConfig(config.copy(allowInvalidProxyCertificate = false)) + }, + ) + } + + if (config.type.isSsh) { item { - OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it, unreadableSecrets = config.unreadableSecrets - "jumpPrivateKey")) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it, unreadableSecrets = config.unreadableSecrets - "privateKey")) } }, label = { FieldLabel(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { OutlinedButton(shape = RoundedCornerShape(12.dp), - enabled = !importingKey, onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, + enabled = !importingKey, onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), - ) { Text(stringResource(R.string.import_jump_key)) } + ) { Text(stringResource(R.string.import_destination_key)) } } - if (config.jumpPassword.isBlank() && config.jumpPrivateKey.isBlank()) { + if (config.password.isBlank() && config.privateKey.isBlank()) { item { Text( - activity.uiText(R.string.ssh_jump_no_credentials), + activity.uiText(R.string.ssh_no_credentials), color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall, ) } } + item { + SettingSwitchRow(config.acceptAnyHostKey, activity.uiText(R.string.ssh_accept_destination), activity.uiText(R.string.ssh_accept_destination_help)) { checked -> + if (checked) unsafeHostKeyHop = "destination" else updateConfig(config.copy(acceptAnyHostKey = false)) + } + } + if (config.trustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_destination_key, config.trustedHostKey), style = MaterialTheme.typography.bodySmall) } } + + if (config.type == ProxyType.HTTPS_JUMP) { + item { Text(stringResource(R.string.https_jump_route), style = MaterialTheme.typography.bodySmall) } + item { HorizontalDivider() } + item { Text(stringResource(R.string.https_jump_proxy), style = MaterialTheme.typography.titleMedium) } + item { + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), + onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) + } + item { + SettingSwitchRow(config.sameJumpAuthentication, stringResource(R.string.https_jump_same_auth), stringResource(R.string.https_jump_same_auth_description)) { + updateConfig(config.copy(sameJumpAuthentication = it)) + } + } + if (!config.sameJumpAuthentication) { + item { + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.https_jump_password), + error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) + } + } + item { + SettingSwitchRow(config.jumpAllowInvalidProxyCertificate, stringResource(R.string.allow_jump_proxy_certificate), stringResource(R.string.allow_jump_proxy_certificate_description)) { checked -> + if (checked) { invalidCertificateIsJump = true; showInvalidCertificateWarning = true } + else updateConfig(config.copy(jumpAllowInvalidProxyCertificate = false)) + } + } + } + + if (config.type == ProxyType.SSH_JUMP) { + item { + HorizontalDivider() + } + item { + Text(stringResource(R.string.jump_host), style = MaterialTheme.typography.titleMedium) + } + item { + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), + onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) + } + item { + SettingSwitchRow(config.sameJumpAuthentication, activity.uiText(R.string.ssh_same_auth), activity.uiText(R.string.ssh_same_auth_help)) { + updateConfig(config.copy(sameJumpAuthentication = it)) + } + } + if (!config.sameJumpAuthentication) { + item { + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.jump_ssh_password_optional), + error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it, unreadableSecrets = config.unreadableSecrets - "jumpPrivateKey")) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedButton(shape = RoundedCornerShape(12.dp), + enabled = !importingKey, onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.import_jump_key)) } + } + if (config.jumpPassword.isBlank() && config.jumpPrivateKey.isBlank()) { + item { + Text( + activity.uiText(R.string.ssh_jump_no_credentials), + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + item { + SettingSwitchRow(config.jumpAcceptAnyHostKey, activity.uiText(R.string.ssh_accept_jump), activity.uiText(R.string.ssh_accept_jump_help)) { checked -> + if (checked) unsafeHostKeyHop = "jump" else updateConfig(config.copy(jumpAcceptAnyHostKey = false)) + } + } + if (config.jumpTrustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_jump_key, config.jumpTrustedHostKey), style = MaterialTheme.typography.bodySmall) } + } + item { - SettingSwitchRow(config.jumpAcceptAnyHostKey, activity.uiText(R.string.ssh_accept_jump), activity.uiText(R.string.ssh_accept_jump_help)) { checked -> - if (checked) unsafeHostKeyHop = "jump" else updateConfig(config.copy(jumpAcceptAnyHostKey = false)) + SettingSwitchRow(config.allowIpv6, activity.uiText(R.string.ipv6_destinations), activity.uiText(R.string.ipv6_destinations_help)) { + updateConfig(config.copy(allowIpv6 = it)) } } - if (config.jumpTrustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_jump_key, config.jumpTrustedHostKey), style = MaterialTheme.typography.bodySmall) } - } - - item { - SettingSwitchRow(config.allowIpv6, activity.uiText(R.string.ipv6_destinations), activity.uiText(R.string.ipv6_destinations_help)) { - updateConfig(config.copy(allowIpv6 = it)) - } - } - item { Text(stringResource(R.string.dns_settings), style = MaterialTheme.typography.titleMedium) } - item { - SettingSwitchRow(config.allowInsecureDns, stringResource(R.string.allow_insecure_dns), - stringResource(R.string.allow_insecure_dns_description)) { enabled -> - if (enabled) insecureDnsWarning = DnsTransport.DOH else updateConfig(config.copy( - allowInsecureDns = false, - dnsProvider = if (config.dnsTransport != DnsTransport.DOH) DnsProvider.CLOUDFLARE else config.dnsProvider, - customDohUrl = if (config.dnsTransport != DnsTransport.DOH) "" else config.customDohUrl, - )) - } - } - if (!config.allowInsecureDns && config.dnsTransport != DnsTransport.DOH) item { - TextButton(onClick = { updateConfig(config.copy(dnsProvider = DnsProvider.CLOUDFLARE, customDohUrl = "")) }) { - Text(stringResource(R.string.dns_transport_doh)) - } - } - if (config.allowInsecureDns) item { - ExposedDropdownMenuBox(dnsTransportExpanded, { dnsTransportExpanded = it }) { - OutlinedTextField(stringResource(config.dnsTransport.titleRes), {}, readOnly = true, - label = { FieldLabel(stringResource(R.string.dns_transport)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsTransportExpanded) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) - DropdownMenu(dnsTransportExpanded, { dnsTransportExpanded = false }) { - DnsTransport.entries.forEach { transport -> - DropdownMenuItem(text = { Text(stringResource(transport.titleRes)) }, onClick = { - dnsTransportExpanded = false - if (transport == DnsTransport.DOH) updateConfig(config.copy(dnsProvider = DnsProvider.CLOUDFLARE, customDohUrl = "")) - else insecureDnsWarning = transport - }) + item { Text(stringResource(R.string.dns_settings), style = MaterialTheme.typography.titleMedium) } + item { + SettingSwitchRow(config.allowInsecureDns, stringResource(R.string.allow_insecure_dns), + stringResource(R.string.allow_insecure_dns_description)) { enabled -> + if (enabled) insecureDnsWarning = DnsTransport.DOH else updateConfig(config.copy( + allowInsecureDns = false, + dnsProvider = if (config.dnsTransport != DnsTransport.DOH) DnsProvider.CLOUDFLARE else config.dnsProvider, + customDohUrl = if (config.dnsTransport != DnsTransport.DOH) "" else config.customDohUrl, + )) + } + } + if (!config.allowInsecureDns && config.dnsTransport != DnsTransport.DOH) item { + TextButton(onClick = { updateConfig(config.copy(dnsProvider = DnsProvider.CLOUDFLARE, customDohUrl = "")) }) { + Text(stringResource(R.string.dns_transport_doh)) + } + } + if (config.allowInsecureDns) item { + ExposedDropdownMenuBox(dnsTransportExpanded, { dnsTransportExpanded = it }) { + OutlinedTextField(stringResource(config.dnsTransport.titleRes), {}, readOnly = true, + label = { FieldLabel(stringResource(R.string.dns_transport)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsTransportExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(dnsTransportExpanded, { dnsTransportExpanded = false }) { + DnsTransport.entries.forEach { transport -> + DropdownMenuItem(text = { Text(stringResource(transport.titleRes)) }, onClick = { + dnsTransportExpanded = false + if (transport == DnsTransport.DOH) updateConfig(config.copy(dnsProvider = DnsProvider.CLOUDFLARE, customDohUrl = "")) + else insecureDnsWarning = transport + }) + } } } - } - } - if (config.dnsTransport == DnsTransport.DOH) { - item { - SettingSwitchRow(config.allowInvalidDohCertificate, stringResource(R.string.allow_invalid_doh_certificate), - stringResource(R.string.allow_invalid_doh_certificate_warning)) { enabled -> - if (enabled) invalidDohWarning = true else updateConfig(config.copy(allowInvalidDohCertificate = false)) + } + if (config.dnsTransport == DnsTransport.DOH) { + item { + SettingSwitchRow(config.allowInvalidDohCertificate, stringResource(R.string.allow_invalid_doh_certificate), + stringResource(R.string.allow_invalid_doh_certificate_warning)) { enabled -> + if (enabled) invalidDohWarning = true else updateConfig(config.copy(allowInvalidDohCertificate = false)) + } } - } - item { - ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { - OutlinedTextField(activity.uiText(config.dnsProvider.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.dns_over_https)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) - DropdownMenu(dnsExpanded, { dnsExpanded = false }) { - DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(activity.uiText(provider.titleRes)) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } + item { + ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { + OutlinedTextField(activity.uiText(config.dnsProvider.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.dns_over_https)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(dnsExpanded, { dnsExpanded = false }) { + DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(activity.uiText(provider.titleRes)) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } + } } } - } - if (config.dnsProvider == DnsProvider.CUSTOM) item { - val dnsError = fieldError(R.string.validation_doh_https_required, R.string.validation_doh_url) - OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, - label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, isError = dnsError != null, - supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - } else { - item { - Text(stringResource(if (config.dnsTransport == DnsTransport.TCP) R.string.insecure_dns_tcp_warning else R.string.insecure_dns_udp_warning), - style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - val dnsError = fieldError(R.string.validation_dns_server, R.string.validation_insecure_dns_disabled) - OutlinedTextField(config.customDohUrl.trim().substringAfter("://", ""), { value -> acceptText(value, 256) { - updateConfig(config.copy(customDohUrl = "${config.dnsTransport.scheme}://$it")) - } }, label = { FieldLabel(stringResource(R.string.dns_server_ip)) }, isError = dnsError != null, - supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - } + if (config.dnsProvider == DnsProvider.CUSTOM) item { + val dnsError = fieldError(R.string.validation_doh_https_required, R.string.validation_doh_url) + OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, + label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, isError = dnsError != null, + supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + } else { + item { + Text(stringResource(if (config.dnsTransport == DnsTransport.TCP) R.string.insecure_dns_tcp_warning else R.string.insecure_dns_udp_warning), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + val dnsError = fieldError(R.string.validation_dns_server, R.string.validation_insecure_dns_disabled) + OutlinedTextField(config.customDohUrl.trim().substringAfter("://", ""), { value -> acceptText(value, 256) { + updateConfig(config.copy(customDohUrl = "${config.dnsTransport.scheme}://$it")) + } }, label = { FieldLabel(stringResource(R.string.dns_server_ip)) }, isError = dnsError != null, + supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + } - item { - Text(stringResource(if (isNew && !editorState.persisted) R.string.draft_profile_hint else R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) - } + item { + Text(stringResource(if (isNew && !editorState.persisted) R.string.draft_profile_hint else R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) + } + } } } diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 8514fd4..21b4e0f 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -469,16 +469,16 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil Text(stringResource(R.string.transfer_working), modifier = Modifier.padding(horizontal = 16.dp)) } SaveStatusBanner() - Surface(tonalElevation = 3.dp) { - Button( - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), - enabled = !busy, - onClick = { - onEditProfile("new") - }, - ) { Text(stringResource(R.string.add_profile)) } - } + Surface(tonalElevation = 3.dp) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + Button( + shape = RoundedCornerShape(12.dp), + modifier = Modifier.widthIn(max = 840.dp).fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), + enabled = !busy, + onClick = { onEditProfile("new") }, + ) { Text(stringResource(R.string.add_profile)) } + } + } } }, contentWindowInsets = WindowInsets.safeDrawing, @@ -488,7 +488,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil contentAlignment = Alignment.TopCenter, ) { LazyColumn( - Modifier.fillMaxHeight().fillMaxWidth().widthIn(max = 840.dp).padding(horizontal = 16.dp), + Modifier.widthIn(max = 840.dp).fillMaxWidth().fillMaxHeight().padding(horizontal = 16.dp), state = listState, contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt index f440cd2..c219d34 100644 --- a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt @@ -3,12 +3,15 @@ package net.megaproxy487 import android.app.Activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow 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 @@ -59,7 +62,7 @@ private data class RouteSpeedResult( val unavailable: Boolean = false, ) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { var results by remember { mutableStateOf(List(2) { RouteSpeedResult() }) } @@ -82,90 +85,92 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> - Column( - Modifier.fillMaxSize().padding(padding).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)) - } - } - Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.SpaceEvenly) { - SPEED_TEST_MEGABYTES.forEach { size -> - Row( - Modifier.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)) + 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)) } } - } - Text(stringResource(R.string.speed_protocol_preference), style = MaterialTheme.typography.bodyMedium) - Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.SpaceEvenly) { - SPEED_TEST_PROTOCOLS.forEach { protocol -> - Row( - Modifier.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) + 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)) + } } } - } - 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 } + 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) + } } - }, - 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) + } + 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 } + } }, - style = MaterialTheme.typography.bodySmall, 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(), + ) + } } } } diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index 9157893..999291f 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -176,129 +176,129 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { // its child can double-apply or misreport the IME area on some OEM builds. contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> - LazyColumn( - Modifier - .fillMaxSize() - .padding(padding), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - item { Text(stringResource(R.string.routing_mode), style = MaterialTheme.typography.titleMedium) } - item { - Column(Modifier.selectableGroup()) { - Row( - Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( - selected = !settings.routeAllApps, - onClick = { updateSettings(settings.copy(routeAllApps = false)) }, - role = Role.RadioButton, - ), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - RadioButton(!settings.routeAllApps, null) - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.split_tunneling)) - Text(stringResource(R.string.selected_apps_routing_description), style = MaterialTheme.typography.bodySmall) + ScreenContent(Modifier.padding(padding)) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { Text(stringResource(R.string.routing_mode), style = MaterialTheme.typography.titleMedium) } + item { + Column(Modifier.selectableGroup()) { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( + selected = !settings.routeAllApps, + onClick = { updateSettings(settings.copy(routeAllApps = false)) }, + role = Role.RadioButton, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + RadioButton(!settings.routeAllApps, null) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.split_tunneling)) + Text(stringResource(R.string.selected_apps_routing_description), style = MaterialTheme.typography.bodySmall) + } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( + selected = settings.routeAllApps, + onClick = { updateSettings(settings.copy(routeAllApps = true)) }, + role = Role.RadioButton, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + RadioButton(settings.routeAllApps, null) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.global_vpn)) + Text(stringResource(R.string.global_vpn_description), style = MaterialTheme.typography.bodySmall) + } } } - Row( - Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( - selected = settings.routeAllApps, - onClick = { updateSettings(settings.copy(routeAllApps = true)) }, - role = Role.RadioButton, - ), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - RadioButton(settings.routeAllApps, null) - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.global_vpn)) - Text(stringResource(R.string.global_vpn_description), style = MaterialTheme.typography.bodySmall) + } + if (!settings.routeAllApps) { + item { + val checked = activity.packageName in settings.selectedPackages + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = checked, + onValueChange = { enabled -> + updateSettings(settings.copy(selectedPackages = if (enabled) { + settings.selectedPackages + activity.packageName + } else { + settings.selectedPackages - activity.packageName + })) + }, + role = Role.Switch, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.route_megaproxy)) + Text(stringResource(R.string.route_megaproxy_description), style = MaterialTheme.typography.bodySmall) + } + Switch(checked, null) } } } - } - if (!settings.routeAllApps) { + item { Text(stringResource(R.string.udp_quic_blocked_description), style = MaterialTheme.typography.bodySmall) } item { - val checked = activity.packageName in settings.selectedPackages Row( Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( - value = checked, - onValueChange = { enabled -> - updateSettings(settings.copy(selectedPackages = if (enabled) { - settings.selectedPackages + activity.packageName - } else { - settings.selectedPackages - activity.packageName - })) - }, + value = settings.bypassLocalNetworks, + onValueChange = { updateSettings(settings.copy(bypassLocalNetworks = it)) }, role = Role.Switch, ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { - Text(stringResource(R.string.route_megaproxy)) - Text(stringResource(R.string.route_megaproxy_description), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.bypass_local_networks)) + Text(stringResource(R.string.bypass_local_networks_description), style = MaterialTheme.typography.bodySmall) } - Switch(checked, null) + Switch(settings.bypassLocalNetworks, null) } } - } - item { Text(stringResource(R.string.udp_quic_blocked_description), style = MaterialTheme.typography.bodySmall) } - item { - Row( - Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( - value = settings.bypassLocalNetworks, - onValueChange = { updateSettings(settings.copy(bypassLocalNetworks = it)) }, - role = Role.Switch, - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.bypass_local_networks)) - Text(stringResource(R.string.bypass_local_networks_description), style = MaterialTheme.typography.bodySmall) + if (!settings.routeAllApps) { + item { Text(stringResource(R.string.applications), style = MaterialTheme.typography.titleMedium) } + if (apps == null) item { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } } - Switch(settings.bypassLocalNetworks, null) - } - } - if (!settings.routeAllApps) { - item { Text(stringResource(R.string.applications), style = MaterialTheme.typography.titleMedium) } - if (apps == null) item { - Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { - CircularProgressIndicator() + item { + OutlinedTextField( + value = appSearch, + onValueChange = { appSearch = it }, + label = { FieldLabel(stringResource(R.string.search_applications)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) } - } - item { - OutlinedTextField( - value = appSearch, - onValueChange = { appSearch = it }, - label = { FieldLabel(stringResource(R.string.search_applications)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - items(visibleApps, key = { it.packageName }) { app -> - val checked = app.packageName in settings.selectedPackages - Row( - Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( - value = checked, - onValueChange = { checkedValue -> - updateSettings(settings.copy(selectedPackages = if (checked) { - if (checkedValue) settings.selectedPackages else settings.selectedPackages - app.packageName - } else { - if (checkedValue) settings.selectedPackages + app.packageName else settings.selectedPackages - })) - }, - role = Role.Checkbox, - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Text(app.label, Modifier.weight(1f)) - Checkbox(checked, null) + items(visibleApps, key = { it.packageName }) { app -> + val checked = app.packageName in settings.selectedPackages + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = checked, + onValueChange = { checkedValue -> + updateSettings(settings.copy(selectedPackages = if (checked) { + if (checkedValue) settings.selectedPackages else settings.selectedPackages - app.packageName + } else { + if (checkedValue) settings.selectedPackages + app.packageName else settings.selectedPackages + })) + }, + role = Role.Checkbox, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text(app.label, Modifier.weight(1f)) + Checkbox(checked, null) + } } } + item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } } - item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } } } } diff --git a/app/src/main/java/net/megaproxy487/UiLayout.kt b/app/src/main/java/net/megaproxy487/UiLayout.kt index 52e7055..e599f8a 100644 --- a/app/src/main/java/net/megaproxy487/UiLayout.kt +++ b/app/src/main/java/net/megaproxy487/UiLayout.kt @@ -2,6 +2,9 @@ package net.megaproxy487 import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -18,6 +21,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +/** Keep forms and result cards readable in large windows. */ +@Composable +internal fun ScreenContent(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(Modifier.widthIn(max = 840.dp).fillMaxSize()) { content() } + } +} + /** Actions keep their natural touch targets and wrap instead of squeezing their neighbours. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -78,7 +89,7 @@ internal fun ScreenTitle(title: String) { @Composable internal fun FieldLabel(text: String) { - Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text) } @Composable diff --git a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt index ade187d..97cbb2e 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -101,34 +101,36 @@ internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit, runNetwork }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> - Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - stringResource(R.string.visibility_intro), - style = MaterialTheme.typography.bodyMedium, - ) - Button(shape = RoundedCornerShape(12.dp), onClick = { revision++ }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.run_checks_again)) - } - VisibilitySection(stringResource(R.string.globally_observable_signals), report.global) - if (report.splitTunneling) { + ScreenContent(Modifier.padding(padding)) { + Column( + Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( - stringResource(R.string.split_visibility_explanation), + stringResource(R.string.visibility_intro), + style = MaterialTheme.typography.bodyMedium, + ) + Button(shape = RoundedCornerShape(12.dp), onClick = { revision++ }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.run_checks_again)) + } + VisibilitySection(stringResource(R.string.globally_observable_signals), report.global) + if (report.splitTunneling) { + Text( + stringResource(R.string.split_visibility_explanation), + style = MaterialTheme.typography.bodySmall, + ) + } + VisibilitySection( + stringResource(if (report.splitTunneling) R.string.visible_to_selected_apps else R.string.vpn_network_signals), + report.selectedApps, + ) + VisibilitySection(stringResource(R.string.proxycheck_reputation), proxyCheckChecks) + Text( + stringResource(R.string.visibility_disclaimer), style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 16.dp), ) } - VisibilitySection( - stringResource(if (report.splitTunneling) R.string.visible_to_selected_apps else R.string.vpn_network_signals), - report.selectedApps, - ) - VisibilitySection(stringResource(R.string.proxycheck_reputation), proxyCheckChecks) - Text( - stringResource(R.string.visibility_disclaimer), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 16.dp), - ) } } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6d7b3e2..7e37985 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -56,7 +56,7 @@ Конфигурация содержит поля, неизвестные спецификации MegaProxy. Они игнорируются. Удалено профилей: %1$d. Удалить профили, отсутствующие в конфигурации? - Эти локальные профили отсутствуют в импортированном файле. Изначально ни один профиль не выбран — отметьте только те, которые действительно нужно удалить. + Этих профилей нет в файле. Отметьте только те, которые хотите удалить. Удалить выбранные Оставить все Добавить профиль @@ -72,18 +72,18 @@ Продолжить Экспорт конфигурации Формат - JSON · профили и переносимые настройки - ProxyList.txt · только совместимые поля прокси + JSON · профили и настройки + ProxyList.txt · список прокси Включить пароли - Пароли не включены. Сохранённые пароли используются только для прежних серверов и пользователей. Токены URL остаются в JSON. + Пароли не включены; сохранённые сохранятся только для прежнего сервера и логина. В JSON останутся токены из URL. Включить закрытые ключи SSH Закрытые ключи будут экспортированы в открытом виде. Экспортировать секреты в открытом виде? - Любой, у кого будет доступ к файлу, сможет прочитать выбранные пароли и закрытые ключи SSH. Храните и передавайте файл безопасно. + Файл будет содержать открытые пароли или ключи SSH. Храните и передавайте его безопасно. Всё равно экспортировать Перенос конфигурации Импортировать небезопасные настройки проверки? - В одном или нескольких профилях отключена проверка HTTPS-сертификата или ключа SSH-сервера. Это позволяет подменить сервер; используйте такие настройки только для контролируемых вами серверов. + В профилях отключена проверка сертификатов или ключей SSH. Возможна подмена серверов. Импортируйте только доверенные настройки. Всё равно импортировать Конфигурация успешно экспортирована. Импортировано профилей из FoxyProxy: %1$d. @@ -94,7 +94,7 @@ Не удалось импортировать конфигурацию: %1$s Неизвестная ошибка Проверка импортируемых параметров - Название, оформление, хост, логин и переданные учётные данные обновятся автоматически. Выберите дополнительные настройки, которые нужно заменить. + Названия, серверы, логины и переданные учётные данные обновятся автоматически. Выберите остальные настройки для замены. Применить импортированные глобальные настройки параметры подключения проверка TLS и SSH @@ -115,40 +115,40 @@ SSH, мультиплексирование Импортировать ключ назначения из файла Импортировать ключ jump host из файла - Название профиля (необязательно) - По умолчанию используется адрес прокси + Название (необязательно) + Если пусто — адрес сервера. Флаг страны Без флага Тип профиля - Адрес HTTPS-прокси - Адрес конечного SSH-сервера + HTTPS-прокси + SSH-сервер Порт - Логин Basic Auth - Логин SSH + Имя пользователя + Имя пользователя Пароль - Пароль SSH (необязательно) - Закрытый ключ (необязательно) + Пароль (необязательно) + Приватный ключ (необязательно) Формат PEM или OpenSSH. Ключи с парольной фразой не поддерживаются. Доверенный ключ назначения: %1$s Промежуточный сервер - Адрес промежуточного SSH-сервера + Промежуточный сервер Порт промежуточного сервера - Логин промежуточного SSH-сервера - Пароль промежуточного SSH-сервера (необязательно) - Закрытый ключ промежуточного сервера (необязательно) + Имя пользователя + Пароль (необязательно) + Приватный ключ (необязательно) Доверенный ключ промежуточного сервера: %1$s DNS через HTTPS - Собственный URL DoH + URL сервера DoH Переподключитесь, чтобы применить изменения к активному VPN. Постоянный VPN активен. Изменения применятся при следующем подключении. Разрешить недоверенный сертификат прокси? - Проверка цепочки сертификатов и имени TLS-прокси будет отключена. Злоумышленник сможет выдать себя за прокси и получить данные Basic Auth. Сертификаты конечных HTTPS-сайтов по-прежнему проверяются. + Злоумышленник сможет выдать себя за прокси и украсть логин и пароль. HTTPS-сертификаты сайтов по-прежнему проверяются. Принимать любой ключ SSH-сервера? - Проверка подлинности SSH-сервера %1$s будет отключена, что допускает атаку посредника. Учётные данные и туннелируемый трафик могут быть перехвачены. + SSH-сервер %1$s не будет проверяться. Злоумышленник может украсть учётные данные и перехватить трафик. Принять любой ключ Ключ SSH-сервера изменился Доверять ключу SSH-сервера? - Ранее доверенный ключ сервера %1$s изменился. Это может означать переустановку сервера или атаку посредника. Перед заменой сверьте отпечаток по доверенному каналу.\n\n + Ключ сервера %1$s изменился. Возможна атака. Перед заменой проверьте отпечаток по доверенному каналу.\n\n Это первое подключение к SSH-серверу %1$s. Сверьте отпечаток по доверенному каналу.\n\n Алгоритм: %1$s\nОтпечаток: %2$s Заменить доверенный ключ @@ -162,39 +162,39 @@ Глобальный VPN Только выбранные приложения используют прокси. Все приложения используют прокси. Это режим по умолчанию. - MASQUE передаёт UDP и QUIC. HTTPS тоже передаёт их при успешном «Предпочитать HTTP/3»; после отката на HTTPS и через SSH обычный UDP блокируется. DNS всегда использует настроенный в профиле DoH-сервер. + UDP поддерживают MASQUE, SOCKS5 и HTTPS с HTTP/3. DNS работает по настройкам профиля. Обходить локальные сети - Прямой доступ по TCP к частным и link-local IP-адресам; MASQUE также пропускает локальный UDP. + Прямой доступ к локальным устройствам. Обход для UDP поддерживают MASQUE и SOCKS5. Поиск приложений Направлять MegaProxy через VPN - Повторяет выбор MegaProxy в списке приложений ниже. - Размер журнала с ротацией (MiB) - Общий размер двух сегментов журнала: 1–100 MiB. + Нужно для проверки VPN из приложения. + Размер журнала (MiB) + 1–100 MiB. Старые события удаляются автоматически. Диагностических событий пока нет. Очистить диагностический журнал? - Оба сегмента журнала будут удалены. + Все сохранённые события журнала будут удалены. Диагностический журнал экспортирован. Режим переключения - Переключение срабатывает только после повторных тайм-аутов, сбросов или отсутствия ответа. Ошибки аутентификации, ключей, сертификатов и конфигурации его не запускают. + Смена профиля при повторных сбоях связи. Ошибки настроек и авторизации не вызывают переключение. Резервные профили Профили перебираются в том же порядке, что и на экране профилей. Выберите хотя бы два профиля, иначе переключаться может быть некуда. Внимание: переключение профиля может изменить видимую страну и публичный IP. Включить глобальное переключение? - При вероятной блокировке MegaProxy автоматически попробует любой настроенный профиль. Это может изменить местоположение и публичный IP. Отдельные переключения подтверждать не потребуется. + Пробовать другие профили при признаках блокировки. Переключение без подтверждения может изменить страну и внешний IP. Включить - Профиль постоянного VPN + Профиль при запуске Этот профиль используется, когда Android запускает MegaProxy как постоянный VPN. Постоянный VPN активен. Выбор другого профиля сразу переподключит VPN. - Открыть системные настройки постоянного VPN + Настройки VPN в Android Отпечаток HTTPS - Профиль HTTPS TLS / JA3 - Сейчас по умолчанию используется отпечаток Chrome для Android; он может измениться в будущей версии MegaProxy. + Отпечаток TLS + По умолчанию — отпечаток браузера, выбранный MegaProxy. JA3: версия,шифры,расширения,группы,точки Отпечаток SSH - Профиль SSH-клиента + Отпечаток SSH Аутентификация SSH - Профиль SSH задаёт приветствие клиента и предпочтительный порядок KEX, шифров и MAC. + Как прокси распознаёт ваш SSH-клиент. Изменения сохраняются автоматически и применяются ко всем профилям. Допустимый диапазон: %1$d–%2$d Подключено через: %1$s @@ -204,16 +204,16 @@ Закрыть Проверка подключения MegaProxy ОбнаруженоТолько выбранные приложенияНе обнаружено - Локальные проверки показывают признаки, доступные другому Android-приложению. Внешние проверки репутации ниже отправляют запрос через основную сеть и VPN-туннель, чтобы сравнить репутацию их публичных IP. + Проверка признаков VPN, видимых приложениям. Репутация IP проверяется внешним сервисом через каждую сеть. Глобально видимые признакиВидно выбранным приложениямПризнаки VPN-сети - Признаки ниже относятся к VPN-сети. При раздельном туннелировании Android показывает её выбранным приложениям, а исключённые используют основную сеть. - Обнаруженный признак сам по себе ничего не доказывает. Android намеренно раскрывает часть состояния VPN; такие же признаки создают обычные приложения безопасности и фильтрации. + Эту VPN-сеть используют только выбранные приложения. + Эти признаки не доказывают, что приложение распознаёт ваш VPN. %1$s Исключённые приложения используют основную сеть. Системные параметры проксиВидно глобальное свойство прокси процесса.Глобальное свойство прокси процесса не видно. Имя VPN-интерфейсаИмя активного интерфейса похоже на tun, tap, wg, ppp или ipsec.Активных интерфейсов с типичным VPN-именем нет. Необычный MTU VPNVPN-интерфейс использует MTU, отличный от 1280 и 1500.Необычный MTU у VPN-интерфейсов не найден. - Локальный MITM-сертификатMegaProxy не устанавливает CA-сертификаты и не перехватывает TLS. - Транспорт VPNAndroid показывает сеть с TRANSPORT_VPN.Транспорт VPN сейчас не виден. + Локальный MITM-сертификатMegaProxy не расшифровывает HTTPS-трафик. + Транспорт VPNAndroid сообщает об активной VPN-сети.Транспорт VPN сейчас не виден. Информация о транспорте VPNAndroid показывает VpnTransportInfo для VPN-сети.VpnTransportInfo не виден в доступных сетях. Возможность NOT_VPNУ VPN-сети нет NET_CAPABILITY_NOT_VPN.По этой проверке ни одна видимая сеть не определена как VPN. Прокси VPN-сетиК VPN-сети привязана настройка прокси.К VPN-сети не привязана настройка прокси. @@ -225,7 +225,7 @@ Лимит исчерпанРепутация IP в ProxyCheck ProxyCheck определяет этот адрес как VPN, прокси, выход Tor или другое анонимное соединение.ProxyCheck не определяет этот адрес как VPN, прокси, выход Tor или другое анонимное соединение. Анонимный дневной лимит запросов ProxyCheck исчерпан. Повторите проверку завтра.Запрос к ProxyCheck завершился ошибкой.Определённого результата нет: ProxyCheck вернул ответ в неизвестном формате. - Скачивает и отправляет по %1$d MB через каждый доступный маршрут (до %2$d MB всего). Задержка — медианное время до первого байта по трём запросам. + По %1$d MB на скачивание и отправку через каждую сеть, до %2$d MB всего. %1$d MB Измерить скоростьИзмерение… Выходной IPСтрана @@ -234,8 +234,8 @@ %1$.1f Мбит/сОшибка Выбрать профильНе удалось сохранить ключ SSH-сервера в этом профиле MegaProxy активенПроверить ключ SSH - Постоянный VPN остаётся активным при автоматическом переключении. Каждое переключение отображается в постоянном уведомлении и на главном экране. - Постоянный VPN остаётся активным при автоматическом переключении. В уведомлении и на главном экране отображается фактически используемый профиль. + Постоянный VPN остаётся включённым. Текущий профиль показан в уведомлении. + Постоянный VPN остаётся включённым. Текущий профиль показан в уведомлении. Интервал keepalive SSH, секунд (0 — отключено)Максимум параллельных каналов SSHСменить SSH-сессию через минут (0 — отключено)Сменить SSH-сессию после MiB (0 — отключено) Не удалось открыть почтовое приложениеJA3 должен содержать пять полей: версия,шифры,расширения,группы,точки Получено @@ -249,17 +249,17 @@ HTTPS через Jump Телефон → промежуточный HTTPS-прокси → конечный HTTPS-прокси → сайт. Промежуточный HTTPS-прокси (первое звено) - Имя промежуточного HTTPS-прокси - Имя пользователя Basic Auth промежуточного прокси - Пароль Basic Auth промежуточного прокси + Промежуточный прокси + Имя пользователя + Пароль Использовать те же данные входа Использовать имя пользователя и пароль конечного прокси. Разрешить самоподписанный сертификат прокси Отключает проверку сертификата только конечного TLS-прокси. Разрешить самоподписанный сертификат промежуточного прокси Отключает проверку сертификата только промежуточного HTTPS-прокси. - Это отключает проверку цепочки сертификатов и имени промежуточного HTTPS-прокси. Злоумышленник сможет выдать себя за него и получить его данные Basic Auth. Проверка сертификата конечного прокси определяется отдельной настройкой. - Имя конечного HTTPS-прокси + Злоумышленник сможет выдать себя за промежуточный прокси и украсть логин и пароль. Проверка конечного прокси не меняется. + Конечный прокси Введите имя прокси Введите имя SSH-сервера Введите имя хоста без схемы и пути @@ -272,7 +272,7 @@ Порт промежуточного сервера должен быть от 1 до 65535 Введите имя пользователя промежуточного SSH-сервера JA3 должен содержать пять полей: version,ciphers,extensions,groups,points - Здесь нужен DNS over HTTPS (DoH), а не обычный DNS или DNS over TLS. Введите HTTPS URL, например https://dns.example/dns-query. + Укажите HTTPS URL, например https://dns.example/dns-query. Укажите HTTPS URL с корректным хостом и портом, без логина, пароля и #фрагмента. IPv6-адрес заключите в квадратные скобки. Введите имя пользователя Basic Auth промежуточного прокси Введите пароль Basic Auth промежуточного прокси @@ -292,13 +292,13 @@ Постоянный VPN · Блокировка без VPN Постоянный VPN Разрешение на VPN не предоставлено в диалоге Android - Используется постоянный VPN. Выбор профиля меняет профиль постоянного VPN и сразу переподключает его; отключением управляет Android. - Пароль SSH и закрытый ключ не заданы. Подключение возможно, только если сервер разрешает вход без учётных данных. + Выбор профиля сразу переподключает постоянный VPN. Отключение — в настройках Android. + Нет пароля и ключа. SSH-сервер должен разрешать вход без них. Принимать любой ключ конечного сервера Небезопасно: отключает проверку подлинности SSH-сервера. Использовать те же данные для входа Использовать имя пользователя, пароль и закрытый ключ конечного сервера. - Пароль и закрытый ключ промежуточного сервера не заданы. Подключение возможно, только если он разрешает вход без учётных данных. + Нет пароля и ключа промежуточного сервера. Он должен разрешать вход без них. Принимать любой ключ промежуточного сервера Небезопасно: отключает проверку подлинности промежуточного сервера. Разрешить подключения к IPv6-адресам @@ -390,7 +390,7 @@ В конфигурации FoxyProxy нет пригодных для использования прокси В записи FoxyProxy %1$d указан некорректный адрес сервера Некорректные данные конфигурации - Не удалось расшифровать некоторые сохранённые учётные данные. Их зашифрованные значения сохранены. Введите затронутые учётные данные заново перед подключением или их экспортом. + Часть учётных данных не расшифрована. Данные сохранены. Введите их заново перед подключением или экспортом. Сохранённые профили недоступны Не удалось прочитать сохранённые профили. Их данные сохранены. Импортируйте резервную копию конфигурации для восстановления. Не удалось открыть файл для экспорта @@ -470,7 +470,7 @@ Обновления приложения Проверка новой версии в F-Droid или GitHub Установленная версия: %1$s - Проверка выполняется примерно раз в сутки при наличии сети. О проигнорированном обновлении напомним через неделю. APK скачивается только с вашего согласия. + Проверка раз в день, напоминание через неделю. Скачивание — с вашего согласия. Источник обновлений Android не сообщил источник установки. Выберите источник обновлений. F-Droid @@ -479,16 +479,16 @@ Проверить обновления В этом источнике нет более новой версии. Приложение или релиз ещё не опубликованы в этом источнике. Источник не изменён. - Не удалось проверить или безопасно скачать обновление. Проверьте соединение и повторите попытку. Установленное приложение не изменено. - Не удалось открыть магазин, браузер или установщик Android. Если скачанный файл был удалён, проверьте и скачайте обновление заново. + Не удалось проверить или скачать обновление. Проверьте сеть и повторите. + Не удалось открыть магазин, браузер или установщик. Если APK удалён, скачайте его снова. Проверка или скачивание… Новая версия: %1$s Обновить через F-Droid Скачать с GitHub APK скачан и проверен. Android проверит его при установке. - Нажмите «Установить обновление». Если Android запросит разрешение, разрешите установку из MegaProxy, вернитесь сюда и нажмите кнопку ещё раз. Подтвердите обновление в системном установщике. + Если Android попросит, разрешите установку из MegaProxy. Вернитесь и снова нажмите «Установить». Установить обновление - APK будет скачан с GitHub в обход проверок F-Droid. Эта версия могла ещё не пройти проверку исходного кода и воспроизводимости в F-Droid. Продолжайте, только если доверяете этому источнику. Можно отменить скачивание и продолжить пользоваться установленной версией. Установка запускается отдельным действием. + Скачать из GitHub без проверки F-Droid? Продолжайте, только если доверяете источнику. Установка потребует отдельного подтверждения. Проверять обновления автоматически Уведомления отключены. Разрешите их, чтобы получать напоминания об обновлениях. Разрешить уведомления @@ -497,23 +497,23 @@ Пропустить версию Отключить автопроверку QUIC RTT - QUIC-пакеты, объявленные потерянными: %1$d · за 5 мин - Потери QUIC-пакетов: нет данных - Только до прокси. Число исходящих QUIC-пакетов, объявленных потерянными, а не TCP-ретрансляций или потерь на всём пути. + Потеряно пакетов QUIC: %1$d · за 5 мин + Потери QUIC: нет данных + Только до прокси; не потери на всём пути. TCP RTT TCP-ретрансляции: %1$d · за 5 мин TCP-ретрансляции: нет данных - Только до первого прокси. Ретрансляции — повторно отправленные TCP-сегменты, а не потеря пакетов. + Только до первого прокси; ретрансляции — не потери пакетов. Напомнить через неделю - Фоновая проверка ещё не запускалась. Время запуска выбирает Android. + Фоновых проверок ещё не было. Последняя фоновая попытка: %1$s - Последняя попытка началась, но её результат ещё не записан. - Найдена новая версия. Показ уведомления зависит от системных разрешений и настроек напоминания. - Ошибка сети или временная ошибка сервера. Повтор запрошен через 30 минут с увеличением интервала; Android может отложить запуск. + Результата последней проверки пока нет. + Найдена новая версия. + Ошибка сети или сервера. Запланирована повторная проверка. Android прервал проверку. Запрошена повторная попытка. - Проверка не удалась. Повторите вручную; выбранный источник не изменён. + Проверка не удалась. Повторите вручную. MASQUE α (HTTP/3) - Адрес MASQUE-прокси + MASQUE-прокси HTTP/3, мультиплексирование HTTP/3 · %1$s %2$s HTTP/3 по UDP, %1$s: %2$s @@ -523,33 +523,33 @@ не подтверждён HTTP/3 проверяет UDP через прокси. Недоступность тестового сервиса не отменяет результат проверки HTTPS. Для MASQUE JA3 должен содержать шифры TLS 1.3 и расширения 16, 43, 51, 57. - Для QUIC используются отпечатки Chrome 146 или Firefox 116. В режиме ручного JA3 задайте QUIC JA3 в этом профиле; HTTPS использует JA3 из настроек. + Выберите отпечаток QUIC. Для ручного режима укажите JA3 в этом профиле. QUIC JA3 для MASQUE Предпочитать HTTP/3 α - Пробовать MASQUE на том же хосте и UDP-порту. С Jump нужны оба узла; иначе вся цепочка использует HTTPS. При откате обычный UDP блокируется; DNS работает через DoH. Неподдерживаемые QUIC-отпечатки или MTU туннеля используют HTTPS. + Пробовать HTTP/3, затем HTTPS. В цепочке HTTP/3 нужен на обоих серверах. При переходе на HTTPS обычный UDP блокируется. HTTP/3 недоступен: используется HTTPS. Обычный UDP блокируется; DNS работает через DoH. SOCKS5 - Имя сервера SOCKS5 - Логин SOCKS5 (необязательно) - Пароль SOCKS5 (необязательно) + SOCKS5-прокси + Имя пользователя (необязательно) + Пароль (необязательно) Оставьте оба поля пустыми или укажите логин и пароль по 1–255 байт UTF-8 каждый. Имя пользователя Basic Auth не должно содержать двоеточие (:). - TCP и UDP ASSOCIATE, DNS через HTTPS через прокси. SOCKS5 не шифрует соединение с прокси, включая пароль, и не использует отпечаток TLS. + Поддерживает TCP и UDP. Связь с прокси и его логин и пароль не шифруются. DNS по умолчанию — DoH. Подписка на конфигурацию Обновление профилей из доверенных HTTPS-источников - Подписывайтесь только на доверенные источники: они могут заменять профили подписки, настройки соединения и URL источников. Сохранённые учётные данные подписки будут отправляться и на новые URL. Загрузки подчиняются маршрутизации Android VPN для этого приложения. - Не удалось прочитать или сохранить подписку. Загрузка без сохранённой авторизации не выполняется. + Доверяйте источнику: он может менять профили и URL подписки. На новые URL тоже отправляются её логин и пароль. + Не удалось прочитать или сохранить подписку. Повторите попытку. Автоматическое обновление Последнее успешное обновление: %1$s - Успешный источник: %1$d (1 — основной) + Источник: %1$d (1 = основной) Все источники недоступны или содержат неподходящий конфиг. Последняя рабочая конфигурация сохранена. Импорт выполнен с предупреждениями: некоторые профили или настройки пропущены. - Настройки соединения изменились. Переподключите VPN, чтобы применить их; текущий туннель продолжает работать с прежними настройками. + Настройки обновлены. Переподключитесь для их применения. Обновить сейчас Основной HTTPS URL - Резервные HTTPS URL, по одному на строку (до 7) - Использовать отдельные учётные данные Basic Auth - Интервал обновления (минуты, 1–10080) + Резервные HTTPS URL + Использовать логин и пароль + Интервал (1–10080 мин) Проверьте HTTPS URL, повторяющиеся источники, учётные данные и интервал. Перенаправления не поддерживаются. Удалить подписку и сохранить профили Сохранить подписку @@ -558,7 +558,7 @@ Настройки VPN обновлены Фоновое обновление подписки изменило настройки активного соединения. Переподключитесь, чтобы применить их. Загрузка конфигурации… - Предпочтительный HTTP-протокол (с переходом на младшие версии) + Предпочитаемая версия HTTP DNS Транспорт DNS DNS over HTTPS (DoH) @@ -570,22 +570,23 @@ TCP DNS не шифруется после прокси. UDP DNS не шифруется и может идти в обход VPN. DNS-трафик после прокси не шифруется. UDP DNS не шифруется и может идти в обход VPN. - IP DNS-сервера и необязательный порт + DNS-сервер: IP[:порт] Введите IPv4 или IPv6 в квадратных скобках; необязательный порт — 1–65535. Профиль использует небезопасный DNS. Разрешите его локально или выберите DoH. Разрешить недоверенные сертификаты DoH - Допускает самоподписанные, просроченные сертификаты и несовпадение имени только для DoH. DNS-трафик может быть перехвачен. Проверка сертификата прокси не меняется. + Ответы DNS могут быть перехвачены или подменены. Отключается только проверка сертификатов DoH; проверка прокси не меняется. Настройки батареи недоступны на этом устройстве. Достигнут лимит хранилища конфигурации. Удалите профили или уменьшите их содержимое. - MegaProxy не выбран для раздельного туннелирования. Для измерения скорости VPN-туннеля включите «Направлять MegaProxy через VPN» в настройках раздельного туннелирования и переподключитесь. Основную сеть можно измерить. + Для замера VPN выберите MegaProxy в раздельном туннелировании и переподключитесь. Основную сеть можно проверить сейчас. MTU Размер пакетов VPN-интерфейса - Максимальный размер IP-пакета VPN-интерфейса в байтах. 1400 оставляет запас для накладных расходов туннеля; оптимальное значение зависит от сети. + Максимальный размер пакета VPN в байтах. Начните с 1400; меняйте при необходимости для вашей сети. 1280 · Минимум для IPv6 1400 · Рекомендуется (по умолчанию) 1500 Своё значение MTU (байты) Введите целое число от 1280 до 65535. - Действует для всех профилей на этом устройстве. Переподключите активный VPN, чтобы применить изменения. + Для всех профилей на этом устройстве, после переподключения. + До 7 HTTPS URL, по одному на строку. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 672af23..3115218 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,7 @@ The configuration contains fields unknown to the MegaProxy specification. They are ignored. Removed %1$d profile(s). Remove profiles missing from the configuration? - These local profiles were not present in the imported file. None are selected by default; select only profiles you really want to remove. + These profiles are missing from the file. Select only those you want to delete. Remove selected Keep all Back @@ -73,18 +73,18 @@ Continue Export configuration Format - JSON · profiles and portable settings - ProxyList.txt · compatible proxy fields only + JSON · profiles and settings + ProxyList.txt · proxy list Include passwords - Passwords are omitted. Saved passwords are reused only for unchanged servers and accounts. URL tokens remain in JSON. + Passwords are omitted; existing ones are kept only for the same server and account. JSON still contains URL tokens. Include SSH private keys Private keys will be exported in plain text. Export secrets in plain text? - Anyone with access to the exported file will be able to read the selected passwords or SSH private keys. Store and transfer it securely. + The file will contain readable passwords or SSH keys. Store and share it securely. Export anyway Configuration transfer Import insecure verification settings? - One or more profiles disable HTTPS certificate or SSH host-key verification. This permits server impersonation and should only be used for servers you control. + Some profiles disable certificate or SSH key checks. An attacker could impersonate those servers. Import only trusted settings. Import anyway Configuration exported successfully. Imported %1$d proxy profile(s) from FoxyProxy. @@ -95,7 +95,7 @@ Could not import the configuration: %1$s Unknown error Review imported options - Name, appearance, host, username and provided credentials will update automatically. Select the additional settings you want to replace. + Names, servers, accounts and supplied credentials update automatically. Choose which other settings to replace. Apply imported global settings connection options TLS and SSH verification @@ -116,40 +116,40 @@ SSH multiplexed Import destination key from file Import jump key from file - Profile name (optional) - Defaults to the proxy host + Name (optional) + Uses the server address if empty. Country flag No flag Profile type - HTTPS proxy hostname - Destination SSH hostname + HTTPS proxy + SSH server Port - Basic Auth username - SSH username + Username + Username Password - SSH password (optional) + Password (optional) Private key (optional) PEM or OpenSSH format. Passphrase-protected keys are not supported. Trusted destination key: %1$s Jump host - Jump SSH hostname + Jump server Jump port - Jump SSH username - Jump SSH password (optional) - Jump private key (optional) + Username + Password (optional) + Private key (optional) Trusted jump key: %1$s DNS over HTTPS - Custom DoH URL + DoH URL Reconnect to apply these changes to the active VPN. Always-on is active. Changes apply on the next connection. Allow an untrusted proxy certificate? - This disables certificate-chain and hostname verification between your phone and the TLS proxy. An attacker could impersonate the proxy and obtain its Basic Auth credentials. Destination HTTPS certificates remain verified. + An attacker could impersonate this proxy and steal its login and password. Website HTTPS certificates remain verified. Accept any SSH host key? - This disables identity verification for the %1$s SSH host and permits man-in-the-middle attacks. Credentials and tunneled traffic could be intercepted. + The %1$s SSH server will not be verified. An attacker could steal credentials and intercept traffic. Accept any key SSH host key changed Trust SSH host key? - Warning: the previously trusted key for the %1$s host has changed. This can indicate a server reinstall or a man-in-the-middle attack. Verify the fingerprint through a trusted channel before replacing it.\n\n + The %1$s host key changed. This may indicate an attack. Verify the fingerprint through a trusted channel before replacing it.\n\n This is the first connection to the %1$s SSH host. Verify its fingerprint through a trusted channel.\n\n Algorithm: %1$s\nFingerprint: %2$s Replace trusted key @@ -163,39 +163,39 @@ Global VPN Only selected applications use the proxy. All applications use the proxy. This is the default mode. - MASQUE forwards UDP and QUIC. HTTPS does too when Prefer HTTP/3 succeeds; fallback HTTPS and SSH block ordinary UDP. DNS always uses the profile\'s configured DoH endpoint. + UDP is supported by MASQUE, SOCKS5 and HTTPS with HTTP/3. DNS uses the profile settings. Bypass local networks - Direct TCP access to private and link-local IP addresses; MASQUE also bypasses local UDP. + Access local devices directly. Local UDP bypass is supported by MASQUE and SOCKS5. Search applications Route MegaProxy through VPN - Mirrors the MegaProxy checkbox in the application list below. - Rotated log limit (MiB) - Total size across both log segments; 1–100 MiB. + Needed to test the VPN from this app. + Log size (MiB) + 1–100 MiB. Older events are removed automatically. No diagnostic events yet. Clear diagnostic log? - Both rotated log segments will be deleted. + All saved log events will be deleted. Diagnostic log exported. Failover mode - Failover is only triggered after repeated timeout, reset or silent-drop signals. Authentication, host-key, certificate and configuration errors never trigger it. + Switch profiles after repeated connection failures. Settings and authentication errors do not trigger a switch. Fallback profiles Profiles are tried in the same order as the Profiles screen. Select at least two profiles. With fewer profiles, failover may have nowhere to switch. Warning: switching profiles may change the apparent country and public exit IP. Enable global failover? - MegaProxy will automatically try any configured profile when blocking is suspected. This can change your location and public exit IP. Individual switches will not require confirmation. + Automatically try other profiles if the proxy seems blocked. Switches need no confirmation and may change your location and exit IP. Enable - Profile for Always-on VPN + Startup profile This profile is used when Android starts MegaProxy as an Always-on VPN. Always-on is active. Selecting another profile reconnects immediately. - Open Android Always-on VPN settings + Open Android VPN settings HTTPS fingerprint - HTTPS TLS / JA3 profile - Default currently uses the Chrome Android fingerprint and may change in a future MegaProxy update. + TLS fingerprint + Default uses a browser fingerprint chosen by MegaProxy. JA3: version,ciphers,extensions,groups,points SSH fingerprint - SSH client profile + SSH fingerprint SSH authentication - The SSH profile controls the client banner and preferred KEX, cipher and MAC ordering. + How the proxy identifies your SSH client. Changes are saved automatically and apply to every profile. Allowed range: %1$d–%2$d Connected through: %1$s @@ -205,16 +205,16 @@ Close MegaProxy connection test DetectedSelected apps onlyNot detected - Local checks show signals that another Android app may observe. The external reputation checks below send a request over the underlying network and VPN tunnel to compare their public IP reputation. + Check VPN signals visible to apps. IP reputation checks contact an external service over each network. Globally observable signalsVisible to selected appsVPN network signals - The signals below belong to the VPN network. With split tunneling, Android exposes that network to selected apps; excluded apps use the underlying network instead. - A detected signal is not proof by itself. Android VPN APIs intentionally expose some VPN state, and legitimate security or filtering apps can produce the same signals. + Only selected apps use this VPN network. + These signals do not prove that an app can detect your VPN. %1$s Excluded apps use their underlying network. System proxy propertiesA process-wide proxy property is visible.No process-wide proxy property is visible. VPN-like interface nameAn active interface has a tun, tap, wg, ppp, or ipsec-style name.No active interface has a common VPN-style name. Unusual VPN MTUA VPN-style interface uses an MTU other than 1280 or 1500.No unusual MTU was found on VPN-style interfaces. - Local MITM certificateMegaProxy installs no CA certificate and performs no TLS interception. - VPN transportAndroid exposes a network with TRANSPORT_VPN.No VPN transport is currently exposed. + Local MITM certificateMegaProxy does not intercept HTTPS traffic. + VPN transportAndroid reports an active VPN network.No VPN transport is currently exposed. VPN transport informationAndroid exposes VpnTransportInfo for the VPN network.VpnTransportInfo is not exposed on the currently visible networks. NOT_VPN capabilityThe VPN network does not have NET_CAPABILITY_NOT_VPN.No visible network is identified by this capability check as a VPN. VPN link proxyA proxy setting is attached to the VPN network.No proxy setting is attached to the VPN network. @@ -226,7 +226,7 @@ Limit reachedProxyCheck IP reputation ProxyCheck identifies this address as a VPN, proxy, Tor exit, or other anonymous connection.ProxyCheck does not identify this address as a VPN, proxy, Tor exit, or other anonymous connection. ProxyCheck’s anonymous daily request limit has been reached. Try again tomorrow.The ProxyCheck request failed.No definite result: ProxyCheck returned an unexpected response format. - Downloads and uploads %1$d MB over each available route (up to %2$d MB total). Latency is the median time to first byte from three requests. + %1$d MB per direction and route, up to %2$d MB total. %1$d MB Measure speedMeasuring… Exit IPCountry @@ -235,8 +235,8 @@ %1$.1f Mbit/sFailed Select profileThe SSH host key could not be saved to this profile MegaProxy is activeReview SSH key - Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen. - Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use. + Always-on stays active during switches. The notification shows the current profile. + Always-on stays active during switches. The notification shows the current profile. SSH keepalive, seconds (0 = disabled)Maximum parallel SSH channelsRotate SSH session after minutes (0 = disabled)Rotate SSH session after MiB (0 = disabled) Could not open an email clientJA3 must contain five fields: version,ciphers,extensions,groups,points Download @@ -250,17 +250,17 @@ HTTPS with Jump Phone → jump HTTPS proxy → destination HTTPS proxy → website. Jump HTTPS proxy (first hop) - Jump HTTPS proxy hostname - Jump Basic Auth username - Jump Basic Auth password + Jump proxy + Username + Password Use the same authentication Reuse the destination proxy username and password. Allow self-signed proxy certificate Disables certificate verification only for the destination TLS proxy. Allow self-signed jump proxy certificate Disables certificate verification only for the jump HTTPS proxy. - This disables certificate-chain and hostname verification for the jump HTTPS proxy. An attacker could impersonate it and obtain its Basic Auth credentials. The destination proxy certificate remains subject to its own setting. - Destination HTTPS proxy hostname + An attacker could impersonate the jump proxy and steal its login and password. Destination proxy verification is unchanged. + Destination proxy Enter the proxy hostname Enter the SSH hostname Enter a hostname without a scheme or path @@ -273,7 +273,7 @@ Jump port must be between 1 and 65535 Enter the jump SSH username JA3 must contain five fields: version,ciphers,extensions,groups,points - This field requires DNS over HTTPS (DoH), not plain DNS or DNS over TLS. Use an HTTPS URL, for example https://dns.example/dns-query. + Enter an HTTPS URL, such as https://dns.example/dns-query. Use an HTTPS URL with a valid host and port, no credentials or #fragment. Enclose IPv6 addresses in brackets. Enter the jump Basic Auth username Enter the jump Basic Auth password @@ -293,13 +293,13 @@ Always-on · Block without VPN Always-on VPN VPN access was not granted in the Android confirmation dialog - Always-on VPN is used. Selecting a profile updates the Always-on profile and reconnects immediately; disconnect remains managed by Android. - No SSH password or private key is configured. Connection will only work if the server permits authentication without credentials. + Selecting a profile reconnects Always-on VPN immediately. Android manages disconnection. + No password or key. The SSH server must allow login without credentials. Accept any destination host key Unsafe: disables SSH server identity verification. Use the same authentication Reuse destination username, password and private key. - No jump password or private key is configured. Connection will only work if the jump server permits authentication without credentials. + No jump password or key. The jump server must allow login without credentials. Accept any jump host key Unsafe: disables jump host identity verification. Enable IPv6 destinations @@ -391,7 +391,7 @@ The FoxyProxy configuration contains no usable proxies FoxyProxy entry %1$d has an invalid hostname Invalid configuration data - Some saved credentials could not be decrypted. Their encrypted data was kept. Re-enter the affected credentials before connecting or exporting them. + Could not decrypt some credentials. Stored data was kept. Re-enter the affected credentials before connecting or exporting. Saved profiles unavailable Saved profiles could not be read. Their stored data was kept. Import a configuration backup to restore them. Could not open the export file @@ -471,7 +471,7 @@ App updates Check F-Droid or GitHub for a new version Installed version: %1$s - Checks run about once a day when online. An ignored update is announced again after a week. APKs are downloaded only with your consent. + Checks daily. Reminders repeat after a week. Downloads require your consent. Update source Android did not identify the installer. Choose an update source. F-Droid @@ -480,16 +480,16 @@ Check for updates No newer version is available from this source. The app or release is not published in this source yet. The source has not been changed. - Could not check or download the update safely. Check your connection and retry. The installed app has not been changed. - Could not open the store, browser, or Android installer. If the download was cleared, check and download again. + Could not check or download the update. Check your connection and retry. + Could not open the store, browser or installer. Try downloading again if the APK is missing. Checking or downloading… New version: %1$s Update through F-Droid Download from GitHub The APK has been downloaded and checked. Android will verify it during installation. - Tap Install update. If Android asks, allow installs from MegaProxy, return here and tap the button again. Confirm the update in the system installer. + If prompted, allow installs from MegaProxy, then return and tap Install update again. Install update - The APK will be downloaded from GitHub, outside F-Droid’s checks. It may not have been reviewed or reproduced by F-Droid. Continue only if you trust this source. You can cancel and keep using the installed version. Installation requires a separate action. + Download from GitHub without F-Droid review? Continue only if you trust this source. Installation requires a separate confirmation. Check for updates automatically Notifications are disabled. Allow them to receive update reminders. Allow notifications @@ -498,23 +498,23 @@ Skip this version Disable auto-checks QUIC RTT - QUIC packets declared lost: %1$d · last 5 min - QUIC packet loss: no data - To the proxy only. Counts outgoing QUIC packets declared lost, not TCP retransmits or end-to-end packet loss. + QUIC packets lost: %1$d · last 5 min + QUIC losses: no data + To the proxy only; not end-to-end packet loss. TCP RTT TCP retransmits: %1$d · last 5 min TCP retransmits: no data - To the first proxy only. Retransmits count resent outgoing TCP segments, not packet loss. + To the first proxy only; retransmits are not packet loss. Remind me in a week - No background check has run yet. Android chooses when to run checks. + No background checks yet. Last background attempt: %1$s - The last attempt started but has not recorded a result yet. - A newer version was found. Notification delivery depends on system permissions and reminder settings. - A network or temporary server error occurred. A retry is requested after 30 minutes, with increasing delays; Android may defer it. + The last check has no result yet. + A newer version was found. + Network or server error. A background retry is scheduled. Android interrupted the check. A retry was requested. - The check failed. Retry manually; the selected source has not changed. + Check failed. Try again manually. MASQUE α (HTTP/3) - MASQUE proxy hostname + MASQUE proxy HTTP/3 multiplexed HTTP/3 · %1$s %2$s HTTP/3 over UDP, %1$s: %2$s @@ -524,33 +524,33 @@ not confirmed HTTP/3 checks UDP through the proxy. An unavailable test service does not invalidate the HTTPS result. MASQUE JA3 requires TLS 1.3 cipher suites and extensions 16, 43, 51, 57. - QUIC uses Chrome 146 or Firefox 116 fingerprints. In Manual JA3 mode, set QUIC JA3 in this profile; HTTPS uses the JA3 from Settings. + Choose a QUIC fingerprint. Manual mode needs a JA3 value in this profile. MASQUE QUIC JA3 Prefer HTTP/3 α - Try MASQUE on the same host and UDP port. With Jump, both nodes must support it; otherwise the whole chain uses HTTPS. HTTPS fallback blocks ordinary UDP; DNS still uses DoH. Unsupported QUIC fingerprints or tunnel MTU use HTTPS. + Try HTTP/3, then HTTPS. Both jump servers must support HTTP/3. HTTPS fallback blocks ordinary UDP. HTTP/3 unavailable: using HTTPS. Ordinary UDP is blocked; DNS uses DoH. SOCKS5 - SOCKS5 proxy hostname - SOCKS5 username (optional) - SOCKS5 password (optional) + SOCKS5 proxy + Username (optional) + Password (optional) Leave both credentials empty, or enter a username and password of 1–255 UTF-8 bytes each. Basic Auth username must not contain a colon (:). - TCP and UDP ASSOCIATE, with DNS over HTTPS through the proxy. SOCKS5 does not encrypt the connection to the proxy, including credentials, and has no TLS fingerprint. + Supports TCP and UDP. The proxy connection and its credentials are not encrypted. DNS uses DoH by default. Configuration subscription Keep profiles updated from trusted HTTPS sources - Only subscribe to trusted sources: they can replace subscribed profiles, connection settings and source URLs. Saved subscription credentials will also be sent to replacement URLs. Downloads follow Android VPN routing for this app. - Could not read or save the subscription. No unauthenticated fallback is used. + Trust the source: it can change profiles and source URLs. Your subscription credentials are sent to replacement URLs too. + Could not read or save the subscription. Please retry. Automatic updates Last successful update: %1$s - Successful source: %1$d (1 is primary) + Source: %1$d (1 = primary) All sources failed. The last working configuration was kept. Imported with warnings: some profiles or settings were ignored. - Connection settings changed. Reconnect the VPN to apply them; the current tunnel continues using its previous settings. + Settings updated. Reconnect to apply them. Update now Primary HTTPS URL - Backup HTTPS URLs, one per line (up to 7) - Use separate Basic Auth credentials - Update interval (minutes, 1–10080) + Backup HTTPS URLs + Use a username and password + Interval (1–10080 min) Check HTTPS URLs, duplicate sources, credentials and interval. Redirects are not supported. Remove subscription and keep profiles Save subscription @@ -559,7 +559,7 @@ VPN settings updated A background subscription update changed the active connection settings. Reconnect to apply them. Loading configuration… - Preferred HTTP protocol (falls back to lower versions) + Preferred HTTP version DNS DNS transport DNS over HTTPS (DoH) @@ -571,22 +571,23 @@ TCP DNS is unencrypted after the proxy. UDP DNS is unencrypted and may bypass the VPN. DNS traffic after the proxy is not encrypted. UDP DNS is unencrypted and may bypass the VPN. - DNS server IP and optional port + DNS server: IP[:port] Enter an IPv4 address or bracketed IPv6 address, optionally with port 1–65535. This profile uses insecure DNS. Enable it locally or choose DoH. Allow invalid DoH certificates - Allows self-signed, expired and mismatched certificates for DoH only. DNS traffic may be intercepted. Proxy certificate verification is unchanged. + DNS replies may be intercepted or changed. Only DoH certificate checks are disabled; proxy checks are unchanged. Battery settings are unavailable on this device. Configuration storage limit reached. Remove profiles or reduce their contents. - MegaProxy is not selected for split tunneling. To measure VPN tunnel speed, enable “Route MegaProxy through VPN” in split tunneling settings and reconnect. The underlying network can still be measured. + To measure VPN speed, select MegaProxy in split tunneling and reconnect. The underlying network can still be tested. MTU VPN interface packet size - The maximum IP packet size of the VPN interface, in bytes. 1400 leaves headroom for tunnel overhead; the best value depends on your network. + Maximum VPN packet size in bytes. Start with 1400; change it if your network needs a different value. 1280 · IPv6 minimum 1400 · Recommended (default) 1500 Custom MTU (bytes) Enter a whole number from 1280 to 65535. - Applies to all profiles on this device. Reconnect an active VPN to apply changes. + Applies to all profiles on this device after reconnection. + Up to 7 HTTPS URLs, one per line. diff --git a/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt b/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt new file mode 100644 index 0000000..2f8a75d --- /dev/null +++ b/app/src/test/java/net/megaproxy487/AdaptiveScreensUiTest.kt @@ -0,0 +1,84 @@ +package net.megaproxy487 + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import org.junit.Assert.* +import org.junit.Test +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Config + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class AdaptiveScreensUiTest : MainUiTestBase() { + private fun assertTextFits(label: String) { + val layouts = mutableListOf() + compose.onNodeWithText(label, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertTrue("No text layout for $label", layouts.isNotEmpty()) + layouts.forEach { layout -> + assertFalse("Clipped text height: $label", layout.didOverflowHeight) + repeat(layout.lineCount) { line -> + // Compare glyph width, allowing rounding to whole layout pixels. + val width = layout.getLineRight(line) - layout.getLineLeft(line) + assertTrue("Clipped text: $label ($width > ${layout.size.width})", width <= layout.size.width + 1f) + assertFalse("Ellipsized text: $label", layout.isLineEllipsized(line)) + } + } + } + + @Test @Config(qualifiers = "ru-w320dp-h960dp") + fun speedOptionsRemainReadableWithLargeFonts() { + content { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 2f)) { + SpeedTestScreen(activity) {} + } + } + SPEED_TEST_MEGABYTES.forEach { size -> assertTextFits(activity.uiText(R.string.cloudflare_speed_size, size)) } + SPEED_TEST_PROTOCOLS.forEach(::assertTextFits) + val first = compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_size, 10)).fetchSemanticsNode().boundsInRoot + val last = compose.onNodeWithText(activity.uiText(R.string.cloudflare_speed_size, 50)).fetchSemanticsNode().boundsInRoot + assertTrue("Options must wrap on narrow windows", last.top > first.top) + } + + @Test @Config(qualifiers = "ru-w320dp-h960dp") + fun fieldLabelCanWrapWithLargeFonts() { + val label = text(R.string.jump_private_key_optional) + content { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 2f)) { + PasswordField("", {}, label, modifier = Modifier.fillMaxWidth()) + } + } + assertTextFits(label) + } + + @Test @Config(qualifiers = "en-w1280dp-h900dp") + fun profileListAndAddButtonShareCenteredWidth() { + content { ProfilesScreen(activity, {}, {}) } + val density = activity.resources.displayMetrics.density + val list = compose.onNode(hasScrollAction()).getUnclippedBoundsInRoot() + val button = node(R.string.add_profile).getUnclippedBoundsInRoot() + assertTrue("Profile list exceeds content limit", (list.right.value - list.left.value) <= 840f) + assertTrue("Add button exceeds content limit", (button.right.value - button.left.value) <= 840f) + assertEquals(640f, (list.left.value + list.right.value) / 2, 1f / density) + assertEquals(640f, (button.left.value + button.right.value) / 2, 1f / density) + } + + @Test @Config(qualifiers = "en-w1280dp-h900dp") + fun editorFieldsShareCenteredWidth() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + val fields = compose.onAllNodes(hasSetTextAction()).fetchSemanticsNodes() + assertTrue(fields.isNotEmpty()) + val density = activity.resources.displayMetrics.density + fields.forEach { field -> + val bounds = field.boundsInRoot + assertTrue("Editor field exceeds content limit", bounds.width / density <= 840f) + assertEquals(640f, bounds.center.x / density, 1f) + } + } +} diff --git a/docs/en/connection-metrics.md b/docs/en/connection-metrics.md index 61b8da9..c58011c 100644 --- a/docs/en/connection-metrics.md +++ b/docs/en/connection-metrics.md @@ -109,6 +109,8 @@ These are separate entries in the main menu; they measure different things: With split tunneling, select MegaProxy to measure the VPN route. It is selected by default; existing application lists gain it once on upgrade. Explicitly removing it afterwards is preserved. +On narrow windows, Speed shows the underlying network and VPN results in separate blocks. Wider windows show a comparison table. The HTTP preference may fall back to lower versions; see the negotiation rules below. + 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 @@ -125,3 +127,8 @@ and unknown response format are distinct states; unavailable routes are shown se Tests parse fixtures rather than contacting ProxyCheck. Full endpoint inventory: [README](../../README.md#privacy-and-security). [Русская версия](../ru/connection-metrics.md) + +UDP forwarding is available with MASQUE, SOCKS5 and HTTPS when HTTP/3 is negotiated. +HTTPS over TCP and SSH block ordinary UDP; DNS has its own routing rules in the +[DNS guide](dns.md). Local-network bypass sends TCP directly and also bypasses +UDP for MASQUE and SOCKS5. It does not provide a direct fallback after tunnel failure. diff --git a/docs/en/index.md b/docs/en/index.md index 07eeea4..0f6e848 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -69,3 +69,15 @@ MASQUE and a usable datagram MTU; otherwise the entire chain retains HTTPS. [Русская версия](../ru/index.md) [DNS: modes, routing and certificates](dns.md). + +## Fingerprints and diagnostic logs + +The default HTTPS fingerprint currently follows Chrome Android; a future update may +choose a different browser preset. SSH presets control the client banner and the +preferred key-exchange, cipher and MAC ordering. These settings describe the connection +to the proxy, not the browser or other apps inside the tunnel. QUIC presets and manual +JA3 requirements are documented in [MASQUE](masque.md). + +The log size setting limits the combined size of two rotating segments to 1–100 MiB. +Older events are removed as the segments rotate; clearing the log deletes both. +See [connection metrics](connection-metrics.md) for what is measured and logged. diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index c66977b..58a878c 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -67,7 +67,7 @@ comes from CI. Use extra phones/APIs only for platform-specific changes or a rep | Transport/auth/trust | Affected transports/hops only: binary payload round trip, correct/wrong/restored credentials, hostname/CA/SSH pin rejection, EOF/half-close, timeouts/cancellation, fallback. No direct fallback around a failed hop. Preserve documented GOST packet-size/IPv6/half-close limits. | | Storage/subscriptions/import/export | Upgrade or changed-format migration, omitted versus empty secrets, cancelled/failed transfer without truncation, fresh subscription versus editor changes, process death/Keystore failure where affected. Preserve malformed/unreadable data; recovery is explicit. Never store credentials in saved-state bundles. | | Test / Speed / VPN visibility | Test exit IP/country and independent UDP outcomes with accessible labels; copy IP. Speed on underlying/VPN routes: partial table/progress, flag/code, HTTP and TLS lines, download/upload and log. Use 10 MB for smoke, h3→h2→h1 fallback only where permitted; Back cancels, Home continues. RTT here is median TTFB of three requests, not passive proxy RTT. ProxyCheck daily-limit/failure/cache states use deterministic fixtures, no live quota consumption in unit tests. | -| UI/accessibility | Changed screens in EN/RU, narrow window and enlarged font; readable banner actions, correct switches versus multi-select checkboxes, TalkBack status labels, copy/select and working links. Full theme/orientation/device permutations only for a relevant defect. | +| UI/accessibility | Changed screens in EN/RU at 320 dp and 1280 dp, including 200% fonts; readable banner actions, correct switches versus multi-select checkboxes, TalkBack status labels, copy/select and working links. Full theme/orientation/device permutations only for a relevant defect. | | Updates/releases | Affected source selection/consent/signature/ABI/version checks, download cancellation and installer result; use fixtures for malformed metadata/APKs. Verify tag/merge tree, signed APK hashes and completed VirusTotal reports when publishing. F-Droid recipe/reproducibility checks apply when that recipe/release changes. | Use synthetic profiles and disposable fixtures. Back up and restore a phone's original app/network, diff --git a/docs/ru/connection-metrics.md b/docs/ru/connection-metrics.md index 427a376..524879a 100644 --- a/docs/ru/connection-metrics.md +++ b/docs/ru/connection-metrics.md @@ -128,3 +128,11 @@ h2 разрешает h1, h1 остаётся h1. Fallback только при Тесты используют фикстуры, не запросы к ProxyCheck. Все адреса: [README](../../README.md#privacy-and-security). [English version](../en/connection-metrics.md) + + +В узких окнах «Скорость» показывает результаты основной сети и VPN отдельными блоками; в широких — сравнительной таблицей. Предпочитаемая версия HTTP допускает переход на младшие версии; правила описаны ниже. + +UDP передаётся через MASQUE, SOCKS5 и HTTPS при согласованном HTTP/3. +HTTPS поверх TCP и SSH блокируют обычный UDP; для DNS действуют отдельные правила +из [руководства DNS](dns.md). Обход локальных сетей отправляет TCP напрямую, а в +MASQUE и SOCKS5 — также UDP. Это не прямой резервный маршрут при отказе туннеля. diff --git a/docs/ru/index.md b/docs/ru/index.md index ff33ef4..ab98e0e 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -65,3 +65,15 @@ TLS. TLS приложений и DoH сохраняют собственное DNS. См. [документацию GOST](https://gost.run/en/tutorials/protocols/socks/). [DNS: режимы, маршрутизация и сертификаты](dns.md). + +## Отпечатки и диагностический журнал + +Отпечаток HTTPS по умолчанию сейчас соответствует Chrome Android; в будущих версиях +может быть выбран другой браузер. Профиль SSH задаёт баннер клиента и порядок предпочтения +алгоритмов обмена ключами, шифрования и MAC. Эти настройки относятся к соединению с прокси, +а не к браузеру и другим приложениям внутри туннеля. Отпечатки QUIC и требования к ручному +JA3 описаны в [руководстве MASQUE](masque.md). + +Размер журнала ограничивает общий объём двух сегментов с ротацией: 1–100 MiB. +При ротации старые события удаляются; очистка журнала удаляет оба сегмента. +Подробности измерений и событий — в [описании метрик соединения](connection-metrics.md). diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index c9ed295..00b0f4e 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -68,7 +68,7 @@ instrumentation-процессом; Keystore seed/verify должны остав | Transport/auth/trust | Только затронутые transports/hops: двоичный round trip, верные/неверные/восстановленные credentials, hostname/CA/SSH pin rejection, EOF/half-close, timeout/cancel и fallback. Нет прямого обхода отказавшего hop. Сохраняются известные ограничения GOST по размеру/IPv6/half-close. | | Хранение/подписки/import/export | Upgrade или затронутая миграция, omitted/empty secrets, отменённый/упавший перенос без усечения файла, свежая подписка при открытом редакторе, process death/Keystore failure при затронутой логике. Повреждённые/нечитаемые данные сохраняются; восстановление явное. Секреты не попадают в saved-state bundles. | | Тест / Скорость / Видимость VPN | Выходной IP/страна и независимые UDP-статусы с a11y; копирование IP. Скорость основной сети/VPN: постепенные результаты/прогресс, флаг/код, HTTP и TLS на отдельных строках, download/upload и лог. Для smoke — 10 MB, h3→h2→h1 fallback только где разрешён; Back отменяет, Home продолжает. Задержка здесь — медиана TTFB трёх запросов, не пассивный RTT прокси. Daily-limit/failure/cache ProxyCheck проверяются фикстурами, без расхода живой квоты unit-тестами. | -| UI/accessibility | Изменённые экраны EN/RU, узкое окно и увеличенный шрифт; читаемые действия баннера, switches и multi-select checkboxes по назначению, TalkBack-статусы, копирование/выделение и рабочие ссылки. Полный перебор тем/ориентаций/устройств — только для соответствующего дефекта. | +| UI/accessibility | Изменённые экраны EN/RU при ширине 320 dp и 1280 dp, в том числе со шрифтом 200%; читаемые действия баннера, switches и multi-select checkboxes по назначению, TalkBack-статусы, копирование/выделение и рабочие ссылки. Полный перебор тем/ориентаций/устройств — только для соответствующего дефекта. | | Обновления/релизы | Затронутые выбор источника/согласие/подпись/ABI/версия, отмена скачивания и результат установщика; некорректные metadata/APK — на фикстурах. При публикации — tag/merge tree, hashes APK и завершённые VirusTotal-отчёты. F-Droid recipe/reproducibility — при изменении соответствующего рецепта/релиза. | Используйте синтетические профили и одноразовые фикстуры. Сохраните и восстановите исходные данные,