Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ branch names, credentials, signing material, or other secrets.
## Product and UI conventions

- All user-visible UI text must use Android string resources and be supplied in both the default
English resources and Russian resources. Do not localize standardized data-unit symbols.
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.
- Traffic totals and rates share the selected unit system. IEC is the default and uses powers of
1024 with Latin symbols (`KiB`, `MiB`, `GiB`, `TiB`, `PiB`); users can select SI powers of 1000
with Latin symbols (`KB`, `MB`, `GB`, `TB`, `PB`).
Expand All @@ -56,6 +57,14 @@ branch names, credentials, signing material, or other secrets.
external service does not make the whole check fail. Keep presentation inputs and formatting
covered by JVM tests.

- Keep screens usable on narrow windows and with enlarged system fonts. Let actions and status
rows wrap or stack; constrain app-bar titles and field labels, and make long dialog content
scrollable. Verify visual changes locally without adding emulator requirements to GitHub CI.

- Configuration writes must outlive individual screens and expose pending/failure state. Keep
transfer operations across configuration changes; never put credentials or export payloads into
Android saved-state bundles, and reject a lost export before opening the output stream.

## Architecture landmarks

- `ProxyVpnService` extends Android's standard `android.net.VpnService`. It owns VPN lifecycle,
Expand Down
151 changes: 80 additions & 71 deletions app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package net.megaproxy487

import androidx.compose.foundation.shape.RoundedCornerShape

import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
Expand All @@ -12,6 +14,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
Expand All @@ -38,6 +42,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -50,13 +55,12 @@ import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import net.megaproxy487.uiStringResource as stringResource
import net.megaproxy487.data.ConfigStore
import net.megaproxy487.vpn.ProxyVpnService
import net.megaproxy487.vpn.TestDiagnosticLog
import net.megaproxy487.vpn.TestState
import net.megaproxy487.vpn.SshHostKeyPromptState
import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE
import net.megaproxy487.vpn.hasOtherProvider
import net.megaproxy487.vpn.openAndroidVpnSettings
import net.megaproxy487.vpn.readAlwaysOnVpnStatus
Expand All @@ -77,24 +81,24 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
val status = readAlwaysOnVpnStatus(activity)
val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000
if (status.hasOtherProvider || dismissedImmediately) {
TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE)
TestDiagnosticLog.fail(activity.uiText(R.string.other_always_on_vpn))
showAlwaysOnConflict = true
} else {
TestDiagnosticLog.fail("VPN access was not granted in the Android confirmation dialog")
TestDiagnosticLog.fail(activity.uiText(R.string.vpn_permission_denied))
}
}
}
val runTest = {
val configStore = ConfigStore(activity)
val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config)
.connectionValidationError()?.let { activity.getString(it) }
.connectionValidationError()?.let { activity.uiText(it) }
if (error != null) {
TestDiagnosticLog.fail(error)
} else {
TestDiagnosticLog.begin()
val status = readAlwaysOnVpnStatus(activity)
if (status.hasOtherProvider) {
TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE)
TestDiagnosticLog.fail(activity.uiText(R.string.other_always_on_vpn))
showAlwaysOnConflict = true
} else {
val intent = VpnService.prepare(activity)
Expand All @@ -107,8 +111,10 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
}
}
}
LaunchedEffect(Unit) {
if (autoStart) {
var autoStartConsumed by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(autoStart) {
if (autoStart && !autoStartConsumed) {
autoStartConsumed = true
TestDiagnosticLog.reset()
runTest()
}
Expand All @@ -117,7 +123,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.connection_test)) },
title = { ScreenTitle(stringResource(R.string.connection_test)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
Expand All @@ -127,58 +133,61 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
},
contentWindowInsets = WindowInsets.safeDrawing,
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val statusLabel = when (state) {
TestState.IDLE -> "Ready"
TestState.RUNNING -> "Testing"
TestState.SUCCEEDED -> "Test passed"
TestState.FAILED -> "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) "Testing…" else statusLabel,
style = MaterialTheme.typography.titleLarge,
color = statusColor,
)
if (state == TestState.RUNNING) {
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
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 { Text(stringResource(R.string.proxy_exit_ip, it)) }
countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it, systemFormattingLocale()))) }
}
exitIp?.let { Text(stringResource(R.string.proxy_exit_ip, it)) }
countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it))) }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = runTest, enabled = state != TestState.RUNNING) { Text(stringResource(R.string.run_again)) }
Button(onClick = {
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.connection_test_clipboard_label), TestDiagnosticLog.entries.joinToString("\n")))
}, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.copy_log)) }
}
Text(
"The test uses a temporary VPN when the main connection is inactive. Credentials and traffic content are not logged.",
style = MaterialTheme.typography.bodySmall,
)
Box(Modifier.fillMaxWidth().weight(1f)) {
SelectionContainer {
Text(
TestDiagnosticLog.entries.joinToString("\n").ifEmpty {
if (state == TestState.RUNNING) "Waiting for the first diagnostic event…" else "No test events yet."
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
)
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()),
)
}
}
}
}
Expand All @@ -187,16 +196,16 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
if (showAlwaysOnConflict) {
AlertDialog(
onDismissRequest = { showAlwaysOnConflict = false },
title = { Text(stringResource(R.string.always_on_conflict_title)) },
text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) },
title = { DialogTitle(stringResource(R.string.always_on_conflict_title)) },
text = { ScrollableDialogText(activity.uiText(R.string.other_always_on_vpn)) },
confirmButton = {
TextButton(onClick = {
TextButton(shape = RoundedCornerShape(12.dp), onClick = {
showAlwaysOnConflict = false
openAndroidVpnSettings(activity)
}) { Text(stringResource(R.string.change_settings)) }
},
dismissButton = {
TextButton(onClick = { showAlwaysOnConflict = false }) { Text(stringResource(R.string.cancel)) }
TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnConflict = false }) { Text(stringResource(R.string.cancel)) }
},
)
}
Expand All @@ -206,17 +215,17 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
SshHostKeyPromptState.clear()
ProxyVpnService.dismissHostKeyPrompt(activity)
},
title = { Text(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) },
text = { Text(buildString {
title = { DialogTitle(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) },
text = { ScrollableDialogText(buildString {
if (pending.changed) {
append(activity.getString(R.string.ssh_changed_key_warning, pending.hop))
append(activity.uiText(R.string.ssh_changed_key_warning, activity.sshHopLabel(pending.hop)))
} else {
append(activity.getString(R.string.ssh_first_connection_warning, pending.hop))
append(activity.uiText(R.string.ssh_first_connection_warning, activity.sshHopLabel(pending.hop)))
}
append(activity.getString(R.string.ssh_key_details, pending.algorithm, pending.fingerprint))
append(activity.uiText(R.string.ssh_key_details, pending.algorithm, pending.fingerprint))
}) },
confirmButton = {
TextButton(onClick = {
TextButton(shape = RoundedCornerShape(12.dp), onClick = {
val saved = ConfigStore(activity).trustSshHostKey(
pending.profileId, pending.hop, pending.fingerprint,
)
Expand All @@ -227,12 +236,12 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
if (saved && persisted == pending.fingerprint) {
ProxyVpnService.test(activity)
} else {
TestDiagnosticLog.fail("The SSH host key could not be saved to the tested profile")
TestDiagnosticLog.fail(activity.uiText(R.string.test_key_save_failed))
}
}) { Text(stringResource(if (pending.changed) R.string.replace_and_test else R.string.trust_and_test)) }
},
dismissButton = {
TextButton(onClick = {
TextButton(shape = RoundedCornerShape(12.dp), onClick = {
SshHostKeyPromptState.clear()
ProxyVpnService.dismissHostKeyPrompt(activity)
}) { Text(stringResource(R.string.cancel)) }
Expand Down
Loading