From bc861ef329b6515b59695eb36eb53d5488b513b8 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 16:26:18 +0300 Subject: [PATCH 1/6] Fix UI text overflow on narrow screens and enlarged fonts --- AGENTS.md | 4 + .../net/megaproxy487/ConnectionTestScreen.kt | 125 +++++++------ .../net/megaproxy487/DiagnosticLogScreen.kt | 110 ++++++------ .../java/net/megaproxy487/MainActivity.kt | 149 +++++++-------- .../net/megaproxy487/ProfileEditorScreen.kt | 74 ++++---- .../java/net/megaproxy487/ProfilesScreen.kt | 169 +++++++++--------- .../java/net/megaproxy487/SettingsScreens.kt | 56 +++--- .../net/megaproxy487/SplitTunnelScreen.kt | 12 +- .../java/net/megaproxy487/SshHostKeyScreen.kt | 12 +- .../main/java/net/megaproxy487/UiLayout.kt | 92 ++++++++++ .../java/net/megaproxy487/VisibilityScreen.kt | 12 +- app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 13 files changed, 471 insertions(+), 346 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/UiLayout.kt diff --git a/AGENTS.md b/AGENTS.md index c2afbdb..71ee72d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,10 @@ 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. + ## Architecture landmarks - `ProxyVpnService` extends Android's standard `android.net.VpnService`. It owns VPN lifecycle, diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index 4dbf11a..d4fcfa2 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -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 @@ -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 @@ -117,7 +121,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)) @@ -127,58 +131,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 -> "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)) + } + exitIp?.let { Text(stringResource(R.string.proxy_exit_ip, it)) } + countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it))) } } - 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.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().heightIn(min = 160.dp, max = logHeight)) { + 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()), + ) + } } } } @@ -187,16 +194,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(OTHER_ALWAYS_ON_VPN_MESSAGE) }, 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)) } }, ) } @@ -206,8 +213,8 @@ 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)) } else { @@ -216,7 +223,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack append(activity.getString(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, ) @@ -232,7 +239,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack }) { 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)) } diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index d6bf57a..5df40a5 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -1,5 +1,7 @@ package net.megaproxy487 +import androidx.compose.foundation.shape.RoundedCornerShape + import android.app.Activity import android.os.Bundle import androidx.activity.ComponentActivity @@ -9,6 +11,10 @@ import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -116,7 +122,7 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.diagnostic_log)) }, + title = { ScreenTitle(stringResource(R.string.diagnostic_log)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -126,54 +132,54 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { }, contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = limitText, - onValueChange = { value -> - if (value.all(Char::isDigit) && value.length <= 3) { - limitText = value - value.toIntOrNull()?.takeIf { - it in PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB - }?.let { - store.setDiagnosticLogLimitMb(it) - PersistentDiagnosticLog.setLimitMb(it) + BoxWithConstraints(Modifier.fillMaxSize().padding(padding)) { + val logHeight = maxOf(160.dp, maxHeight * 0.55f) + Column( + Modifier.fillMaxSize().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = limitText, + onValueChange = { value -> + if (value.all(Char::isDigit) && value.length <= 3) { + limitText = value + value.toIntOrNull()?.takeIf { + it in PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB + }?.let { + store.setDiagnosticLogLimitMb(it) + PersistentDiagnosticLog.setLimitMb(it) + } } + }, + label = { FieldLabel(stringResource(R.string.rotated_log_limit)) }, + supportingText = { Text(stringResource(R.string.rotated_log_limit_description)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + WrappingActions(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(shape = RoundedCornerShape(12.dp), onClick = { exportDocument.launch("MegaProxy-diagnostic.log") }) { + Text(stringResource(R.string.export_action)) + } + OutlinedButton(shape = RoundedCornerShape(12.dp), onClick = { showClearConfirmation = true }) { + Text(stringResource(R.string.clear_action)) } - }, - label = { Text(stringResource(R.string.rotated_log_limit)) }, - supportingText = { Text(stringResource(R.string.rotated_log_limit_description)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { exportDocument.launch("MegaProxy-diagnostic.log") }, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.export_action)) - } - OutlinedButton(onClick = { showClearConfirmation = true }, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.clear_action)) } - } - Text( - buildString { - append(if (autoScroll) "Auto-scroll on" else "Auto-scroll paused · scroll to the bottom to resume") - append(" · showing the latest 512 KB") - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - if (lines.isEmpty()) item { Text(stringResource(R.string.no_diagnostic_events)) } - itemsIndexed(lines, key = { index, _ -> index }) { _, line -> - Text(line, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + Text( + buildString { + append(if (autoScroll) "Auto-scroll on" else "Auto-scroll paused · scroll to the bottom to resume") + append(" · showing the latest 512 KB") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp, max = logHeight), + ) { + if (lines.isEmpty()) item { Text(stringResource(R.string.no_diagnostic_events)) } + itemsIndexed(lines, key = { index, _ -> index }) { _, line -> + Text(line, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } } } } @@ -182,20 +188,20 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { if (showClearConfirmation) { AlertDialog( onDismissRequest = { showClearConfirmation = false }, - title = { Text(stringResource(R.string.clear_diagnostic_log_title)) }, - text = { Text(stringResource(R.string.clear_diagnostic_log_message)) }, - confirmButton = { TextButton(onClick = { + title = { DialogTitle(stringResource(R.string.clear_diagnostic_log_title)) }, + text = { ScrollableDialogText(stringResource(R.string.clear_diagnostic_log_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showClearConfirmation = false PersistentDiagnosticLog.clear() }) { Text(stringResource(R.string.clear_action)) } }, - dismissButton = { TextButton(onClick = { showClearConfirmation = false }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showClearConfirmation = false }) { Text(stringResource(R.string.cancel)) } }, ) } message?.let { AlertDialog( onDismissRequest = { message = null }, - text = { Text(it) }, - confirmButton = { TextButton(onClick = { message = null }) { Text(stringResource(R.string.ok)) } }, + text = { ScrollableDialogText(it) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { message = null }) { Text(stringResource(R.string.ok)) } }, ) } } diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 746aa05..697ea8e 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -163,7 +164,7 @@ class MainActivity : LocalizedActivity() { } @Composable -private fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modifier = Modifier) { +internal fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modifier = Modifier) { Surface( modifier = modifier, color = foreground.copy(alpha = 0.14f), @@ -181,6 +182,7 @@ private fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modif style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.sp), maxLines = 1, softWrap = false, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), ) } @@ -196,36 +198,48 @@ private fun ProfileSelectorLabel( style: TextStyle = MaterialTheme.typography.bodyLarge, ) { var badgeWidth by remember { mutableStateOf(0) } - Box(modifier, contentAlignment = Alignment.CenterStart) { - Text( - name, - color = foreground, - style = style, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Clip, - modifier = Modifier.fillMaxWidth() - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - // Mask only the name layer: the badge and its border stay crisp. - val end = (size.width - badgeWidth - 6.dp.toPx()).coerceAtLeast(0f) - val start = (end - 24.dp.toPx()).coerceAtLeast(0f) - drawRect( - brush = Brush.horizontalGradient( - colors = listOf(Color.Black, Color.Transparent), - startX = start, - endX = end.coerceAtLeast(start + 1f), - ), - blendMode = BlendMode.DstIn, - ) - }, - ) - ProfileTypeBadge( - type, - foreground, - Modifier.align(Alignment.CenterEnd).onSizeChanged { badgeWidth = it.width }, - ) + var availableWidth by remember { mutableStateOf(Int.MAX_VALUE) } + val density = LocalDensity.current + Box(modifier.onSizeChanged { availableWidth = it.width }) { + val stacked = availableWidth < with(density) { (220.dp * fontScale).toPx() } + if (stacked) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(name, color = foreground, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis) + ProfileTypeBadge(type, foreground) + } + } else { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { + Text( + name, + color = foreground, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + // Mask only the name layer: the badge and its border stay crisp. + val end = (size.width - badgeWidth - 6.dp.toPx()).coerceAtLeast(0f) + val start = (end - 24.dp.toPx()).coerceAtLeast(0f) + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Black, Color.Transparent), + startX = start, + endX = end.coerceAtLeast(start + 1f), + ), + blendMode = BlendMode.DstIn, + ) + }, + ) + ProfileTypeBadge( + type, + foreground, + Modifier.align(Alignment.CenterEnd).onSizeChanged { badgeWidth = it.width }, + ) + } + } } } @@ -375,7 +389,7 @@ internal fun MainScreen( } Scaffold( - topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) }, + topBar = { TopAppBar(title = { ScreenTitle(stringResource(R.string.app_name)) }) }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { @@ -427,7 +441,7 @@ internal fun MainScreen( Surface( color = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - shape = RoundedCornerShape(999.dp), + shape = RoundedCornerShape(12.dp), ) { Text( transportLabel, @@ -534,19 +548,16 @@ internal fun MainScreen( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), modifier = Modifier.fillMaxWidth(), ) { - Row( - Modifier.fillMaxWidth().padding(14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(activeProfileError, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onErrorContainer) - TextButton(onClick = { - onEditProfile(activeProfile.id) - }) { Text(stringResource(R.string.configure)) } - } + AdaptiveLabelRow( + modifier = Modifier.padding(14.dp), + label = { Text(activeProfileError, modifier = it, color = MaterialTheme.colorScheme.onErrorContainer) }, + trailing = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { onEditProfile(activeProfile.id) }) { Text(stringResource(R.string.configure)) } + }, + ) } } - Button( + Button(shape = RoundedCornerShape(12.dp), onClick = { if (isAlwaysOnVpnActive(activity)) { systemVpnStatus = readAlwaysOnVpnStatus(activity) @@ -569,20 +580,17 @@ internal fun MainScreen( ) } if (connected) { - FilledTonalButton( + FilledTonalButton(shape = RoundedCornerShape(12.dp), onClick = { ProxyVpnService.reconnect(activity) }, modifier = Modifier.fillMaxWidth(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + WrappingActions(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)) { Text(stringResource(R.string.reconnect)) if (pendingReconnect) { Surface( color = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - shape = RoundedCornerShape(999.dp), + shape = RoundedCornerShape(12.dp), ) { Text( stringResource(R.string.apply_new_settings), @@ -594,15 +602,15 @@ internal fun MainScreen( } } } - FilledTonalButton( + FilledTonalButton(shape = RoundedCornerShape(12.dp), onClick = onOpenConnectionTest, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.test_connection)) } - FilledTonalButton( + FilledTonalButton(shape = RoundedCornerShape(12.dp), onClick = onOpenSettings, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.settings)) } - FilledTonalButton( + FilledTonalButton(shape = RoundedCornerShape(12.dp), onClick = { scope.launch { runCatching { @@ -635,10 +643,10 @@ internal fun MainScreen( if (showCrashReport) { AlertDialog( onDismissRequest = {}, - title = { Text(stringResource(R.string.unexpected_stop_title)) }, - text = { Text(stringResource(R.string.crash_report_saved)) }, + title = { DialogTitle(stringResource(R.string.unexpected_stop_title)) }, + text = { ScrollableDialogText(stringResource(R.string.crash_report_saved)) }, confirmButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { scope.launch { runCatching { val intent = withContext(Dispatchers.IO) { @@ -660,7 +668,7 @@ internal fun MainScreen( }) { Text(stringResource(R.string.report)) } }, dismissButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { CrashHandler.markReportHandled() showCrashReport = false }) { Text(stringResource(R.string.close)) } @@ -671,16 +679,16 @@ internal fun MainScreen( 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(OTHER_ALWAYS_ON_VPN_MESSAGE) }, 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)) } }, ) } @@ -692,8 +700,8 @@ internal fun MainScreen( } AlertDialog( onDismissRequest = ::dismissHostKeyPrompt, - 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)) } else { @@ -702,7 +710,7 @@ internal fun MainScreen( append(activity.getString(R.string.ssh_key_details, pending.algorithm, pending.fingerprint)) }) }, confirmButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { if (store.trustSshHostKey(pending.profileId, pending.hop, pending.fingerprint)) { SshHostKeyPromptState.clear() ProxyVpnService.reconnect(activity) @@ -712,7 +720,7 @@ internal fun MainScreen( }) { Text(stringResource(if (pending.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, dismissButton = { - TextButton(onClick = ::dismissHostKeyPrompt) { Text(stringResource(R.string.cancel)) } + TextButton(shape = RoundedCornerShape(12.dp), onClick = ::dismissHostKeyPrompt) { Text(stringResource(R.string.cancel)) } }, ) } @@ -741,10 +749,7 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { val context = androidx.compose.ui.platform.LocalContext.current val unitSystem = TrafficUnitPreferences.current(context) Card(Modifier.fillMaxWidth()) { - Row( - Modifier.fillMaxWidth().padding(14.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { + AdaptiveStatColumns(Modifier.padding(14.dp)) { statModifier -> StatValue( label = stringResource(R.string.traffic_download), value = "↓ ${formatTrafficRate(stats.downloadBytesPerSecond, unitSystem)}", @@ -752,7 +757,7 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { R.string.traffic_total, formatTrafficBytes(stats.native.downloadBytes, unitSystem), ), - modifier = Modifier.weight(1f), + modifier = statModifier, ) StatValue( label = stringResource(R.string.traffic_upload), @@ -761,7 +766,7 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { R.string.traffic_total, formatTrafficBytes(stats.native.uploadBytes, unitSystem), ), - modifier = Modifier.weight(1f), + modifier = statModifier, ) val latency = stats.native.proxyLatencyMillis val ageMillis = System.currentTimeMillis() - stats.native.proxyLatencyAtMillis @@ -772,7 +777,7 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { latency.toInt(), ), supportingValue = if (latency <= 0) null else formatAge(ageMillis).ifEmpty { null }, - modifier = Modifier.weight(1f), + modifier = statModifier, ) } val samples = stats.native.connectionSamples diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index b666aea..2946706 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -1,5 +1,7 @@ package net.megaproxy487 +import androidx.compose.foundation.shape.RoundedCornerShape + import android.app.Activity import android.net.Uri import android.os.Bundle @@ -156,7 +158,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: Scaffold( topBar = { TopAppBar( - title = { Text(profile.displayName) }, + title = { ScreenTitle(profile.displayName) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -177,7 +179,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: OutlinedTextField(profile.name, { name -> acceptText(name, 256) { profile = profile.copy(name = name) saveProfile() - } }, label = { Text(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } }, 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 }) { @@ -185,7 +187,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" } ?: "No flag", - {}, readOnly = true, label = { Text(stringResource(R.string.country_flag)) }, + {}, readOnly = true, label = { FieldLabel(stringResource(R.string.country_flag)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -215,7 +217,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { - OutlinedTextField(if (config.type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else config.type.title, {}, readOnly = true, label = { Text(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(if (config.type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else config.type.title, {}, 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(if (type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else type.title) }, onClick = { updateConfig(config.copy(type = type, port = type.defaultPort, jumpPort = if (type.hasJump && (!config.type.hasJump || type.isHttps != config.type.isHttps)) type.defaultPort else config.jumpPort)) @@ -227,7 +229,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } item { - OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type.isHttps) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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.isHttps) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(portText, { value -> @@ -235,13 +237,13 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: val port = value.toIntOrNull() if (port == null) error = activity.getString(R.string.validation_port) else updateConfig(config.copy(port = port)) - }, label = { Text(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + }, label = { FieldLabel(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(stringResource(if (config.type.isHttps) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type.isHttps) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(stringResource(if (config.type.isHttps) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { FieldLabel(stringResource(if (config.type.isHttps) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } if (config.type.isHttps) item { SettingCheckboxRow( checked = config.allowInvalidProxyCertificate, @@ -256,10 +258,10 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (!config.type.isHttps) { item { - OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, 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( + OutlinedButton(shape = RoundedCornerShape(12.dp), onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.import_destination_key)) } @@ -286,7 +288,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: 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 = { Text(stringResource(R.string.https_jump_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(jumpPortText, { value -> @@ -294,7 +296,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: jumpPortText = value updateConfig(config.copy(jumpPort = value.toIntOrNull() ?: 0)) } - }, label = { Text(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + }, label = { FieldLabel(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { SettingCheckboxRow(config.sameJumpAuthentication, stringResource(R.string.https_jump_same_auth), stringResource(R.string.https_jump_same_auth_description)) { @@ -303,10 +305,10 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } if (!config.sameJumpAuthentication) { item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text(stringResource(R.string.https_jump_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text(stringResource(R.string.https_jump_password)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_password)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } } item { @@ -325,7 +327,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: Text(stringResource(R.string.jump_host), style = MaterialTheme.typography.titleMedium) } item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text(stringResource(R.string.jump_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(jumpPortText, { value -> @@ -333,7 +335,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: jumpPortText = value value.toIntOrNull()?.let { updateConfig(config.copy(jumpPort = it)) } } - }, label = { Text(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + }, label = { FieldLabel(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { SettingCheckboxRow(config.sameJumpAuthentication, "Use the same authentication", "Reuse destination username, password and private key.") { @@ -342,16 +344,16 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } if (!config.sameJumpAuthentication) { item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text(stringResource(R.string.jump_ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text(stringResource(R.string.jump_ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { Text(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { - OutlinedButton( + OutlinedButton(shape = RoundedCornerShape(12.dp), onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.import_jump_key)) } @@ -385,25 +387,25 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { - OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { Text(stringResource(R.string.dns_over_https)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(config.dnsProvider.title, {}, 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(provider.title) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } } } } - if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { Text(stringResource(R.string.custom_doh_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } + if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } error?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } if (showReconnectPrompt) { item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Text(stringResource(R.string.reconnect_apply_changes), style = MaterialTheme.typography.bodyMedium) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { + WrappingActions() { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false connectionChangeDeferred = true }) { Text(stringResource(R.string.next_connection)) } - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false connectionChangeDeferred = true ProxyVpnService.reconnect(activity) @@ -416,9 +418,9 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (showAlwaysOnNotice) { item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { - Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - Text(stringResource(R.string.always_on_changes_next_connection), modifier = Modifier.weight(1f)) - TextButton(onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(stringResource(R.string.always_on_changes_next_connection)) + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } } } } @@ -432,22 +434,22 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (showInvalidCertificateWarning) { AlertDialog( onDismissRequest = { showInvalidCertificateWarning = false }, - title = { Text(stringResource(R.string.allow_untrusted_certificate_title)) }, - text = { Text(stringResource(if (invalidCertificateIsJump) R.string.allow_untrusted_jump_certificate_message else R.string.allow_untrusted_certificate_message)) }, - confirmButton = { TextButton(onClick = { + title = { DialogTitle(stringResource(R.string.allow_untrusted_certificate_title)) }, + text = { ScrollableDialogText(stringResource(if (invalidCertificateIsJump) R.string.allow_untrusted_jump_certificate_message else R.string.allow_untrusted_certificate_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { updateConfig(if (invalidCertificateIsJump) config.copy(jumpAllowInvalidProxyCertificate = true) else config.copy(allowInvalidProxyCertificate = true)) showInvalidCertificateWarning = false }) { Text(stringResource(R.string.ok)) } }, - dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showInvalidCertificateWarning = false }) { Text(stringResource(R.string.cancel)) } }, ) } unsafeHostKeyHop?.let { hop -> AlertDialog( onDismissRequest = { unsafeHostKeyHop = null }, - title = { Text(stringResource(R.string.accept_any_ssh_key_title)) }, - text = { Text(stringResource(R.string.accept_any_ssh_key_message, hop)) }, - confirmButton = { TextButton(onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text(stringResource(R.string.accept_any_key)) } }, - dismissButton = { TextButton(onClick = { unsafeHostKeyHop = null }) { Text(stringResource(R.string.cancel)) } }, + title = { DialogTitle(stringResource(R.string.accept_any_ssh_key_title)) }, + text = { ScrollableDialogText(stringResource(R.string.accept_any_ssh_key_message, hop)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text(stringResource(R.string.accept_any_key)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { unsafeHostKeyHop = null }) { Text(stringResource(R.string.cancel)) } }, ) } } diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 2f25be0..19663d0 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -31,6 +31,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -38,7 +43,8 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Button +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider @@ -408,29 +414,33 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.profiles)) }, + title = { ScreenTitle(stringResource(R.string.profiles)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, actions = { - TextButton(onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) { - Text(stringResource(R.string.import_action)) + IconButton(onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) { + Icon(Icons.Default.FileDownload, contentDescription = stringResource(R.string.import_action)) } - TextButton(onClick = { showExportDialog = true }) { Text(stringResource(R.string.export_action)) } + IconButton(onClick = { showExportDialog = true }) { Icon(Icons.Default.FileUpload, contentDescription = stringResource(R.string.export_action)) } }, ) }, - floatingActionButton = { - FloatingActionButton(onClick = { - scope.launch { - val profile = withContext(ConfigIoDispatcher) { store.addProfile() } - refresh() - edit(profile) - } - }) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_profile)) + bottomBar = { + Surface(tonalElevation = 3.dp) { + Button( + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), + onClick = { + scope.launch { + val profile = withContext(ConfigIoDispatcher) { store.addProfile() } + refresh() + edit(profile) + } + }, + ) { Text(stringResource(R.string.add_profile)) } } }, contentWindowInsets = WindowInsets.safeDrawing, @@ -442,6 +452,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil LazyColumn( Modifier.fillMaxHeight().fillMaxWidth().widthIn(max = 840.dp).padding(horizontal = 16.dp), state = listState, + contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { @@ -524,9 +535,9 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil deleteProfile?.let { profile -> AlertDialog( onDismissRequest = { deleteProfile = null }, - title = { Text(stringResource(R.string.delete_profile_title, profile.displayName)) }, - text = { Text(stringResource(R.string.delete_profile_message)) }, - confirmButton = { TextButton(onClick = { + title = { DialogTitle(stringResource(R.string.delete_profile_confirmation)) }, + text = { ScrollableDialogText(profile.displayName + "\n\n" + stringResource(R.string.delete_profile_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { deleteProfile = null scope.launch { val reconnect = withContext(ConfigIoDispatcher) { @@ -538,83 +549,83 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil if (reconnect) ProxyVpnService.reconnect(activity) } }, enabled = profiles.size > 1) { Text(stringResource(R.string.delete)) } }, - dismissButton = { TextButton(onClick = { deleteProfile = null }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { deleteProfile = null }) { Text(stringResource(R.string.cancel)) } }, ) } if (showImportFilterNotice) { AlertDialog( onDismissRequest = { showImportFilterNotice = false }, - title = { Text(stringResource(R.string.only_https_imported_title)) }, - text = { Text(stringResource(R.string.only_https_imported_message, importedProfileCount, skippedNonHttps)) }, - confirmButton = { TextButton(onClick = { showImportFilterNotice = false }) { Text(stringResource(R.string.continue_action)) } }, + title = { DialogTitle(stringResource(R.string.only_https_imported_title)) }, + text = { ScrollableDialogText(stringResource(R.string.only_https_imported_message, importedProfileCount, skippedNonHttps)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showImportFilterNotice = false }) { Text(stringResource(R.string.continue_action)) } }, ) } if (showExportDialog) { AlertDialog( onDismissRequest = { showExportDialog = false }, - title = { Text(stringResource(R.string.export_configuration_title)) }, + title = { DialogTitle(stringResource(R.string.export_configuration_title)) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.format)) Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(exportFormat == ConfigExportFormat.JSON, { exportFormat = ConfigExportFormat.JSON }) - Text(stringResource(R.string.export_json_description)) + Text(stringResource(R.string.export_json_description), modifier = Modifier.weight(1f)) } Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(exportFormat == ConfigExportFormat.PROXY_LIST, { exportFormat = ConfigExportFormat.PROXY_LIST }) - Text(stringResource(R.string.export_proxy_list_description)) + Text(stringResource(R.string.export_proxy_list_description), modifier = Modifier.weight(1f)) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(includePasswords, { includePasswords = it }) - Text(stringResource(R.string.include_passwords)) + Text(stringResource(R.string.include_passwords), modifier = Modifier.weight(1f)) } if (!includePasswords) { Text(stringResource(R.string.passwords_omitted_message), style = MaterialTheme.typography.bodySmall) } if (exportFormat == ConfigExportFormat.JSON) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(includePrivateKeys, { includePrivateKeys = it }) - Text(stringResource(R.string.include_private_keys)) + Text(stringResource(R.string.include_private_keys), modifier = Modifier.weight(1f)) } if (includePrivateKeys) Text(stringResource(R.string.private_keys_plaintext_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } }, - confirmButton = { TextButton(onClick = { + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showExportDialog = false if (includePasswords || includePrivateKeys) showPasswordExportWarning = true else launchExport() }) { Text(stringResource(R.string.export_action)) } }, - dismissButton = { TextButton(onClick = { showExportDialog = false }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showExportDialog = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showPasswordExportWarning) { AlertDialog( onDismissRequest = { showPasswordExportWarning = false }, - title = { Text(stringResource(R.string.export_secrets_title)) }, - text = { Text(stringResource(R.string.export_secrets_message)) }, - confirmButton = { TextButton(onClick = { + title = { DialogTitle(stringResource(R.string.export_secrets_title)) }, + text = { ScrollableDialogText(stringResource(R.string.export_secrets_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showPasswordExportWarning = false launchExport() }) { Text(stringResource(R.string.export_anyway)) } }, - dismissButton = { TextButton(onClick = { showPasswordExportWarning = false }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showPasswordExportWarning = false }) { Text(stringResource(R.string.cancel)) } }, ) } transferMessage?.let { message -> AlertDialog( onDismissRequest = { transferMessage = null }, - title = { Text(stringResource(R.string.configuration_transfer)) }, - text = { Text(message) }, - confirmButton = { TextButton(onClick = { transferMessage = null }) { Text(stringResource(R.string.ok)) } }, + title = { DialogTitle(stringResource(R.string.configuration_transfer)) }, + text = { ScrollableDialogText(message) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { transferMessage = null }) { Text(stringResource(R.string.ok)) } }, ) } pendingUnsafeImport?.let { configuration -> AlertDialog( onDismissRequest = { pendingUnsafeImport = null }, - title = { Text(stringResource(R.string.unsafe_import_title)) }, - text = { Text(stringResource(R.string.unsafe_import_message)) }, - confirmButton = { TextButton(onClick = { + title = { DialogTitle(stringResource(R.string.unsafe_import_title)) }, + text = { ScrollableDialogText(stringResource(R.string.unsafe_import_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { pendingUnsafeImport = null prepareJsonImport(configuration) }) { Text(stringResource(R.string.import_anyway)) } }, - dismissButton = { TextButton(onClick = { pendingUnsafeImport = null }) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { pendingUnsafeImport = null }) { Text(stringResource(R.string.cancel)) } }, ) } importOptionsReview?.let { review -> @@ -624,10 +635,10 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil selectedImportOptionKeys.clear() applyImportedGlobalOptions = false }, - title = { Text(stringResource(R.string.import_options_title)) }, + title = { DialogTitle(stringResource(R.string.import_options_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.import_options_message)) + Text(stringResource(R.string.import_options_message), modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) { items(review.profiles, key = { it.profile.id }) { item -> Row( @@ -646,7 +657,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil else selectedImportOptionKeys.remove(key) }, ) - Text(stringResource(group)) + Text(stringResource(group), modifier = Modifier.weight(1f)) } } } @@ -661,14 +672,14 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil checked = applyImportedGlobalOptions, onCheckedChange = { applyImportedGlobalOptions = it }, ) - Text(stringResource(R.string.import_global_options)) + Text(stringResource(R.string.import_global_options), modifier = Modifier.weight(1f)) } } } } }, confirmButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { val selectedOptions = selectedImportOptionKeys.toSet() val applyGlobals = !review.globalChanged || applyImportedGlobalOptions val resolved = review.configuration.copy( @@ -691,7 +702,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil }) { Text(stringResource(R.string.apply_import)) } }, dismissButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { importOptionsReview = null selectedImportOptionKeys.clear() applyImportedGlobalOptions = false @@ -706,10 +717,10 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil selectedMissingProfileIds.clear() transferMessage = review.importSummary }, - title = { Text(stringResource(R.string.remove_missing_profiles_title)) }, + title = { DialogTitle(stringResource(R.string.remove_missing_profiles_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.remove_missing_profiles_message)) + Text(stringResource(R.string.remove_missing_profiles_message), modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) LazyColumn(Modifier.fillMaxWidth().heightIn(max = 320.dp)) { items(review.profiles, key = { it.id }) { profile -> Row( @@ -723,7 +734,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil else selectedMissingProfileIds.remove(profile.id) }, ) - Column { + Column(Modifier.weight(1f)) { Text(profile.displayName) Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) } @@ -733,7 +744,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } }, confirmButton = { - TextButton( + TextButton(shape = RoundedCornerShape(12.dp), enabled = selectedMissingProfileIds.isNotEmpty(), onClick = { val selected = selectedMissingProfileIds.toSet() @@ -755,7 +766,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil ) { Text(stringResource(R.string.remove_selected)) } }, dismissButton = { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { missingProfilesReview = null selectedMissingProfileIds.clear() transferMessage = review.importSummary @@ -780,40 +791,28 @@ private fun ProfileCard( modifier = modifier.fillMaxWidth(), ) { Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (profile.flagEmoji.isNotEmpty()) { - Surface( - color = Color.White, contentColor = Color.Black, - shape = RoundedCornerShape(6.dp), - border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)), - ) { Text(profile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) } - } - Column(Modifier.weight(1f)) { - Text(profile.displayName, style = MaterialTheme.typography.titleMedium) - Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) - } - Surface( - color = foreground.copy(alpha = 0.16f), - contentColor = foreground, - shape = RoundedCornerShape(50), - border = BorderStroke(1.dp, foreground.copy(alpha = 0.55f)), - ) { - Text( - when (profile.config.type) { - ProxyType.HTTPS -> "HTTPS" - ProxyType.HTTPS_JUMP -> stringResource(R.string.https_with_jump) - ProxyType.SSH -> "SSH" - ProxyType.SSH_JUMP -> "SSH + Jump" - }, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), - ) - } - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onConfigure) { Text(stringResource(R.string.configure), color = foreground) } - TextButton(onClick = onClone) { Text(stringResource(R.string.clone), color = foreground) } - TextButton(onClick = onDelete) { Text(stringResource(R.string.delete), color = foreground) } + AdaptiveLabelRow( + label = { labelModifier -> + Row(labelModifier, verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (profile.flagEmoji.isNotEmpty()) { + Surface( + color = Color.White, contentColor = Color.Black, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)), + ) { Text(profile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) } + } + Column(Modifier.weight(1f)) { + Text(profile.displayName, style = MaterialTheme.typography.titleMedium) + Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) + } + } + }, + trailing = { ProfileTypeBadge(profile.config.type, foreground) }, + ) + WrappingActions() { + TextButton(shape = RoundedCornerShape(12.dp), onClick = onConfigure) { Text(stringResource(R.string.configure), color = foreground) } + TextButton(shape = RoundedCornerShape(12.dp), onClick = onClone) { Text(stringResource(R.string.clone), color = foreground) } + TextButton(shape = RoundedCornerShape(12.dp), onClick = onDelete) { Text(stringResource(R.string.delete), color = foreground) } } } } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 4d8b723..e43a548 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -1,5 +1,7 @@ package net.megaproxy487 +import androidx.compose.foundation.shape.RoundedCornerShape + import android.app.Activity import android.content.Intent import android.net.Uri @@ -177,9 +179,9 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga val selected = AppLanguageManager.current(activity) AlertDialog( onDismissRequest = { showLanguageDialog = false }, - title = { Text(stringResource(R.string.language)) }, + title = { DialogTitle(stringResource(R.string.language)) }, text = { - Column { + Column(Modifier.verticalScroll(rememberScrollState())) { AppLanguage.entries.forEach { language -> val label = if (language == AppLanguage.RUSSIAN) { stringResource(R.string.language_russian) @@ -199,22 +201,22 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.RadioButton(selected == language, null) - Text(label) + Text(label, modifier = Modifier.weight(1f)) } } } }, confirmButton = {}, - dismissButton = { TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showLanguageDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, ) } if (showTrafficUnitsDialog) { AlertDialog( onDismissRequest = { showTrafficUnitsDialog = false }, - title = { Text(stringResource(R.string.traffic_units)) }, + title = { DialogTitle(stringResource(R.string.traffic_units)) }, text = { - Column { + Column(Modifier.verticalScroll(rememberScrollState())) { TrafficUnitSystem.entries.forEach { unitSystem -> val label = stringResource( if (unitSystem == TrafficUnitSystem.IEC) R.string.traffic_units_iec @@ -233,14 +235,14 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.RadioButton(trafficUnitSystem == unitSystem, null) - Text(label) + Text(label, modifier = Modifier.weight(1f)) } } } }, confirmButton = {}, dismissButton = { - TextButton(onClick = { showTrafficUnitsDialog = false }) { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showTrafficUnitsDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -264,7 +266,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { } SettingsScaffold(onBack, stringResource(R.string.failover)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { - OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { Text(stringResource(R.string.failover_mode)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { FieldLabel(stringResource(R.string.failover_mode)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(expanded, { expanded = false }) { FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { expanded = false @@ -290,7 +292,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { verticalAlignment = Alignment.CenterVertically, ) { Checkbox(checked, null) - Text(profile.displayNameWithFlag) + Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f)) } } val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds } @@ -316,10 +318,10 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { } if (pendingAll) AlertDialog( onDismissRequest = { pendingAll = false }, - title = { Text(stringResource(R.string.enable_global_failover_title)) }, - text = { Text(stringResource(R.string.enable_global_failover_message)) }, - confirmButton = { TextButton(onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text(stringResource(R.string.enable)) } }, - dismissButton = { TextButton(onClick = { pendingAll = false }) { Text(stringResource(R.string.cancel)) } }, + title = { DialogTitle(stringResource(R.string.enable_global_failover_title)) }, + text = { ScrollableDialogText(stringResource(R.string.enable_global_failover_message)) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text(stringResource(R.string.enable)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { pendingAll = false }) { Text(stringResource(R.string.cancel)) } }, ) } @@ -335,7 +337,7 @@ internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( selected.displayNameWithFlag, {}, readOnly = true, - label = { Text(stringResource(R.string.always_on_profile)) }, + label = { FieldLabel(stringResource(R.string.always_on_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -406,7 +408,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( settings.tlsProfile.title, {}, readOnly = true, - label = { Text(stringResource(R.string.https_tls_ja3_profile)) }, + label = { FieldLabel(stringResource(R.string.https_tls_ja3_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -421,7 +423,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { OutlinedTextField( settings.customJa3, { if (it.length <= 8 * 1024) save(customJa3 = it) }, - label = { Text(stringResource(R.string.ja3_format)) }, + label = { FieldLabel(stringResource(R.string.ja3_format)) }, minLines = 2, modifier = Modifier.fillMaxWidth(), ) @@ -431,7 +433,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { ExposedDropdownMenuBox(sshExpanded, { sshExpanded = it }) { OutlinedTextField( settings.sshProfile.title, {}, readOnly = true, - label = { Text(stringResource(R.string.ssh_client_profile)) }, + label = { FieldLabel(stringResource(R.string.ssh_client_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -447,7 +449,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { ExposedDropdownMenuBox(sshAuthExpanded, { sshAuthExpanded = it }) { OutlinedTextField( settings.sshAuthMode.title, {}, readOnly = true, - label = { Text(stringResource(R.string.ssh_authentication)) }, + label = { FieldLabel(stringResource(R.string.ssh_authentication)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshAuthExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -475,9 +477,9 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Text(stringResource(R.string.reconnect_apply_changes)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { showReconnectPrompt = false; deferred = true }) { Text(stringResource(R.string.next_connection)) } - TextButton(onClick = { + WrappingActions() { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; deferred = true }) { Text(stringResource(R.string.next_connection)) } + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false deferred = true ProxyVpnService.reconnect(activity) @@ -488,9 +490,9 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { } if (showAlwaysOnNotice) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { - Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.always_on_changes_next_connection), modifier = Modifier.weight(1f)) - TextButton(onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(stringResource(R.string.always_on_changes_next_connection)) + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } } } } @@ -510,7 +512,7 @@ private fun IntegerSettingField(initialValue: Int, range: IntRange, label: Strin value.toIntOrNull()?.takeIf { it in range }?.let(onValidValue) } }, - label = { Text(label) }, + label = { FieldLabel(label) }, supportingText = if (text.isNotEmpty() && parsed !in range) { { Text(stringResource(R.string.allowed_range, range.first, range.last)) } } else null, @@ -526,7 +528,7 @@ private fun SettingsScaffold(onBack: () -> Unit, title: String, content: @Compos Scaffold( topBar = { TopAppBar( - title = { Text(title) }, + title = { ScreenTitle(title) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index cb97b62..576be0c 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -1,5 +1,7 @@ package net.megaproxy487 +import androidx.compose.foundation.shape.RoundedCornerShape + import android.app.Activity import android.content.Intent import android.os.Bundle @@ -113,7 +115,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.split_tunneling)) }, + title = { ScreenTitle(stringResource(R.string.split_tunneling)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -132,14 +134,14 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { if (showAlwaysOnDeferredNotice) "Always-on is active. Routing changes apply on the next connection." else "Reconnect to apply routing changes to the active VPN.", ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { + WrappingActions() { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false showAlwaysOnDeferredNotice = false deferChangesUntilNextConnection = true }) { Text(stringResource(if (showAlwaysOnDeferredNotice) R.string.dismiss else R.string.next_connection)) } if (showReconnectPrompt) { - TextButton(onClick = { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false deferChangesUntilNextConnection = true ProxyVpnService.reconnect(activity) @@ -223,7 +225,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { OutlinedTextField( value = appSearch, onValueChange = { appSearch = it }, - label = { Text(stringResource(R.string.search_applications)) }, + label = { FieldLabel(stringResource(R.string.search_applications)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt index ae71a83..79cdb70 100644 --- a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt +++ b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt @@ -1,5 +1,9 @@ package net.megaproxy487 +import androidx.compose.ui.unit.dp + +import androidx.compose.foundation.shape.RoundedCornerShape + import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.material3.AlertDialog @@ -21,17 +25,17 @@ internal fun SshHostKeyScreen(activity: Activity, prompt: PendingSshHostKey, onD } AlertDialog( onDismissRequest = ::reject, - title = { Text(stringResource(if (prompt.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, - text = { Text(buildString { + title = { DialogTitle(stringResource(if (prompt.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, + text = { ScrollableDialogText(buildString { append(activity.getString(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, prompt.hop)) append(activity.getString(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) }) }, - confirmButton = { TextButton(onClick = { + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { if (ConfigStore(activity).trustSshHostKey(prompt.profileId, prompt.hop, prompt.fingerprint)) { if (prompt.testOnly) ProxyVpnService.test(activity) else ProxyVpnService.reconnect(activity) } onDismiss() }) { Text(stringResource(if (prompt.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, - dismissButton = { TextButton(onClick = ::reject) { Text(stringResource(R.string.cancel)) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = ::reject) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/app/src/main/java/net/megaproxy487/UiLayout.kt b/app/src/main/java/net/megaproxy487/UiLayout.kt new file mode 100644 index 0000000..52e7055 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/UiLayout.kt @@ -0,0 +1,92 @@ +package net.megaproxy487 + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Actions keep their natural touch targets and wrap instead of squeezing their neighbours. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun WrappingActions( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.End, + content: @Composable () -> Unit, +) { + FlowRow( + modifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { content() } +} + +/** Reserve enough text space at large font sizes before placing a trailing control inline. */ +@Composable +internal fun AdaptiveLabelRow( + modifier: Modifier = Modifier, + minimumInlineWidth: Dp = 320.dp, + label: @Composable (Modifier) -> Unit, + trailing: @Composable () -> Unit, +) { + BoxWithConstraints(modifier.fillMaxWidth()) { + if (maxWidth < minimumInlineWidth * LocalDensity.current.fontScale) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + label(Modifier.fillMaxWidth()) + trailing() + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + label(Modifier.weight(1f)) + trailing() + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AdaptiveStatColumns(modifier: Modifier = Modifier, content: @Composable (Modifier) -> Unit) { + BoxWithConstraints(modifier.fillMaxWidth()) { + val columns = (maxWidth / (132.dp * LocalDensity.current.fontScale)).toInt().coerceIn(1, 3) + FlowRow( + Modifier.fillMaxWidth(), + maxItemsInEachRow = columns, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { content(Modifier.weight(1f)) } + } +} + +/** App bars have a fixed height; the full title remains available to accessibility services. */ +@Composable +internal fun ScreenTitle(title: String) { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +@Composable +internal fun FieldLabel(text: String) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +@Composable +internal fun ScrollableDialogText(text: String) { + Text(text, modifier = Modifier.verticalScroll(rememberScrollState())) +} + +@Composable +internal fun DialogTitle(title: String) { + Text(title, maxLines = 2, overflow = TextOverflow.Ellipsis) +} diff --git a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt index 0ef3494..38b923b 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -72,7 +72,7 @@ internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.visibility)) }, + title = { ScreenTitle(stringResource(R.string.visibility)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -90,7 +90,7 @@ internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit) { stringResource(R.string.visibility_intro), style = MaterialTheme.typography.bodyMedium, ) - Button(onClick = { report = buildVisibilityReport(activity) }, modifier = Modifier.fillMaxWidth()) { + Button(shape = RoundedCornerShape(12.dp), onClick = { report = buildVisibilityReport(activity) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.run_checks_again)) } VisibilitySection(stringResource(R.string.globally_observable_signals), report.global) @@ -119,10 +119,10 @@ private fun VisibilitySection(title: String, checks: List) { checks.forEach { check -> Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(check.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) - VisibilityBadge(check.state) - } + AdaptiveLabelRow( + label = { Text(check.title, style = MaterialTheme.typography.titleSmall, modifier = it) }, + trailing = { VisibilityBadge(check.state) }, + ) Text(check.detail, style = MaterialTheme.typography.bodySmall) } } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a61711e..eb8cec6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -284,4 +284,5 @@ %1$d мин %1$d ч %2$d мин %1$d д %2$d ч + Удалить профиль? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index debb1e4..b73a572 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,4 +279,5 @@ %1$d min %1$d h %2$d min %1$d d %2$d h + Delete profile? From c592c6335536f0ca3bb9950a6fdf42ae1304875f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 16:49:13 +0300 Subject: [PATCH 2/6] Localize UI messages while preserving system data formats --- AGENTS.md | 3 +- .../net/megaproxy487/ConnectionTestScreen.kt | 39 ++--- .../net/megaproxy487/DiagnosticLogScreen.kt | 12 +- .../java/net/megaproxy487/FeedbackEmail.kt | 39 ++--- .../java/net/megaproxy487/LocalizedLabels.kt | 83 +++++++++ .../java/net/megaproxy487/MainActivity.kt | 63 +++---- .../net/megaproxy487/ProfileEditorScreen.kt | 40 ++--- .../java/net/megaproxy487/ProfilesScreen.kt | 45 ++--- .../java/net/megaproxy487/SettingsScreens.kt | 30 ++-- .../net/megaproxy487/SplitTunnelScreen.kt | 6 +- .../java/net/megaproxy487/SshHostKeyScreen.kt | 6 +- .../java/net/megaproxy487/UiFormatting.kt | 12 ++ app/src/main/java/net/megaproxy487/UiText.kt | 48 ++++++ .../java/net/megaproxy487/VisibilityScreen.kt | 24 +-- .../java/net/megaproxy487/data/ConfigStore.kt | 6 +- .../net/megaproxy487/data/ConfigTransfer.kt | 32 ++-- .../net/megaproxy487/data/FoxyProxyParser.kt | 28 +-- .../net/megaproxy487/data/LimitedInput.kt | 6 +- .../net/megaproxy487/data/PrivateKeyImport.kt | 18 +- .../net/megaproxy487/data/ProxyListParser.kt | 24 +-- .../net/megaproxy487/data/SuperProxyParser.kt | 6 +- .../net/megaproxy487/vpn/ProxyVpnService.kt | 69 ++++---- .../java/net/megaproxy487/vpn/VpnSettings.kt | 3 - app/src/main/res/values-ru/strings.xml | 163 ++++++++++++++++++ app/src/main/res/values/strings.xml | 163 ++++++++++++++++++ .../megaproxy487/LocalizedResourcesTest.kt | 12 ++ .../java/net/megaproxy487/UiFormattingTest.kt | 59 +++++++ .../megaproxy487/data/PrivateKeyImportTest.kt | 5 +- 28 files changed, 803 insertions(+), 241 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/LocalizedLabels.kt create mode 100644 app/src/main/java/net/megaproxy487/UiFormatting.kt create mode 100644 app/src/main/java/net/megaproxy487/UiText.kt create mode 100644 app/src/test/java/net/megaproxy487/UiFormattingTest.kt diff --git a/AGENTS.md b/AGENTS.md index 71ee72d..d38fe98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index d4fcfa2..b92f0c5 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -54,13 +54,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 @@ -81,24 +80,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) @@ -138,10 +137,10 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack verticalArrangement = Arrangement.spacedBy(12.dp), ) { val statusLabel = when (state) { - TestState.IDLE -> "Ready" - TestState.RUNNING -> "Testing" - TestState.SUCCEEDED -> "Test passed" - TestState.FAILED -> "Test failed" + 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 @@ -154,7 +153,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack else -> MaterialTheme.colorScheme.onSurface } Text( - if (state == TestState.RUNNING) "Testing…" else statusLabel, + if (state == TestState.RUNNING) activity.uiText(R.string.test_testing_progress) else statusLabel, style = MaterialTheme.typography.titleLarge, color = statusColor, ) @@ -162,25 +161,25 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack 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))) } + countryCode?.let { Text(stringResource(R.string.proxy_exit_country, formatCountry(it, systemFormattingLocale()))) } } } 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.getString(R.string.connection_test_clipboard_label), TestDiagnosticLog.entries.joinToString("\n"))) + 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( - "The test uses a temporary VPN when the main connection is inactive. Credentials and traffic content are not logged.", + 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) "Waiting for the first diagnostic event…" else "No test events yet." + 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()), @@ -195,7 +194,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack AlertDialog( onDismissRequest = { showAlwaysOnConflict = false }, title = { DialogTitle(stringResource(R.string.always_on_conflict_title)) }, - text = { ScrollableDialogText(OTHER_ALWAYS_ON_VPN_MESSAGE) }, + text = { ScrollableDialogText(activity.uiText(R.string.other_always_on_vpn)) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnConflict = false @@ -216,11 +215,11 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack 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(shape = RoundedCornerShape(12.dp), onClick = { @@ -234,7 +233,7 @@ 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)) } }, diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index 5df40a5..8c57bb1 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -52,7 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -90,10 +90,10 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { withContext(Dispatchers.IO) { activity.contentResolver.openOutputStream(uri, "wt")?.buffered()?.use { PersistentDiagnosticLog.copyTo(it) - } ?: error("Could not open the export file") + } ?: throw UiException(R.string.error_open_export) } - }.onSuccess { message = activity.getString(R.string.diagnostic_log_exported) } - .onFailure { message = it.message ?: "Could not export the diagnostic log" } + }.onSuccess { message = activity.uiText(R.string.diagnostic_log_exported) } + .onFailure { message = it.userMessage(activity, R.string.log_export_failed) } } } } @@ -166,8 +166,8 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { } Text( buildString { - append(if (autoScroll) "Auto-scroll on" else "Auto-scroll paused · scroll to the bottom to resume") - append(" · showing the latest 512 KB") + append(if (autoScroll) activity.uiText(R.string.log_auto_scroll_on) else activity.uiText(R.string.log_auto_scroll_paused)) + append(activity.uiText(R.string.log_latest)) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/net/megaproxy487/FeedbackEmail.kt b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt index bdce2ab..3c5b878 100644 --- a/app/src/main/java/net/megaproxy487/FeedbackEmail.kt +++ b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt @@ -17,7 +17,6 @@ import java.util.UUID object FeedbackEmail { private const val ADDRESS = "megaproxy-feedback@hotmail.com" - private const val SUBJECT = "[MegaProxy] Feedback" fun createIntent( context: Context, @@ -35,23 +34,23 @@ object FeedbackEmail { } val versionName = packageInfo.versionName ?: "unknown" val body = buildString { - appendLine(if (crashReport) "Please describe what happened before the crash above this line." else "Please write your comments above this line.") + appendLine(if (crashReport) context.uiText(R.string.crash_instructions) else context.uiText(R.string.feedback_instructions)) appendLine() - appendLine("--- Diagnostic information ---") - appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") - appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") - if (Build.VERSION.SECURITY_PATCH.isNotBlank()) appendLine("Security patch: ${Build.VERSION.SECURITY_PATCH}") - appendLine("App: $versionName ($versionCode)") + appendLine(context.uiText(R.string.feedback_diagnostics)) + appendLine(context.uiText(R.string.feedback_device, Build.MANUFACTURER, Build.MODEL)) + appendLine(context.uiText(R.string.feedback_android, Build.VERSION.RELEASE, Build.VERSION.SDK_INT)) + if (Build.VERSION.SECURITY_PATCH.isNotBlank()) appendLine(context.uiText(R.string.feedback_patch, Build.VERSION.SECURITY_PATCH)) + appendLine(context.uiText(R.string.feedback_app, versionName, versionCode)) appendLine("ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") - appendLine("Connection: ${connection.name.lowercase()}") - appendLine("Always-on: $alwaysOn; lockdown: $lockdown") - appendLine("Routing: ${if (config.routeAllApps) "global" else "split"}; selected app count: ${config.selectedPackages.size}") - appendLine("HTTPS fingerprint: ${config.profile.name}") - appendLine("SSH fingerprint: ${config.sshProfile.name}") - appendLine("DoH provider: ${config.dnsProvider.name}") - appendLine("IPv6: ${config.allowIpv6}; bypass local networks: ${config.bypassLocalNetworks}") - appendLine("Proxy certificate verification: ${!config.allowInvalidProxyCertificate}") - appendLine("Privacy-filtered logs are attached.") + appendLine(context.uiText(R.string.feedback_connection, connection.name.lowercase())) + appendLine(context.uiText(R.string.feedback_always_on, alwaysOn, lockdown)) + appendLine(context.uiText(R.string.feedback_routing, context.uiText(if (config.routeAllApps) R.string.feedback_routing_global else R.string.feedback_routing_split), config.selectedPackages.size)) + appendLine(context.uiText(R.string.feedback_https, config.profile.name)) + appendLine(context.uiText(R.string.feedback_ssh, config.sshProfile.name)) + appendLine(context.uiText(R.string.feedback_dns, config.dnsProvider.name)) + appendLine(context.uiText(R.string.feedback_ipv6, config.allowIpv6, config.bypassLocalNetworks)) + appendLine(context.uiText(R.string.feedback_certificate, !config.allowInvalidProxyCertificate)) + appendLine(context.uiText(R.string.feedback_logs)) } val directory = File(context.cacheDir, "feedback").also { it.mkdirs() } @@ -74,10 +73,10 @@ object FeedbackEmail { val sendIntent = Intent(Intent.ACTION_SEND).apply { type = "application/zip" putExtra(Intent.EXTRA_EMAIL, arrayOf(ADDRESS)) - putExtra(Intent.EXTRA_SUBJECT, if (crashReport) "[MegaProxy] Crash" else SUBJECT) + putExtra(Intent.EXTRA_SUBJECT, context.uiText(if (crashReport) R.string.feedback_crash_subject else R.string.feedback_subject)) putExtra(Intent.EXTRA_TEXT, body) putExtra(Intent.EXTRA_STREAM, logUri) - clipData = ClipData.newUri(context.contentResolver, "MegaProxy diagnostic log archive", logUri) + clipData = ClipData.newUri(context.contentResolver, context.uiText(R.string.feedback_archive), logUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val emailPackages = context.packageManager @@ -89,8 +88,8 @@ object FeedbackEmail { it.resolveActivity(context.packageManager) != null } } - if (targeted.isEmpty()) return Intent.createChooser(sendIntent, "Send feedback") - return Intent.createChooser(targeted.first(), "Send feedback").apply { + if (targeted.isEmpty()) return Intent.createChooser(sendIntent, context.uiText(R.string.feedback_send)) + return Intent.createChooser(targeted.first(), context.uiText(R.string.feedback_send)).apply { if (targeted.size > 1) { putExtra(Intent.EXTRA_INITIAL_INTENTS, targeted.drop(1).toTypedArray()) } diff --git a/app/src/main/java/net/megaproxy487/LocalizedLabels.kt b/app/src/main/java/net/megaproxy487/LocalizedLabels.kt new file mode 100644 index 0000000..3ca6ef0 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/LocalizedLabels.kt @@ -0,0 +1,83 @@ +package net.megaproxy487 + +import androidx.annotation.StringRes +import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.SshAuthMode +import net.megaproxy487.model.SshProfile +import net.megaproxy487.model.TlsProfile +import net.megaproxy487.vpn.BlockingSignal + +@get:StringRes +internal val ProxyType.titleRes: Int + get() = when (this) { + ProxyType.HTTPS -> R.string.option_proxytype_https + ProxyType.HTTPS_JUMP -> R.string.option_proxytype_https_jump + ProxyType.SSH -> R.string.option_proxytype_ssh + ProxyType.SSH_JUMP -> R.string.option_proxytype_ssh_jump + } + +@get:StringRes +internal val SshProfile.titleRes: Int + get() = when (this) { + SshProfile.DEFAULT -> R.string.option_sshprofile_default + SshProfile.OPENSSH_TERMUX -> R.string.option_sshprofile_openssh_termux + SshProfile.CONNECTBOT -> R.string.option_sshprofile_connectbot + SshProfile.JUICESSH -> R.string.option_sshprofile_juicessh + SshProfile.TERMIUS_ANDROID -> R.string.option_sshprofile_termius_android + } + +@get:StringRes +internal val SshAuthMode.titleRes: Int + get() = when (this) { + SshAuthMode.AUTO -> R.string.option_sshauthmode_auto + SshAuthMode.PASSWORD_ONLY -> R.string.option_sshauthmode_password_only + SshAuthMode.KEY_ONLY -> R.string.option_sshauthmode_key_only + } + +@get:StringRes +internal val FailoverMode.titleRes: Int + get() = when (this) { + FailoverMode.DISABLED -> R.string.option_failovermode_disabled + FailoverMode.SELECTED -> R.string.option_failovermode_selected + FailoverMode.ALL -> R.string.option_failovermode_all + } + +@get:StringRes +internal val DnsProvider.titleRes: Int + get() = when (this) { + DnsProvider.CLOUDFLARE -> R.string.option_dnsprovider_cloudflare + DnsProvider.GOOGLE -> R.string.option_dnsprovider_google + DnsProvider.QUAD9 -> R.string.option_dnsprovider_quad9 + DnsProvider.YANDEX -> R.string.option_dnsprovider_yandex + DnsProvider.YANDEX_SAFE -> R.string.option_dnsprovider_yandex_safe + DnsProvider.YANDEX_FAMILY -> R.string.option_dnsprovider_yandex_family + DnsProvider.CUSTOM -> R.string.option_dnsprovider_custom + } + +@get:StringRes +internal val TlsProfile.titleRes: Int + get() = when (this) { + TlsProfile.DEFAULT -> R.string.option_tlsprofile_default + TlsProfile.CHROME_ANDROID -> R.string.option_tlsprofile_chrome_android + TlsProfile.FIREFOX_ANDROID -> R.string.option_tlsprofile_firefox_android + TlsProfile.EDGE_ANDROID -> R.string.option_tlsprofile_edge_android + TlsProfile.RANDOMIZED -> R.string.option_tlsprofile_randomized + TlsProfile.SAMSUNG_INTERNET -> R.string.option_tlsprofile_samsung_internet + TlsProfile.YANDEX_BROWSER -> R.string.option_tlsprofile_yandex_browser + TlsProfile.CUSTOM -> R.string.option_tlsprofile_custom + } + +@get:StringRes +internal val BlockingSignal.titleRes: Int + get() = when (this) { + BlockingSignal.TCP_TIMEOUT -> R.string.blocking_tcp_timeout + BlockingSignal.TLS_HANDSHAKE_TIMEOUT -> R.string.blocking_tls_handshake_timeout + BlockingSignal.TLS_HANDSHAKE_RESET -> R.string.blocking_tls_handshake_reset + BlockingSignal.CONNECT_RESPONSE_TIMEOUT -> R.string.blocking_connect_response_timeout + BlockingSignal.CONNECT_RESPONSE_RESET -> R.string.blocking_connect_response_reset + BlockingSignal.SSH_HANDSHAKE_TIMEOUT -> R.string.blocking_ssh_handshake_timeout + BlockingSignal.CONNECTION_RESET -> R.string.blocking_connection_reset + BlockingSignal.SILENT_DROP -> R.string.blocking_silent_drop + } diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 697ea8e..04f4366 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -8,9 +8,6 @@ import android.net.VpnService import android.os.Build import android.os.Bundle import android.os.SystemClock -import java.text.DateFormat -import java.util.Date -import androidx.compose.ui.platform.LocalConfiguration import net.megaproxy487.vpn.ConnectionSession import net.megaproxy487.vpn.connectionDuration import androidx.activity.ComponentActivity @@ -75,7 +72,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.semantics.LiveRegionMode @@ -108,7 +105,6 @@ import net.megaproxy487.vpn.readAlwaysOnVpnStatus import net.megaproxy487.vpn.AlwaysOnVpnStatus import net.megaproxy487.vpn.hasOtherProvider import net.megaproxy487.vpn.openAndroidVpnSettings -import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE import net.megaproxy487.model.ProfileColors import net.megaproxy487.model.ProxyType import net.megaproxy487.model.ProxyProfile @@ -173,12 +169,7 @@ internal fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modi border = BorderStroke(1.dp, foreground.copy(alpha = 0.5f)), ) { Text( - when (type) { - ProxyType.HTTPS -> "HTTPS" - ProxyType.HTTPS_JUMP -> stringResource(R.string.https_with_jump) - ProxyType.SSH -> "SSH" - ProxyType.SSH_JUMP -> "SSH + Jump" - }, + stringResource(type.titleRes), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.sp), maxLines = 1, softWrap = false, @@ -346,7 +337,7 @@ internal fun MainScreen( error = null showAlwaysOnConflict = true } else { - error = "VPN access was not granted in the Android confirmation dialog" + error = activity.uiText(R.string.vpn_permission_denied) } } } @@ -373,7 +364,7 @@ internal fun MainScreen( systemVpnStatus = readAlwaysOnVpnStatus(activity) error = null } else { - error = globalSettings.applyTo(store.activeProfile().config).validationError()?.let { activity.getString(it) } + error = globalSettings.applyTo(store.activeProfile().config).validationError()?.let { activity.uiText(it) } } if (error == null && !isAlwaysOnVpnActive(activity)) { if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( @@ -400,9 +391,9 @@ internal fun MainScreen( ) { val connected = connection == VpnConnectionState.CONNECTED val statusLabel = when (connection) { - VpnConnectionState.CONNECTED -> "Connected" - VpnConnectionState.CONNECTING -> "Connecting" - VpnConnectionState.DISCONNECTED -> "Disconnected" + VpnConnectionState.CONNECTED -> activity.uiText(R.string.status_connected) + VpnConnectionState.CONNECTING -> activity.uiText(R.string.status_connecting) + VpnConnectionState.DISCONNECTED -> activity.uiText(R.string.status_disconnected) } val statusColor = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant Card(Modifier.fillMaxWidth().semantics { @@ -420,13 +411,13 @@ internal fun MainScreen( tint = statusColor, ) Text( - if (connection == VpnConnectionState.CONNECTING) "Connecting…" else statusLabel, + if (connection == VpnConnectionState.CONNECTING) activity.uiText(R.string.status_connecting_progress) else statusLabel, style = MaterialTheme.typography.headlineSmall, color = statusColor, ) if (alwaysOn) { Text( - if (lockdown) "Always-on · Block without VPN" else "Always-on VPN", + if (lockdown) activity.uiText(R.string.always_on_lockdown) else activity.uiText(R.string.always_on_badge), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -466,7 +457,7 @@ internal fun MainScreen( val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first() val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId } - val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()?.let { activity.getString(it) } + val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()?.let { activity.uiText(it) } val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)]) val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White Box(Modifier.fillMaxWidth()) { @@ -497,7 +488,7 @@ internal fun MainScreen( } } ProfileSelectorLabel( - activeProfile.displayName, + activeProfile.localizedName(activity), activeProfile.config.type, onProfileColor, modifier = Modifier.weight(1f), @@ -506,7 +497,7 @@ internal fun MainScreen( Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.select_profile), tint = onProfileColor) } if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) { - Text(stringResource(R.string.connected_through, actualProfile.displayNameWithFlag), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.connected_through, actualProfile.localizedNameWithFlag(activity)), style = MaterialTheme.typography.bodySmall) } DropdownMenu( profileMenuExpanded, @@ -517,7 +508,7 @@ internal fun MainScreen( DropdownMenuItem( text = { ProfileSelectorLabel( - profile.displayNameWithFlag, + profile.localizedNameWithFlag(activity), profile.config.type, MaterialTheme.colorScheme.onSurface, modifier = Modifier.fillMaxWidth(), @@ -619,7 +610,7 @@ internal fun MainScreen( } activity.startActivity(intent) }.onFailure { - error = "No email client is available: ${it.message ?: "unknown error"}" + error = activity.uiText(R.string.could_not_open_email) } } }, @@ -630,7 +621,7 @@ internal fun MainScreen( } if (alwaysOn) { Text( - "Always-on VPN is used. Selecting a profile updates the Always-on profile and reconnects immediately; disconnect remains managed by Android.", + activity.uiText(R.string.always_on_profile_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), @@ -662,7 +653,7 @@ internal fun MainScreen( CrashHandler.markReportHandled() showCrashReport = false }.onFailure { - error = "Could not open an email client: ${it.message ?: "unknown error"}" + error = activity.uiText(R.string.could_not_open_email) } } }) { Text(stringResource(R.string.report)) } @@ -680,7 +671,7 @@ internal fun MainScreen( AlertDialog( onDismissRequest = { showAlwaysOnConflict = false }, title = { DialogTitle(stringResource(R.string.always_on_conflict_title)) }, - text = { ScrollableDialogText(OTHER_ALWAYS_ON_VPN_MESSAGE) }, + text = { ScrollableDialogText(activity.uiText(R.string.other_always_on_vpn)) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnConflict = false @@ -703,11 +694,11 @@ internal fun MainScreen( 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(shape = RoundedCornerShape(12.dp), onClick = { @@ -715,7 +706,7 @@ internal fun MainScreen( SshHostKeyPromptState.clear() ProxyVpnService.reconnect(activity) } else { - error = activity.getString(R.string.ssh_key_save_failed) + error = activity.uiText(R.string.ssh_key_save_failed) } }) { Text(stringResource(if (pending.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, @@ -752,19 +743,19 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { AdaptiveStatColumns(Modifier.padding(14.dp)) { statModifier -> StatValue( label = stringResource(R.string.traffic_download), - value = "↓ ${formatTrafficRate(stats.downloadBytesPerSecond, unitSystem)}", + value = "↓ ${formatTrafficRate(stats.downloadBytesPerSecond, unitSystem, systemFormattingLocale())}", supportingValue = stringResource( R.string.traffic_total, - formatTrafficBytes(stats.native.downloadBytes, unitSystem), + formatTrafficBytes(stats.native.downloadBytes, unitSystem, systemFormattingLocale()), ), modifier = statModifier, ) StatValue( label = stringResource(R.string.traffic_upload), - value = "↑ ${formatTrafficRate(stats.uploadBytesPerSecond, unitSystem)}", + value = "↑ ${formatTrafficRate(stats.uploadBytesPerSecond, unitSystem, systemFormattingLocale())}", supportingValue = stringResource( R.string.traffic_total, - formatTrafficBytes(stats.native.uploadBytes, unitSystem), + formatTrafficBytes(stats.native.uploadBytes, unitSystem, systemFormattingLocale()), ), modifier = statModifier, ) @@ -811,9 +802,7 @@ private fun ConnectionTimingDetails(session: ConnectionSession) { } } } - val locale = LocalConfiguration.current.locales[0] - val startedAt = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, locale) - .format(Date(session.startedAtMillis)) + val startedAt = formatConnectionStartedAt(session.startedAtMillis, systemFormattingLocale()) val seconds = duration.seconds val elapsed = when { seconds < 60 -> stringResource(R.string.connection_duration_seconds, seconds) diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 2946706..fa98326 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -57,7 +57,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp @@ -101,8 +101,8 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: var connectionChangeDeferred by remember { mutableStateOf(false) } val countries = remember { Locale.getISOCountries().map { code -> - code to Locale.Builder().setRegion(code).build().getDisplayCountry(Locale.getDefault()) - }.sortedBy { it.second.lowercase(Locale.getDefault()) } + code to Locale.Builder().setRegion(code).build().getDisplayCountry(systemFormattingLocale()) + }.sortedBy { it.second.lowercase(systemFormattingLocale()) } } val globalSettings = remember { store.globalConnectionSettings() } @@ -119,7 +119,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: config = updated profile = profile.copy(config = updated) saveProfile() - error = globalSettings.applyTo(updated).connectionValidationError()?.let { activity.getString(it) } + error = globalSettings.applyTo(updated).connectionValidationError()?.let { activity.uiText(it) } if (ProxyVpnService.isRunning && profile.id == editedConnectionProfileId) { coroutineScope.launch(ConfigIoDispatcher) { store.markPendingReconnect() } } @@ -137,14 +137,14 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: val result = runCatching { withContext(Dispatchers.IO) { activity.contentResolver.openInputStream(uri)?.use { it.readPrivateKeyText() } - ?: throw IllegalArgumentException("Unable to open the selected file") + ?: throw UiException(R.string.error_open_file) } } result.onSuccess { imported -> updateConfig(if (jump) config.copy(jumpPrivateKey = imported) else config.copy(privateKey = imported)) error = null }.onFailure { failure -> - error = failure.message ?: "Unable to import the private key" + error = failure.userMessage(activity, R.string.key_import_failed) } } } @@ -158,7 +158,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: Scaffold( topBar = { TopAppBar( - title = { ScreenTitle(profile.displayName) }, + title = { ScreenTitle(profile.localizedName(activity)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) @@ -186,7 +186,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: OutlinedTextField( profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" - } ?: "No flag", + } ?: 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(), @@ -217,9 +217,9 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { - OutlinedTextField(if (config.type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else config.type.title, {}, readOnly = true, label = { FieldLabel(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + 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(if (type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else type.title) }, onClick = { + ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(activity.uiText(type.titleRes)) }, onClick = { updateConfig(config.copy(type = type, port = type.defaultPort, jumpPort = if (type.hasJump && (!config.type.hasJump || type.isHttps != config.type.isHttps)) type.defaultPort else config.jumpPort)) portText = type.defaultPort.toString() if (type.hasJump) jumpPortText = config.jumpPort.toString() @@ -235,7 +235,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: OutlinedTextField(portText, { value -> portText = value val port = value.toIntOrNull() - if (port == null) error = activity.getString(R.string.validation_port) + if (port == null) error = activity.uiText(R.string.validation_port) else updateConfig(config.copy(port = port)) }, label = { FieldLabel(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } @@ -269,14 +269,14 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (config.password.isBlank() && config.privateKey.isBlank()) { item { Text( - "No SSH password or private key is configured. Connection will only work if the server permits authentication without credentials.", + activity.uiText(R.string.ssh_no_credentials), color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall, ) } } item { - SettingCheckboxRow(config.acceptAnyHostKey, "Accept any destination host key", "Unsafe: disables SSH server identity verification.") { checked -> + SettingCheckboxRow(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)) } } @@ -338,7 +338,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: }, label = { FieldLabel(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - SettingCheckboxRow(config.sameJumpAuthentication, "Use the same authentication", "Reuse destination username, password and private key.") { + SettingCheckboxRow(config.sameJumpAuthentication, activity.uiText(R.string.ssh_same_auth), activity.uiText(R.string.ssh_same_auth_help)) { updateConfig(config.copy(sameJumpAuthentication = it)) } } @@ -361,7 +361,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (config.jumpPassword.isBlank() && config.jumpPrivateKey.isBlank()) { item { Text( - "No jump password or private key is configured. Connection will only work if the jump server permits authentication without credentials.", + activity.uiText(R.string.ssh_jump_no_credentials), color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall, ) @@ -369,7 +369,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } item { - SettingCheckboxRow(config.jumpAcceptAnyHostKey, "Accept any jump host key", "Unsafe: disables jump host identity verification.") { checked -> + SettingCheckboxRow(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)) } } @@ -377,7 +377,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { - SettingCheckboxRow(config.allowIpv6, "Enable IPv6 destinations", "Enable only if this proxy can reach IPv6 destinations.") { + SettingCheckboxRow(config.allowIpv6, activity.uiText(R.string.ipv6_destinations), activity.uiText(R.string.ipv6_destinations_help)) { updateConfig(config.copy(allowIpv6 = it)) } } @@ -387,9 +387,9 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { - OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { FieldLabel(stringResource(R.string.dns_over_https)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + 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(provider.title) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } + DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(activity.uiText(provider.titleRes)) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } } } } @@ -447,7 +447,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: AlertDialog( onDismissRequest = { unsafeHostKeyHop = null }, title = { DialogTitle(stringResource(R.string.accept_any_ssh_key_title)) }, - text = { ScrollableDialogText(stringResource(R.string.accept_any_ssh_key_message, hop)) }, + text = { ScrollableDialogText(stringResource(R.string.accept_any_ssh_key_message, activity.sshHopLabel(hop))) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text(stringResource(R.string.accept_any_key)) } }, dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { unsafeHostKeyHop = null }) { Text(stringResource(R.string.cancel)) } }, ) diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 19663d0..48e0cd6 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -79,7 +79,7 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -255,14 +255,14 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil val result = withContext(Dispatchers.IO) { runCatching { activity.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { it.write(content) } - ?: error("Could not open the export file") + ?: throw UiException(R.string.error_open_export) } } - result.onSuccess { transferMessage = activity.getString(R.string.configuration_exported) } + result.onSuccess { transferMessage = activity.uiText(R.string.configuration_exported) } .onFailure { - importError = activity.getString( + importError = activity.uiText( R.string.configuration_export_failed, - it.message ?: activity.getString(R.string.unknown_error), + it.userMessage(activity, R.string.unknown_error), ) } } @@ -279,12 +279,12 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil val imported = result.added + result.updated + result.unchanged val missingPasswords = imported.count { it.config.password.isEmpty() } val summary = listOfNotNull( - activity.getString(R.string.config_import_summary, result.added.size, result.updated.size, result.unchanged.size), - activity.getString(R.string.config_import_skipped, configuration.skippedProfiles) + activity.uiText(R.string.config_import_summary, result.added.size, result.updated.size, result.unchanged.size), + activity.uiText(R.string.config_import_skipped, configuration.skippedProfiles) .takeIf { configuration.skippedProfiles > 0 }, - activity.getString(R.string.config_import_missing_passwords, missingPasswords) + activity.uiText(R.string.config_import_missing_passwords, missingPasswords) .takeIf { missingPasswords > 0 }, - activity.getString(R.string.config_import_always_on_reconnect) + activity.uiText(R.string.config_import_always_on_reconnect) .takeIf { ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning }, ).joinToString(" ") if (result.missing.isEmpty()) { @@ -355,7 +355,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil if (uri != null) { runCatching { val text = activity.contentResolver.openInputStream(uri)?.buffered()?.use { it.readConfigText() } - ?: error("Could not read the selected file") + ?: throw UiException(R.string.error_read_file) val isJson = activity.contentResolver.getType(uri) == "application/json" || uri.lastPathSegment.orEmpty().substringAfterLast('.', "").equals("json", true) || text.trimStart().startsWith('{') @@ -378,7 +378,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil skippedNonHttps = imported.skippedNonHttps showImportFilterNotice = imported.skippedNonHttps > 0 if (imported.skippedNonHttps == 0) { - transferMessage = activity.getString(R.string.imported_foxyproxy, added.size) + transferMessage = activity.uiText(R.string.imported_foxyproxy, added.size) } } } else { @@ -395,17 +395,17 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil showImportFilterNotice = imported.skippedNonHttps > 0 if (imported.skippedNonHttps == 0) { transferMessage = if (isSuperProxy) { - activity.getString(R.string.imported_super_proxy, added.size) + activity.uiText(R.string.imported_super_proxy, added.size) } else { - activity.getString(R.string.imported_https_profiles, added.size) + activity.uiText(R.string.imported_https_profiles, added.size) } } } importError = null }.onFailure { - importError = activity.getString( + importError = activity.uiText( R.string.configuration_import_failed, - it.message ?: activity.getString(R.string.unknown_error), + it.userMessage(activity, R.string.error_invalid_input), ) } } @@ -509,10 +509,10 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil .semantics { customActions = buildList { if (profiles.firstOrNull()?.id != profile.id) { - add(CustomAccessibilityAction(activity.getString(R.string.move_up)) { moveProfile(profile.id, -1) }) + add(CustomAccessibilityAction(activity.uiText(R.string.move_up)) { moveProfile(profile.id, -1) }) } if (profiles.lastOrNull()?.id != profile.id) { - add(CustomAccessibilityAction(activity.getString(R.string.move_down)) { moveProfile(profile.id, 1) }) + add(CustomAccessibilityAction(activity.uiText(R.string.move_down)) { moveProfile(profile.id, 1) }) } } }, @@ -536,7 +536,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil AlertDialog( onDismissRequest = { deleteProfile = null }, title = { DialogTitle(stringResource(R.string.delete_profile_confirmation)) }, - text = { ScrollableDialogText(profile.displayName + "\n\n" + stringResource(R.string.delete_profile_message)) }, + text = { ScrollableDialogText(profile.localizedName(activity) + "\n\n" + stringResource(R.string.delete_profile_message)) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { deleteProfile = null scope.launch { @@ -646,7 +646,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil verticalAlignment = Alignment.Top, ) { Column(Modifier.padding(top = 10.dp)) { - Text(item.profile.displayName, style = MaterialTheme.typography.titleSmall) + Text(item.profile.localizedName(activity), style = MaterialTheme.typography.titleSmall) item.changedGroups.forEach { group -> val key = importOptionKey(item.profile.id, group) Row(verticalAlignment = Alignment.CenterVertically) { @@ -735,7 +735,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil }, ) Column(Modifier.weight(1f)) { - Text(profile.displayName) + Text(profile.localizedName(activity)) Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) } } @@ -760,7 +760,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil refresh() if (reconnect) ProxyVpnService.reconnect(activity) transferMessage = review.importSummary + " " + - activity.getString(R.string.config_import_removed, selected.size) + activity.uiText(R.string.config_import_removed, selected.size) } }, ) { Text(stringResource(R.string.remove_selected)) } @@ -784,6 +784,7 @@ private fun ProfileCard( onClone: () -> Unit, onDelete: () -> Unit, ) { + val activity = androidx.compose.ui.platform.LocalContext.current val background = Color(ProfileColors.argb[Math.floorMod(profile.colorIndex, ProfileColors.argb.size)]) val foreground = if (background.luminance() > 0.45f) Color.Black else Color.White Card( @@ -802,7 +803,7 @@ private fun ProfileCard( ) { Text(profile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) } } Column(Modifier.weight(1f)) { - Text(profile.displayName, style = MaterialTheme.typography.titleMedium) + Text(profile.localizedName(activity), style = MaterialTheme.typography.titleMedium) Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) } } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index e43a548..29a7202 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -57,7 +57,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.semantics.Role @@ -158,13 +158,13 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga ) } activity.startActivity(intent) - }.onFailure { supportError = it.message ?: activity.getString(R.string.could_not_open_email) } + }.onFailure { supportError = activity.uiText(R.string.could_not_open_email) } } } SettingsButton(stringResource(R.string.github_issues), stringResource(R.string.github_issues_description)) { runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/andre487/AndroidMegaProxy/issues"))) - }.onFailure { supportError = activity.getString(R.string.no_browser) } + }.onFailure { supportError = activity.uiText(R.string.no_browser) } } supportError?.let { Text(it, color = MaterialTheme.colorScheme.error) } Text( @@ -266,9 +266,9 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { } SettingsScaffold(onBack, stringResource(R.string.failover)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { - OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { FieldLabel(stringResource(R.string.failover_mode)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(activity.uiText(settings.failoverMode.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.failover_mode)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(expanded, { expanded = false }) { - FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { + FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(activity.uiText(mode.titleRes)) }, onClick = { expanded = false if (mode == FailoverMode.ALL) pendingAll = true else save(mode = mode) }) } @@ -292,7 +292,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { verticalAlignment = Alignment.CenterVertically, ) { Checkbox(checked, null) - Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f)) + Text(profile.localizedNameWithFlag(activity), modifier = Modifier.weight(1f)) } } val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds } @@ -336,14 +336,14 @@ internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { SettingsScaffold(onBack, stringResource(R.string.always_on_vpn)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( - selected.displayNameWithFlag, {}, readOnly = true, + selected.localizedNameWithFlag(activity), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.always_on_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(expanded, { expanded = false }) { store.sortedProfiles().forEach { profile -> - DropdownMenuItem(text = { Text(profile.displayNameWithFlag) }, onClick = { + DropdownMenuItem(text = { Text(profile.localizedNameWithFlag(activity)) }, onClick = { selected = profile scope.launch(ConfigIoDispatcher) { store.setAlwaysOnProfile(profile.id) } expanded = false @@ -399,7 +399,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { ) { saveSettings(settings.copy(tlsProfile = profile, customJa3 = customJa3, sshProfile = sshProfile)) error = if (profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null) { - activity.getString(R.string.invalid_ja3) + activity.uiText(R.string.invalid_ja3) } else null } @@ -407,14 +407,14 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { Text(stringResource(R.string.https_fingerprint), style = MaterialTheme.typography.titleMedium) ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( - settings.tlsProfile.title, {}, readOnly = true, + activity.uiText(settings.tlsProfile.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.https_tls_ja3_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(expanded, { expanded = false }) { TlsProfile.entries.filter { it.available }.forEach { profile -> - DropdownMenuItem(text = { Text(profile.title) }, onClick = { save(profile = profile); expanded = false }) + DropdownMenuItem(text = { Text(activity.uiText(profile.titleRes)) }, onClick = { save(profile = profile); expanded = false }) } } } @@ -432,14 +432,14 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { Text(stringResource(R.string.ssh_fingerprint), style = MaterialTheme.typography.titleMedium) ExposedDropdownMenuBox(sshExpanded, { sshExpanded = it }) { OutlinedTextField( - settings.sshProfile.title, {}, readOnly = true, + activity.uiText(settings.sshProfile.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.ssh_client_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(sshExpanded, { sshExpanded = false }) { SshProfile.entries.forEach { profile -> - DropdownMenuItem(text = { Text(profile.title) }, onClick = { + DropdownMenuItem(text = { Text(activity.uiText(profile.titleRes)) }, onClick = { save(sshProfile = profile) sshExpanded = false }) @@ -448,13 +448,13 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { } ExposedDropdownMenuBox(sshAuthExpanded, { sshAuthExpanded = it }) { OutlinedTextField( - settings.sshAuthMode.title, {}, readOnly = true, + activity.uiText(settings.sshAuthMode.titleRes), {}, readOnly = true, label = { FieldLabel(stringResource(R.string.ssh_authentication)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshAuthExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(sshAuthExpanded, { sshAuthExpanded = false }) { - SshAuthMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { + SshAuthMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(activity.uiText(mode.titleRes)) }, onClick = { saveSettings(settings.copy(sshAuthMode = mode)) sshAuthExpanded = false }) } diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index 576be0c..adf3b2a 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -50,7 +50,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role 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.data.ConfigIoDispatcher import net.megaproxy487.model.GlobalConnectionSettings @@ -131,8 +131,8 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { ) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Text( - if (showAlwaysOnDeferredNotice) "Always-on is active. Routing changes apply on the next connection." - else "Reconnect to apply routing changes to the active VPN.", + if (showAlwaysOnDeferredNotice) activity.uiText(R.string.routing_deferred) + else activity.uiText(R.string.routing_reconnect), ) WrappingActions() { TextButton(shape = RoundedCornerShape(12.dp), onClick = { diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt index 79cdb70..0006b1f 100644 --- a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt +++ b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import net.megaproxy487.data.ConfigStore import net.megaproxy487.vpn.PendingSshHostKey import net.megaproxy487.vpn.ProxyVpnService @@ -27,8 +27,8 @@ internal fun SshHostKeyScreen(activity: Activity, prompt: PendingSshHostKey, onD onDismissRequest = ::reject, title = { DialogTitle(stringResource(if (prompt.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, text = { ScrollableDialogText(buildString { - append(activity.getString(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, prompt.hop)) - append(activity.getString(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) + append(activity.uiText(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, activity.sshHopLabel(prompt.hop))) + append(activity.uiText(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) }) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { if (ConfigStore(activity).trustSshHostKey(prompt.profileId, prompt.hop, prompt.fingerprint)) { diff --git a/app/src/main/java/net/megaproxy487/UiFormatting.kt b/app/src/main/java/net/megaproxy487/UiFormatting.kt new file mode 100644 index 0000000..2fad62a --- /dev/null +++ b/app/src/main/java/net/megaproxy487/UiFormatting.kt @@ -0,0 +1,12 @@ +package net.megaproxy487 + +import java.util.Locale + +/** Translated templates never override the system formatting locale. */ +internal fun formatUiText(template: String, vararg args: Any, locale: Locale = Locale.getDefault()): String = + if (args.isEmpty()) template else String.format(locale, template, *args) + +internal fun formatConnectionStartedAt(startedAtMillis: Long, locale: Locale = Locale.getDefault()): String = + java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.SHORT, java.text.DateFormat.MEDIUM, locale, + ).format(java.util.Date(startedAtMillis)) diff --git a/app/src/main/java/net/megaproxy487/UiText.kt b/app/src/main/java/net/megaproxy487/UiText.kt new file mode 100644 index 0000000..ee7fce4 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/UiText.kt @@ -0,0 +1,48 @@ +package net.megaproxy487 + +import android.content.Context +import androidx.annotation.StringRes +import net.megaproxy487.model.ProxyProfile +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration + +/** UI language selects words; the system locale selects number/date formatting. */ +internal fun Context.uiText(@StringRes id: Int, vararg args: Any): String { + val localized = if (resources.configuration.locales[0].language == AppLanguageManager.current(this).tag) { + this + } else AppLanguageManager.wrap(this) + val template = localized.getString(id) + return formatUiText(template, *args, locale = systemFormattingLocale()) +} + +internal fun ProxyProfile.localizedName(context: Context): String = + name.trim().ifEmpty { config.host.trim().ifEmpty { context.uiText(R.string.profile_new) } } + +internal fun ProxyProfile.localizedNameWithFlag(context: Context): String = + listOf(flagEmoji, localizedName(context)).filter(String::isNotEmpty).joinToString(" ") + +/** Expected input errors retain a stable resource ID and format arguments across layers. */ +internal class UiException(@StringRes val textId: Int, vararg val arguments: Any) : + IllegalArgumentException("UI error $textId") + +internal fun Throwable.userMessage(context: Context, @StringRes fallback: Int): String = + if (this is UiException) context.uiText(textId, *arguments) else context.uiText(fallback) + +internal inline fun requireUi(condition: Boolean, error: () -> UiException = { UiException(R.string.error_invalid_input) }) { + if (!condition) throw error() +} + +@Composable +internal fun uiStringResource(@StringRes id: Int, vararg args: Any): String { + // Read configuration so a language/configuration change invalidates Compose text. + LocalConfiguration.current + return LocalContext.current.uiText(id, *args) +} + +internal fun Context.sshHopLabel(hop: String): String = + uiText(if (hop == "jump") R.string.ssh_hop_jump else R.string.ssh_hop_destination) + +/** Android may set the process default to the app language; use the device configuration. */ +internal fun systemFormattingLocale(): java.util.Locale = + android.content.res.Resources.getSystem().configuration.locales[0] diff --git a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt index 38b923b..082ce4c 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource +import net.megaproxy487.uiStringResource as stringResource import net.megaproxy487.ui.theme.MegaProxyTheme import net.megaproxy487.data.ConfigStore import java.net.NetworkInterface @@ -192,23 +192,23 @@ private fun buildVisibilityReport(context: Context): VisibilityReport { splitTunneling -> VisibilityState.SELECTED_APPS_ONLY else -> VisibilityState.DETECTED }, - if (detected && splitTunneling) context.getString(R.string.excluded_apps_underlying_network, yes) else if (detected) yes else no, + if (detected && splitTunneling) context.uiText(R.string.excluded_apps_underlying_network, yes) else if (detected) yes else no, ) return VisibilityReport( global = listOf( - check(context.getString(R.string.system_proxy_properties), systemProxyVisible, context.getString(R.string.system_proxy_yes), context.getString(R.string.system_proxy_no)), - check(context.getString(R.string.vpn_interface_name), vpnInterfaces.isNotEmpty(), context.getString(R.string.vpn_interface_yes), context.getString(R.string.vpn_interface_no)), - check(context.getString(R.string.unusual_vpn_mtu), unusualMtu.isNotEmpty(), context.getString(R.string.unusual_vpn_mtu_yes), context.getString(R.string.unusual_vpn_mtu_no)), - VisibilityCheck(context.getString(R.string.local_mitm_certificate), VisibilityState.NOT_DETECTED, context.getString(R.string.local_mitm_certificate_detail)), + check(context.uiText(R.string.system_proxy_properties), systemProxyVisible, context.uiText(R.string.system_proxy_yes), context.uiText(R.string.system_proxy_no)), + check(context.uiText(R.string.vpn_interface_name), vpnInterfaces.isNotEmpty(), context.uiText(R.string.vpn_interface_yes), context.uiText(R.string.vpn_interface_no)), + check(context.uiText(R.string.unusual_vpn_mtu), unusualMtu.isNotEmpty(), context.uiText(R.string.unusual_vpn_mtu_yes), context.uiText(R.string.unusual_vpn_mtu_no)), + VisibilityCheck(context.uiText(R.string.local_mitm_certificate), VisibilityState.NOT_DETECTED, context.uiText(R.string.local_mitm_certificate_detail)), ), selectedApps = listOf( - vpnCheck(context.getString(R.string.vpn_transport), vpnTransportVisible, context.getString(R.string.vpn_transport_yes), context.getString(R.string.vpn_transport_no)), - vpnCheck(context.getString(R.string.vpn_transport_information), vpnInfoVisible, context.getString(R.string.vpn_info_yes), context.getString(R.string.vpn_info_no)), - vpnCheck(context.getString(R.string.not_vpn_capability), vpnWithoutNotVpn, context.getString(R.string.not_vpn_yes), context.getString(R.string.not_vpn_no)), - vpnCheck(context.getString(R.string.vpn_link_proxy), linkProxyVisible, context.getString(R.string.vpn_link_proxy_yes), context.getString(R.string.vpn_link_proxy_no)), - vpnCheck(context.getString(R.string.virtual_default_route), virtualDefaultRoute, context.getString(R.string.virtual_route_yes), context.getString(R.string.virtual_route_no)), - vpnCheck(context.getString(R.string.virtual_dns_address), virtualDns, context.getString(R.string.virtual_dns_yes), context.getString(R.string.virtual_dns_no)), + vpnCheck(context.uiText(R.string.vpn_transport), vpnTransportVisible, context.uiText(R.string.vpn_transport_yes), context.uiText(R.string.vpn_transport_no)), + vpnCheck(context.uiText(R.string.vpn_transport_information), vpnInfoVisible, context.uiText(R.string.vpn_info_yes), context.uiText(R.string.vpn_info_no)), + vpnCheck(context.uiText(R.string.not_vpn_capability), vpnWithoutNotVpn, context.uiText(R.string.not_vpn_yes), context.uiText(R.string.not_vpn_no)), + vpnCheck(context.uiText(R.string.vpn_link_proxy), linkProxyVisible, context.uiText(R.string.vpn_link_proxy_yes), context.uiText(R.string.vpn_link_proxy_no)), + vpnCheck(context.uiText(R.string.virtual_default_route), virtualDefaultRoute, context.uiText(R.string.virtual_route_yes), context.uiText(R.string.virtual_route_no)), + vpnCheck(context.uiText(R.string.virtual_dns_address), virtualDns, context.uiText(R.string.virtual_dns_yes), context.uiText(R.string.virtual_dns_no)), ), splitTunneling = splitTunneling, ) diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 348745f..6630e48 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -1,5 +1,8 @@ package net.megaproxy487.data +import net.megaproxy487.uiText +import net.megaproxy487.localizedName + import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties @@ -32,6 +35,7 @@ data class ConfigurationImportResult( ) class ConfigStore(context: Context) { + private val context = context.applicationContext private val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) @Synchronized @@ -206,7 +210,7 @@ class ConfigStore(context: Context) { val source = existing[sourceIndex] val clone = source.copy( id = UUID.randomUUID().toString(), - name = "${source.displayName} copy", + name = context.uiText(net.megaproxy487.R.string.profile_copy, source.localizedName(context)), ) writeProfiles(existing.toMutableList().apply { add(sourceIndex + 1, clone) }) return clone diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index c4e849c..22322f3 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -1,5 +1,9 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + import net.megaproxy487.model.DnsProvider import net.megaproxy487.model.ProxyConfig import net.megaproxy487.model.ProxyProfile @@ -96,21 +100,21 @@ object ConfigTransfer { fun importJson(text: String): PortableConfiguration { val root = JSONObject(text) - require(isSupportedSchema(root.optString("schema"))) { "This is not a MegaProxy configuration file" } + requireUi(isSupportedSchema(root.optString("schema"))) { UiException(R.string.error_config_invalid) } val version = root.optInt("version", 0) - require(version in 1..SCHEMA_VERSION) { "Unsupported MegaProxy configuration version: $version" } - val array = root.optJSONArray("profiles") ?: error("The configuration has no profiles") - require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains more than $MAX_IMPORTED_PROFILES profiles" } - if (version >= 7) require((0 until array.length()).all { index -> + requireUi(version in 1..SCHEMA_VERSION) { UiException(R.string.error_config_version, version) } + val array = root.optJSONArray("profiles") ?: throw UiException(R.string.error_config_no_profiles) + requireUi(array.length() <= MAX_IMPORTED_PROFILES) { UiException(R.string.error_config_too_many, MAX_IMPORTED_PROFILES) } + if (version >= 7) requireUi((0 until array.length()).all { index -> array.optJSONObject(index)?.optString("id")?.let { it.isNotBlank() && it.length <= 256 } == true - }) { "Every profile in version 7 or newer must have a stable ID" } + }) { UiException(R.string.error_config_stable_ids) } val decoded = (0 until array.length()).map { index -> runCatching { decodeProfile(array.getJSONObject(index), index) } } val decodedProfiles = decoded.mapNotNull(Result::getOrNull) - require(decodedProfiles.isNotEmpty()) { "The configuration contains no usable profiles" } - require(decodedProfiles.map(ProxyProfile::id).distinct().size == decodedProfiles.size) { - "The configuration contains duplicate profile IDs" + requireUi(decodedProfiles.isNotEmpty()) { UiException(R.string.error_config_no_usable) } + requireUi(decodedProfiles.map(ProxyProfile::id).distinct().size == decodedProfiles.size) { + UiException(R.string.error_config_duplicate_ids) } val legacyIpv6 = root.optJSONObject("routing")?.optBoolean("allowIpv6", false) ?: false val profiles = if (version in 2..5) decodedProfiles.map { @@ -149,7 +153,7 @@ object ConfigTransfer { val failover = root.optJSONObject("failover") ?: JSONObject() val parsedTls = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT) val packages = routing.optJSONArray("selectedPackages")?.let { array -> - require(array.length() <= MAX_IMPORTED_PACKAGES) { "The configuration contains too many application package names" } + requireUi(array.length() <= MAX_IMPORTED_PACKAGES) { UiException(R.string.error_config_packages) } (0 until array.length()).mapNotNull { index -> array.optString(index).takeIf { it.length <= 256 }?.trim() ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) } @@ -165,7 +169,7 @@ object ConfigTransfer { sshRotationMb = ssh.optInt("rotationMb", 0).coerceIn(0, 10240), failoverMode = enumValue(failover.optString("mode"), FailoverMode.DISABLED), failoverProfileIds = failover.optJSONArray("profileIds")?.let { array -> - require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains too many failover profile IDs" } + requireUi(array.length() <= MAX_IMPORTED_PROFILES) { UiException(R.string.error_config_failover_ids) } (0 until array.length()).mapNotNull { array.optString(it).takeIf { id -> id.isNotBlank() && id.length <= 256 } } @@ -229,11 +233,11 @@ object ConfigTransfer { val dns = item.optJSONObject("dns") ?: JSONObject() val routing = item.optJSONObject("routing") ?: JSONObject() val host = proxy.limitedString("host", 253).trim() - require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) + requireUi(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) val type = enumValue(proxy.optString("type"), ProxyType.HTTPS) val jump = proxy.optJSONObject("jump") val packages = routing.optJSONArray("selectedPackages")?.let { array -> - require(array.length() <= MAX_IMPORTED_PACKAGES) { "A profile contains too many application package names" } + requireUi(array.length() <= MAX_IMPORTED_PACKAGES) { UiException(R.string.error_profile_packages) } (0 until array.length()).mapNotNull { packageIndex -> array.optString(packageIndex).takeIf { it.length <= 256 }?.trim() ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) } @@ -278,7 +282,7 @@ object ConfigTransfer { } private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also { - require(it.length <= maxLength) { "$name is too long" } + requireUi(it.length <= maxLength) { UiException(R.string.error_field_long, name) } } private inline fun > enumValue(value: String, default: T): T = diff --git a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt index 4ed7a03..dcfb16c 100644 --- a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt +++ b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt @@ -1,19 +1,23 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + import net.megaproxy487.model.ProxyConfig import org.json.JSONObject object FoxyProxyParser { fun parse(text: String): Result = runCatching { val root = JSONObject(text) - require(!ConfigTransfer.isSupportedSchema(root.optString("schema"))) { - "This is a MegaProxy configuration, not a FoxyProxy configuration" + requireUi(!ConfigTransfer.isSupportedSchema(root.optString("schema"))) { + UiException(R.string.error_foxy_wrong) } val data = root.optJSONArray("data") - ?: error("The FoxyProxy configuration has no data array") - require(data.length() > 0) { "The FoxyProxy proxy list is empty" } - require(data.length() <= MAX_IMPORTED_PROFILES) { - "The FoxyProxy configuration contains more than $MAX_IMPORTED_PROFILES entries" + ?: throw UiException(R.string.error_foxy_data) + requireUi(data.length() > 0) { UiException(R.string.error_foxy_empty) } + requireUi(data.length() <= MAX_IMPORTED_PROFILES) { + UiException(R.string.error_foxy_many, MAX_IMPORTED_PROFILES) } var skippedNonHttps = 0 @@ -28,9 +32,9 @@ object FoxyProxyParser { parseProxy(item, index + 1)?.let(::add) } } - require(proxies.isNotEmpty()) { - if (skippedNonHttps > 0) "The FoxyProxy configuration contains no HTTPS proxies" - else "The FoxyProxy configuration contains no usable proxies" + requireUi(proxies.isNotEmpty()) { + if (skippedNonHttps > 0) UiException(R.string.error_foxy_no_https) + else UiException(R.string.error_foxy_no_usable) } ProxyListImportResult(proxies, skippedNonHttps) } @@ -39,8 +43,8 @@ object FoxyProxyParser { val host = item.limitedString("hostname", 253).trim().ifEmpty { item.limitedString("address", 253).trim() } - require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) { - "FoxyProxy entry $position has an invalid hostname" + requireUi(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) { + UiException(R.string.error_foxy_host, position) } val port = when (val value = item.opt("port")) { is Number -> value.toInt() @@ -62,6 +66,6 @@ object FoxyProxyParser { } private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also { - require(it.length <= maxLength) { "$name is too long" } + requireUi(it.length <= maxLength) { UiException(R.string.error_field_long, name) } } } diff --git a/app/src/main/java/net/megaproxy487/data/LimitedInput.kt b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt index e279dbf..f211ef8 100644 --- a/app/src/main/java/net/megaproxy487/data/LimitedInput.kt +++ b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt @@ -1,5 +1,9 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + import java.io.ByteArrayOutputStream import java.io.InputStream @@ -15,7 +19,7 @@ fun InputStream.readConfigText(): String { val count = read(buffer) if (count < 0) break total += count - require(total <= MAX_CONFIG_FILE_BYTES) { "Configuration file is larger than 4 MB" } + requireUi(total <= MAX_CONFIG_FILE_BYTES) { UiException(R.string.error_config_large) } output.write(buffer, 0, count) } return output.toString(Charsets.UTF_8.name()) diff --git a/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt index cee2182..87c5346 100644 --- a/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt +++ b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt @@ -1,5 +1,9 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + import java.io.ByteArrayOutputStream import java.io.InputStream @@ -21,21 +25,21 @@ fun InputStream.readPrivateKeyText(): String { val count = read(buffer) if (count < 0) break total += count - require(total <= MAX_PRIVATE_KEY_FILE_BYTES) { "Private key file is larger than 64 KiB" } + requireUi(total <= MAX_PRIVATE_KEY_FILE_BYTES) { UiException(R.string.error_key_large) } output.write(buffer, 0, count) } val text = output.toString(Charsets.UTF_8.name()).removePrefix("\uFEFF").trim() - require(text.isNotEmpty()) { "Private key file is empty" } - require(!text.contains("BEGIN ENCRYPTED PRIVATE KEY") && + requireUi(text.isNotEmpty()) { UiException(R.string.error_key_empty) } + requireUi(!text.contains("BEGIN ENCRYPTED PRIVATE KEY") && !text.contains(Regex("Proc-Type:\\s*4,ENCRYPTED", RegexOption.IGNORE_CASE))) { - "Passphrase-protected private keys are not supported" + UiException(R.string.error_key_encrypted) } val begin = Regex("^-----BEGIN ([A-Z0-9 ]+)-----", RegexOption.MULTILINE).find(text) - ?: throw IllegalArgumentException("File does not contain a PEM or OpenSSH private key") + ?: throw UiException(R.string.error_key_missing) val type = begin.groupValues[1] - require(type in supportedPrivateKeyTypes) { "Unsupported private key format: $type" } - require(text.contains("-----END $type-----")) { "Private key footer is missing" } + requireUi(type in supportedPrivateKeyTypes) { UiException(R.string.error_key_format, type) } + requireUi(text.contains("-----END $type-----")) { UiException(R.string.error_key_footer) } return "$text\n" } diff --git a/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt index aaafa0b..0629f10 100644 --- a/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt +++ b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt @@ -1,5 +1,9 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + import net.megaproxy487.model.ProxyConfig import java.net.URI import java.net.URLDecoder @@ -14,11 +18,11 @@ object ProxyListParser { .filter { it.isNotEmpty() && !it.startsWith("#") } .take(MAX_IMPORTED_PROFILES + 1) .toList() - require(lines.isNotEmpty()) { "The proxy list is empty" } - require(lines.size <= MAX_IMPORTED_PROFILES) { "The proxy list contains more than $MAX_IMPORTED_PROFILES entries" } + requireUi(lines.isNotEmpty()) { UiException(R.string.error_proxy_empty) } + requireUi(lines.size <= MAX_IMPORTED_PROFILES) { UiException(R.string.error_proxy_many, MAX_IMPORTED_PROFILES) } var skippedNonHttps = 0 val proxies = lines.mapIndexedNotNull { index, line -> - val uri = runCatching { URI(line) }.getOrElse { error("Line ${index + 1} is not a valid URI") } + val uri = runCatching { URI(line) }.getOrElse { throw UiException(R.string.error_proxy_uri, index + 1) } if (!uri.scheme.equals("https", ignoreCase = true)) { skippedNonHttps++ null @@ -30,15 +34,15 @@ object ProxyListParser { } private fun parseLine(uri: URI, lineNumber: Int): ImportedProxy { - require(uri.toString().length <= 64 * 1024) { "Line $lineNumber is too long" } - val host = uri.host?.takeIf(String::isNotBlank) ?: error("Line $lineNumber has no proxy host") - val userInfo = uri.rawUserInfo ?: error("Line $lineNumber has no Basic Auth credentials") + requireUi(uri.toString().length <= 64 * 1024) { UiException(R.string.error_proxy_long, lineNumber) } + val host = uri.host?.takeIf(String::isNotBlank) ?: throw UiException(R.string.error_proxy_host, lineNumber) + val userInfo = uri.rawUserInfo ?: throw UiException(R.string.error_proxy_auth, lineNumber) val separator = userInfo.indexOf(':') - require(separator >= 0) { "Line $lineNumber has no Basic Auth password" } + requireUi(separator >= 0) { UiException(R.string.error_proxy_password, lineNumber) } val username = decodeUriComponent(userInfo.substring(0, separator)) val password = decodeUriComponent(userInfo.substring(separator + 1)) - require(username.length <= 4_096) { "Line $lineNumber has an excessively long username" } - require(password.length <= 16_384) { "Line $lineNumber has an excessively long password" } + requireUi(username.length <= 4_096) { UiException(R.string.error_proxy_user_long, lineNumber) } + requireUi(password.length <= 16_384) { UiException(R.string.error_proxy_password_long, lineNumber) } val query = parseQuery(uri.rawQuery) return ImportedProxy( name = query["title"].orEmpty(), @@ -55,7 +59,7 @@ object ProxyListParser { private fun parseQuery(rawQuery: String?): Map = rawQuery.orEmpty() .splitToSequence('&').filter(String::isNotEmpty).take(101).toList().also { - require(it.size <= 100) { "A proxy URL contains too many query parameters" } + requireUi(it.size <= 100) { UiException(R.string.error_proxy_query) } }.associate { pair -> val separator = pair.indexOf('=') if (separator < 0) decode(pair) to "" diff --git a/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt index 8d1a847..6259327 100644 --- a/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt +++ b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt @@ -1,5 +1,9 @@ package net.megaproxy487.data +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi + object SuperProxyParser { const val HEADER = "# superproxy:proxylist:v1" @@ -7,7 +11,7 @@ object SuperProxyParser { text.lineSequence().map(String::trim).firstOrNull(String::isNotEmpty) == HEADER fun parse(text: String): Result = runCatching { - require(matches(text)) { "This is not a supported Super Proxy configuration file" } + requireUi(matches(text)) { UiException(R.string.error_super_invalid) } // Super Proxy's optional `fingerprint` query parameter is a certificate pin. // MegaProxy intentionally relies on Android's trust store and does not import pins. ProxyListParser.parse(text).getOrThrow() diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 2ea41a5..bc7e646 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -1,5 +1,10 @@ package net.megaproxy487.vpn +import net.megaproxy487.R +import net.megaproxy487.titleRes +import net.megaproxy487.uiText +import net.megaproxy487.localizedName + import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -83,7 +88,7 @@ class ProxyVpnService : VpnService() { updatePassiveHealthState() getSystemService(NotificationManager::class.java).notify( NOTIFICATION_ID, - notification(failoverNotice ?: retryStatus ?: VpnRuntimeState.networkWarning.value ?: if (isRunning) "Connected" else "Reconnecting…"), + notification(failoverNotice ?: retryStatus ?: VpnRuntimeState.networkWarning.value ?: if (isRunning) this@ProxyVpnService.uiText(R.string.status_connected) else this@ProxyVpnService.uiText(R.string.status_reconnecting)), ) val retryDue = SystemClock.elapsedRealtime() >= nextStartAttemptAt if (retryDue && tunnel == null && hostKeyPrompt == null && !connectionBlockedForAction && !testRunning.get() && startRunning.compareAndSet(false, true)) { @@ -166,7 +171,7 @@ class ProxyVpnService : VpnService() { probableFailureCounts.clear(); probableFailureTimes.clear(); attemptedFailoverProfiles.clear() VpnRuntimeState.updateNetworkWarning(null) DiagnosticLog.add("event=vpn_reconnect reason=${intent.getStringExtra(EXTRA_RECONNECT_REASON) ?: "settings_changed"}") - startForeground(NOTIFICATION_ID, notification("Reconnecting…")) + startForeground(NOTIFICATION_ID, notification(this@ProxyVpnService.uiText(R.string.status_reconnecting))) stopTunnel(removeForeground = false) val generation = startGeneration.get() ConfigStore(this).setConnectionDesired(true) @@ -196,7 +201,7 @@ class ProxyVpnService : VpnService() { } hostKeyPrompt = null SshHostKeyPromptState.clear() - startForeground(NOTIFICATION_ID, notification("Testing connection…")) + startForeground(NOTIFICATION_ID, notification(this@ProxyVpnService.uiText(R.string.status_testing_connection))) TestDiagnosticLog.begin() thread(name = "megaproxy-connection-test") { try { @@ -210,7 +215,7 @@ class ProxyVpnService : VpnService() { } return START_NOT_STICKY } - startForeground(NOTIFICATION_ID, notification("Connecting…")) + startForeground(NOTIFICATION_ID, notification(this@ProxyVpnService.uiText(R.string.status_connecting_progress))) if (tunnel == null && startRunning.compareAndSet(false, true)) { val generation = startGeneration.get() VpnRuntimeState.update(VpnConnectionState.CONNECTING) @@ -241,7 +246,7 @@ class ProxyVpnService : VpnService() { private fun testConnection() { val storedConfig = ConfigStore(this).globalConnectionSettings().applyTo(ConfigStore(this).activeProfile().config) - storedConfig.connectionValidationError()?.let { getString(it) }?.let { + storedConfig.connectionValidationError()?.let { uiText(it) }?.let { TestDiagnosticLog.fail("Connection test cannot start: $it") if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } return @@ -261,7 +266,7 @@ class ProxyVpnService : VpnService() { } val result = NativeProxyCore(this, TestDiagnosticLog::add).test(config) { message -> configureHostKeyPrompt(message, ConfigStore(this).activeProfileId(), true) - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) } if (result != null) TestDiagnosticLog.succeed(result.exitIp, result.countryCode) else TestDiagnosticLog.fail() if (temporaryVpn) { @@ -287,12 +292,12 @@ class ProxyVpnService : VpnService() { else if (storedConfig.routeAllApps) "event=vpn_start mode=global" else "event=vpn_start mode=split selected_app_count=${storedConfig.selectedPackages.size}" ) - val validationError = if (testOnly) storedConfig.connectionValidationError()?.let { getString(it) } else storedConfig.validationError()?.let { getString(it) } + val validationError = if (testOnly) storedConfig.connectionValidationError()?.let { uiText(it) } else storedConfig.validationError()?.let { uiText(it) } validationError?.let { if (testOnly) TestDiagnosticLog.fail(it) else { val notice = if (isAlwaysOnMode) - "VPN configuration requires attention. Fix it in MegaProxy, then reconnect from Android Always-on VPN settings." - else "VPN configuration requires attention. Open MegaProxy settings." + this@ProxyVpnService.uiText(R.string.vpn_config_always_on) + else this@ProxyVpnService.uiText(R.string.vpn_config_attention) VpnRuntimeState.updateNetworkWarning(notice) if (isAlwaysOnMode) { connectionBlockedForAction = true @@ -350,7 +355,7 @@ class ProxyVpnService : VpnService() { } return proxyCore.resolveProxy(host) { message -> failureDetail = message - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) }?.also { addressCache.put(host, it) } } val proxyIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.HTTPS_JUMP) "" else resolveHost(storedConfig.host, "proxy") ?: run { @@ -376,7 +381,7 @@ class ProxyVpnService : VpnService() { failureDetail = message configureHostKeyPrompt(message, promptProfileId, testOnly) if (!testOnly && "dpi_hint=possible" in message) monitorHandler.post { handleRuntimeDiagnostic(promptProfileId, message) } - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) } if (!started) { establishedTunnel.close() @@ -420,7 +425,7 @@ class ProxyVpnService : VpnService() { TestDiagnosticLog.fail(message) if (hostKeyPrompt != null) { getSystemService(NotificationManager::class.java).notify( - NOTIFICATION_ID, notification("SSH host key approval required"), + NOTIFICATION_ID, notification(this@ProxyVpnService.uiText(R.string.ssh_key_approval)), ) } else { stopForeground(STOP_FOREGROUND_REMOVE) @@ -435,8 +440,8 @@ class ProxyVpnService : VpnService() { } if (signal == null && requiresUserAction(detail)) { val notice = if (isAlwaysOnMode) - "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings, then reconnect Always-on VPN." - else "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings." + this@ProxyVpnService.uiText(R.string.vpn_auth_always_on) + else this@ProxyVpnService.uiText(R.string.vpn_auth_attention) VpnRuntimeState.updateNetworkWarning(notice) if (isAlwaysOnMode) { connectionBlockedForAction = true @@ -453,7 +458,7 @@ class ProxyVpnService : VpnService() { val failureStage = connectionFailureStage(detail) consecutiveStartFailures++ if (!isAlwaysOnMode && consecutiveStartFailures >= MAX_MANUAL_START_FAILURES) { - val notice = "$failureStage Connection stopped after $consecutiveStartFailures failed attempts." + val notice = this@ProxyVpnService.uiText(R.string.vpn_retries_stopped, failureStage, consecutiveStartFailures) DiagnosticLog.add("event=vpn_retry result=exhausted attempts=$consecutiveStartFailures stage=${failureStageToken(detail)}") retryStatus = null ConfigStore(this).setConnectionDesired(false) @@ -466,7 +471,7 @@ class ProxyVpnService : VpnService() { } val delay = retryDelayMs(consecutiveStartFailures) nextStartAttemptAt = SystemClock.elapsedRealtime() + delay - retryStatus = "$failureStage Retrying in ${delay / 1_000}s (attempt ${consecutiveStartFailures + 1})." + retryStatus = this@ProxyVpnService.uiText(R.string.vpn_retry_delay, failureStage, delay / 1_000, consecutiveStartFailures + 1) DiagnosticLog.add("event=vpn_retry result=scheduled attempt=${consecutiveStartFailures + 1} delay_ms=$delay stage=${failureStageToken(detail)}") VpnRuntimeState.update(VpnConnectionState.CONNECTING) getSystemService(NotificationManager::class.java).notify( @@ -487,12 +492,12 @@ class ProxyVpnService : VpnService() { } private fun connectionFailureStage(detail: String): String = when { - "SSH jump handshake" in detail -> "Jump host SSH handshake failed." - "SSH destination handshake" in detail -> "Destination SSH handshake failed." - "jump host could not reach" in detail -> "Jump host could not reach the destination." - "dial jump host" in detail -> "Could not connect to the jump host." - "VPN interface could not be established" in detail -> "Android could not create the VPN interface." - else -> "Connection failed." + "SSH jump handshake" in detail -> this@ProxyVpnService.uiText(R.string.vpn_jump_handshake) + "SSH destination handshake" in detail -> this@ProxyVpnService.uiText(R.string.vpn_destination_handshake) + "jump host could not reach" in detail -> this@ProxyVpnService.uiText(R.string.vpn_jump_unreachable) + "dial jump host" in detail -> this@ProxyVpnService.uiText(R.string.vpn_jump_connect) + "VPN interface could not be established" in detail -> this@ProxyVpnService.uiText(R.string.vpn_interface_failed) + else -> this@ProxyVpnService.uiText(R.string.vpn_connection_failed) } private fun failureStageToken(detail: String): String = when { @@ -516,7 +521,7 @@ class ProxyVpnService : VpnService() { private fun handleProbableBlocking(profileId: String, signal: BlockingSignal) { val store = ConfigStore(this) val settings = store.globalConnectionSettings() - val warning = "Proxy probably blocked (${signal.name.lowercase().replace('_', ' ')})." + val warning = uiText(R.string.vpn_probably_blocked, uiText(signal.titleRes)) VpnRuntimeState.updateNetworkWarning(warning) // DNS failover may move a hostname away from a filtered or stale address. Give // the current profile one fresh resolution attempt before changing exit IP or @@ -528,7 +533,7 @@ class ProxyVpnService : VpnService() { if (profile.type.hasJump) remove(profile.jumpHost) } } - val notice = "$warning Retrying this profile with fresh encrypted DNS before failover." + val notice = this@ProxyVpnService.uiText(R.string.vpn_refresh_dns, warning) VpnRuntimeState.updateNetworkWarning(notice) DiagnosticLog.add("event=blocking_recovery action=refresh_dns profile_type=${store.profile(profileId)?.config?.type?.name?.lowercase() ?: "unknown"}") getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) @@ -536,7 +541,7 @@ class ProxyVpnService : VpnService() { return } if (settings.failoverMode == FailoverMode.DISABLED) { - val notice = "$warning Failover is disabled; the connection may remain unavailable." + val notice = this@ProxyVpnService.uiText(R.string.vpn_failover_disabled, warning) VpnRuntimeState.updateNetworkWarning(notice) getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) return @@ -551,7 +556,7 @@ class ProxyVpnService : VpnService() { else -> emptyList() }.filter { it.id != profileId && it.id !in attemptedFailoverProfiles } val next = candidates.firstOrNull() ?: run { - val notice = "$warning No eligible fallback profile remains." + val notice = this@ProxyVpnService.uiText(R.string.vpn_failover_empty, warning) failoverNotice = notice store.setFailoverState(false, notice) VpnRuntimeState.updateNetworkWarning(notice) @@ -561,7 +566,7 @@ class ProxyVpnService : VpnService() { attemptedFailoverProfiles += next.id resetRetryState() store.setConnectionProfile(next.id) - val notice = "Failover active: switched to ${next.displayName}. Location and exit IP may have changed." + val notice = this@ProxyVpnService.uiText(R.string.vpn_failover_active, next.localizedName(this)) failoverNotice = notice store.setFailoverState(true, notice) VpnRuntimeState.updateNetworkWarning(notice) @@ -620,7 +625,7 @@ class ProxyVpnService : VpnService() { } newConnectionOutcomes && stats.connectionSamples >= 3 && stats.connectionErrorRate >= 0.75 && !healthWarningActive -> { healthWarningActive = true - val warning = "Connection is degraded: most recent proxy connections failed." + val warning = this@ProxyVpnService.uiText(R.string.vpn_degraded) VpnRuntimeState.updateNetworkWarning(warning) DiagnosticLog.add("event=connection_health state=degraded error_rate_percent=${(stats.connectionErrorRate * 100).toInt()} samples=${stats.connectionSamples}") } @@ -657,7 +662,7 @@ class ProxyVpnService : VpnService() { } if (validated == null) { if (captive) { - val warning = "Wi-Fi sign-in is required before the VPN can reconnect." + val warning = this@ProxyVpnService.uiText(R.string.vpn_wifi_login) VpnRuntimeState.updateNetworkWarning(warning) getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(warning)) DiagnosticLog.add("event=network state=captive_portal") @@ -727,7 +732,7 @@ class ProxyVpnService : VpnService() { private fun createChannel() { val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW).apply { - description = "Persistent VPN status" + description = this@ProxyVpnService.uiText(R.string.vpn_channel_description) setShowBadge(false) } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) @@ -763,12 +768,12 @@ class ProxyVpnService : VpnService() { private fun notification(text: String) = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(net.megaproxy487.R.drawable.ic_vpn_notification) - .setContentTitle(getString(net.megaproxy487.R.string.megaproxy_active)) + .setContentTitle(uiText(R.string.megaproxy_active)) .setContentText(text) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) - .also { builder -> hostKeyPrompt?.let { builder.addAction(0, getString(net.megaproxy487.R.string.review_ssh_key), it) } } + .also { builder -> hostKeyPrompt?.let { builder.addAction(0, uiText(R.string.review_ssh_key), it) } } .build() companion object { diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt index 4efbe1d..eae4ba2 100644 --- a/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt +++ b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt @@ -4,9 +4,6 @@ import android.app.Activity import android.content.Intent import android.provider.Settings -const val OTHER_ALWAYS_ON_VPN_MESSAGE = - "Another app is configured as Always-on VPN. Change the Always-on VPN app in Android settings, then try again." - fun openAndroidVpnSettings(activity: Activity) { val vpnSettings = Intent(Settings.ACTION_VPN_SETTINGS) runCatching { activity.startActivity(vpnSettings) } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eb8cec6..7ef5a31 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -285,4 +285,167 @@ %1$d ч %2$d мин %1$d д %2$d ч Удалить профиль? + + Подключено + Подключение + Отключено + Подключение… + Постоянный VPN · Блокировка без VPN + Постоянный VPN + Разрешение на VPN не предоставлено в диалоге Android + Используется постоянный VPN. Выбор профиля меняет профиль постоянного VPN и сразу переподключает его; отключением управляет Android. + Пароль SSH и закрытый ключ не заданы. Подключение возможно, только если сервер разрешает вход без учётных данных. + Принимать любой ключ конечного сервера + Небезопасно: отключает проверку подлинности SSH-сервера. + Использовать те же данные для входа + Использовать имя пользователя, пароль и закрытый ключ конечного сервера. + Пароль и закрытый ключ промежуточного сервера не заданы. Подключение возможно, только если он разрешает вход без учётных данных. + Принимать любой ключ промежуточного сервера + Небезопасно: отключает проверку подлинности промежуточного сервера. + Разрешить подключения к IPv6-адресам + Включайте, только если прокси поддерживает подключения к IPv6-адресам. + Готово к проверке + Проверка + Проверка пройдена + Проверка не пройдена + Проверка… + Если основное соединение неактивно, проверка использует временный VPN. Учётные данные и содержимое трафика не записываются в журнал. + Ожидание первого события диагностики… + Событий проверки пока нет. + Не удалось сохранить ключ SSH-сервера в проверяемый профиль + Автопрокрутка включена + Автопрокрутка приостановлена · прокрутите до конца, чтобы возобновить + · показаны последние 512 KB + Постоянный VPN включён. Изменения маршрутизации применятся при следующем подключении. + Переподключитесь, чтобы применить изменения маршрутизации к активному VPN. + HTTPS + HTTPS через Jump + SSH + SSH через Jump + По умолчанию (сейчас OpenSSH/Termux) + OpenSSH / Termux + ConnectBot + JuiceSSH + Termius Android + Автоматически: ключ, затем пароль + Только пароль + Только закрытый ключ + Отключено + Выбранные профили + Все профили + Cloudflare + Google + Quad9 + Яндекс: базовый + Яндекс: безопасный + Яндекс: семейный + Свой URL DoH + По умолчанию (сейчас Chrome Android) + Chrome Android 133 (uTLS) + Firefox Android 120 (uTLS) + Edge Android (ожидается проверенный отпечаток) + Случайный совместимый + Samsung Internet (ожидается проверенный отпечаток) + Яндекс Браузер (ожидается проверенный отпечаток) + JA3 вручную + Новый профиль + %1$s — копия + В качестве постоянного VPN выбрано другое приложение. Измените приложение постоянного VPN в настройках Android и повторите попытку. + Не удалось импортировать закрытый ключ + Не удалось экспортировать диагностический журнал + Это не файл конфигурации MegaProxy + Неподдерживаемая версия конфигурации MegaProxy: %1$d + В конфигурации нет профилей + Конфигурация содержит более %1$d профилей + В конфигурации версии 7 и новее у каждого профиля должен быть постоянный ID + В конфигурации нет пригодных для использования профилей + В конфигурации есть повторяющиеся ID профилей + В конфигурации слишком много имён пакетов приложений + В конфигурации слишком много ID резервных профилей + В профиле слишком много имён пакетов приложений + Значение поля %1$s слишком длинное + Размер файла закрытого ключа превышает 64 KiB + Файл закрытого ключа пуст + Закрытые ключи с парольной защитой не поддерживаются + В файле нет закрытого ключа PEM или OpenSSH + Неподдерживаемый формат закрытого ключа: %1$s + Отсутствует завершающая строка закрытого ключа + Размер файла конфигурации превышает 4 MB + Список прокси пуст + Список прокси содержит более %1$d записей + Строка %1$d содержит некорректный URI + Строка %1$d слишком длинная + В строке %1$d не указан адрес прокси + В строке %1$d не указаны данные Basic Auth + В строке %1$d не указан пароль Basic Auth + В строке %1$d слишком длинное имя пользователя + В строке %1$d слишком длинный пароль + URL прокси содержит слишком много параметров запроса + Формат конфигурации Super Proxy не поддерживается + Это конфигурация MegaProxy, а не FoxyProxy + В конфигурации FoxyProxy нет массива data + Список прокси FoxyProxy пуст + Конфигурация FoxyProxy содержит более %1$d записей + В конфигурации FoxyProxy нет HTTPS-прокси + В конфигурации FoxyProxy нет пригодных для использования прокси + В записи FoxyProxy %1$d указан некорректный адрес сервера + Некорректные данные конфигурации + Не удалось открыть файл для экспорта + Не удалось прочитать выбранный файл + Не удалось открыть выбранный файл + Переподключение… + Проверка соединения… + Проверьте конфигурацию VPN в MegaProxy, затем переподключитесь в настройках постоянного VPN Android. + Проверьте конфигурацию VPN в настройках MegaProxy. + Требуется подтверждение ключа SSH-сервера + Проверьте данные для входа, сертификат и ключ SSH-сервера, затем переподключите постоянный VPN. + Проверьте данные для входа, сертификат и ключ SSH-сервера. + %1$s Подключение остановлено после %2$d неудачных попыток. + %1$s Повтор через %2$d с (попытка %3$d). + Не удалось установить SSH-соединение с промежуточным сервером. + Не удалось установить SSH-соединение с конечным сервером. + Промежуточный сервер не смог подключиться к конечному серверу. + Не удалось подключиться к промежуточному серверу. + Android не удалось создать VPN-интерфейс. + Не удалось подключиться. + %1$s Перед переключением на резервный профиль повторяем подключение с обновлёнными данными зашифрованного DNS. + %1$s Переключение на резервный профиль отключено; соединение может оставаться недоступным. + %1$s Доступных резервных профилей больше нет. + Используется резервный профиль: %1$s. Страна и выходной IP могли измениться. + Соединение нестабильно: большинство последних подключений к прокси завершились ошибкой. + Для переподключения VPN необходимо войти в сеть Wi-Fi. + Состояние постоянного VPN + Вероятно, прокси заблокирован (%1$s). + Напишите свой отзыв выше этой строки. + Опишите выше этой строки, что произошло перед сбоем. + --- Диагностические сведения --- + Устройство: %1$s %2$s + Android: %1$s (API %2$s) + Обновление безопасности: %1$s + Приложение: %1$s (%2$s) + Соединение: %1$s + Постоянный VPN: %1$s; блокировка без VPN: %2$s + Отпечаток HTTPS: %1$s + Отпечаток SSH: %1$s + Провайдер DoH: %1$s + IPv6: %1$s; обход локальных сетей: %2$s + Проверка сертификата прокси: %1$s + Прикреплены журналы с удалёнными конфиденциальными данными. + Отправить отзыв + Архив диагностических журналов MegaProxy + [MegaProxy] Сбой + [MegaProxy] Отзыв + Маршрутизация: %1$s; выбрано приложений: %2$d + все приложения + раздельная + Истекло время подключения TCP + Истекло время установления TLS-соединения + TLS-соединение сброшено при установлении + Истекло время ожидания ответа CONNECT + Соединение сброшено при ожидании ответа CONNECT + Истекло время установления SSH-соединения + Соединение сброшено + Нет ответа + (промежуточный узел) + (конечный узел) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b73a572..7fd4952 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,4 +280,167 @@ %1$d h %2$d min %1$d d %2$d h Delete profile? + + Connected + Connecting + Disconnected + Connecting… + 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. + 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. + Accept any jump host key + Unsafe: disables jump host identity verification. + Enable IPv6 destinations + Enable only if this proxy can reach IPv6 destinations. + Ready + Testing + Test passed + Test failed + Testing… + The test uses a temporary VPN when the main connection is inactive. Credentials and traffic content are not logged. + Waiting for the first diagnostic event… + No test events yet. + The SSH host key could not be saved to the tested profile + Auto-scroll on + Auto-scroll paused · scroll to the bottom to resume + · showing the latest 512 KB + Always-on is active. Routing changes apply on the next connection. + Reconnect to apply routing changes to the active VPN. + HTTPS + HTTPS with Jump + SSH + SSH with Jump + Default (currently OpenSSH/Termux) + OpenSSH / Termux + ConnectBot + JuiceSSH + Termius Android + Auto: key, then password + Password only + Private key only + Disabled + Selected profiles + All profiles + Cloudflare + Google + Quad9 + Yandex Basic + Yandex Safe + Yandex Family + Custom DoH URL + Default (currently Chrome Android) + Chrome Android 133 (uTLS) + Firefox Android 120 (uTLS) + Edge Android (awaiting a verified profile) + Randomized compatible + Samsung Internet (awaiting a verified profile) + Yandex Browser (awaiting a verified profile) + Manual JA3 + New profile + %1$s copy + Another app is configured as Always-on VPN. Change the Always-on VPN app in Android settings, then try again. + Unable to import the private key + Could not export the diagnostic log + This is not a MegaProxy configuration file + Unsupported MegaProxy configuration version: %1$d + The configuration has no profiles + The configuration contains more than %1$d profiles + Every profile in version 7 or newer must have a stable ID + The configuration contains no usable profiles + The configuration contains duplicate profile IDs + The configuration contains too many application package names + The configuration contains too many failover profile IDs + A profile contains too many application package names + %1$s is too long + Private key file is larger than 64 KiB + Private key file is empty + Passphrase-protected private keys are not supported + File does not contain a PEM or OpenSSH private key + Unsupported private key format: %1$s + Private key footer is missing + Configuration file is larger than 4 MB + The proxy list is empty + The proxy list contains more than %1$d entries + Line %1$d is not a valid URI + Line %1$d is too long + Line %1$d has no proxy host + Line %1$d has no Basic Auth credentials + Line %1$d has no Basic Auth password + Line %1$d has an excessively long username + Line %1$d has an excessively long password + A proxy URL contains too many query parameters + This is not a supported Super Proxy configuration file + This is a MegaProxy configuration, not a FoxyProxy configuration + The FoxyProxy configuration has no data array + The FoxyProxy proxy list is empty + The FoxyProxy configuration contains more than %1$d entries + The FoxyProxy configuration contains no HTTPS proxies + The FoxyProxy configuration contains no usable proxies + FoxyProxy entry %1$d has an invalid hostname + Invalid configuration data + Could not open the export file + Could not read the selected file + Unable to open the selected file + Reconnecting… + Testing connection… + VPN configuration requires attention. Fix it in MegaProxy, then reconnect from Android Always-on VPN settings. + VPN configuration requires attention. Open MegaProxy settings. + SSH host key approval required + VPN connection requires attention. Check authentication, certificate, or SSH host-key settings, then reconnect Always-on VPN. + VPN connection requires attention. Check authentication, certificate, or SSH host-key settings. + %1$s Connection stopped after %2$d failed attempts. + %1$s Retrying in %2$ds (attempt %3$d). + Jump host SSH handshake failed. + Destination SSH handshake failed. + Jump host could not reach the destination. + Could not connect to the jump host. + Android could not create the VPN interface. + Connection failed. + %1$s Retrying this profile with fresh encrypted DNS before failover. + %1$s Failover is disabled; the connection may remain unavailable. + %1$s No eligible fallback profile remains. + Failover active: switched to %1$s. Location and exit IP may have changed. + Connection is degraded: most recent proxy connections failed. + Wi-Fi sign-in is required before the VPN can reconnect. + Persistent VPN status + Proxy probably blocked (%1$s). + Please write your comments above this line. + Please describe what happened before the crash above this line. + --- Diagnostic information --- + Device: %1$s %2$s + Android: %1$s (API %2$s) + Security patch: %1$s + App: %1$s (%2$s) + Connection: %1$s + Always-on: %1$s; lockdown: %2$s + HTTPS fingerprint: %1$s + SSH fingerprint: %1$s + DoH provider: %1$s + IPv6: %1$s; bypass local networks: %2$s + Proxy certificate verification: %1$s + Privacy-filtered logs are attached. + Send feedback + MegaProxy diagnostic log archive + [MegaProxy] Crash + [MegaProxy] Feedback + Routing: %1$s; selected app count: %2$d + global + split + TCP connection timed out + TLS handshake timed out + TLS handshake reset + CONNECT response timed out + CONNECT response reset + SSH handshake timed out + Connection reset + No response + (jump host) + (destination host) diff --git a/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt b/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt index 1230231..b46b989 100644 --- a/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt +++ b/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt @@ -26,6 +26,18 @@ class LocalizedResourcesTest { assertTrue(russian.getValue("traffic_units_si").contains("KB, MB, GB")) } + @Test + fun translationsPreserveFormatArguments() { + val english = stringsIn(resourceFile("values/strings.xml")) + val russian = stringsIn(resourceFile("values-ru/strings.xml")) + val placeholder = Regex("%[0-9]+\\$[a-zA-Z]") + english.forEach { (key, text) -> + assertEquals(key, + placeholder.findAll(text).map { it.value }.sorted().toList(), + placeholder.findAll(russian.getValue(key)).map { it.value }.sorted().toList()) + } + } + private fun resourceFile(relativePath: String): File = sequenceOf(File("src/main/res", relativePath), File("app/src/main/res", relativePath)) .firstOrNull(File::isFile) diff --git a/app/src/test/java/net/megaproxy487/UiFormattingTest.kt b/app/src/test/java/net/megaproxy487/UiFormattingTest.kt new file mode 100644 index 0000000..1a10ca9 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/UiFormattingTest.kt @@ -0,0 +1,59 @@ +package net.megaproxy487 + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UiFormattingTest { + @Test + fun translatedWordsKeepSystemNumberAndDateFormatting() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.US) + assertEquals("Значение: 1,234.5", formatUiText("Значение: %,.1f", 1234.5)) + Locale.setDefault(Locale.GERMANY) + assertEquals("Value: 1.234,5", formatUiText("Value: %,.1f", 1234.5)) + val date = java.time.LocalDate.of(2026, 3, 7) + assertEquals("Дата: März", formatUiText("Дата: %tB", date)) + assertEquals(Locale.GERMANY, Locale.getDefault()) + } finally { + Locale.setDefault(original) + } + } + + @Test + fun connectionDateUsesSystemLocale() { + val originalLocale = Locale.getDefault() + val originalZone = java.util.TimeZone.getDefault() + try { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")) + val timestamp = java.time.Instant.parse("2026-03-07T12:00:00Z").toEpochMilli() + Locale.setDefault(Locale.US) + assertTrue(formatConnectionStartedAt(timestamp).contains("3/7/26")) + Locale.setDefault(Locale.GERMANY) + assertTrue(formatConnectionStartedAt(timestamp).contains("07.03.26")) + } finally { + Locale.setDefault(originalLocale) + java.util.TimeZone.setDefault(originalZone) + } + } + + @Test + fun explicitSystemLocaleWinsOverAppProcessLocale() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("ru")) + assertEquals("Значение: 1,234.5", + formatUiText("Значение: %,.1f", 1234.5, locale = Locale.US)) + assertEquals("ru", Locale.getDefault().language) + } finally { + Locale.setDefault(original) + } + } + + @Test + fun unformattedTextMayContainLiteralPercent() { + assertEquals("100%", formatUiText("100%")) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt index d4ad2cb..bfa4227 100644 --- a/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt +++ b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt @@ -1,6 +1,8 @@ package net.megaproxy487.data import java.io.ByteArrayInputStream +import net.megaproxy487.UiException +import net.megaproxy487.R import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test @@ -21,9 +23,10 @@ class PrivateKeyImportTest { fun rejectsPublicKey() { val input = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA example" - assertThrows(IllegalArgumentException::class.java) { + val failure = assertThrows(UiException::class.java) { ByteArrayInputStream(input.toByteArray()).readPrivateKeyText() } + assertEquals(R.string.error_key_missing, failure.textId) } @Test From 04ffee5034b5e3ef5cbab2614fc63b2aab2e3455 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 16:53:56 +0300 Subject: [PATCH 3/6] Move version footer from settings to the main screen --- app/src/main/java/net/megaproxy487/MainActivity.kt | 6 ++++++ app/src/main/java/net/megaproxy487/SettingsScreens.kt | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 04f4366..497efb2 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -627,6 +627,12 @@ internal fun MainScreen( modifier = Modifier.padding(top = 12.dp), ) } + Text( + stringResource(R.string.version_and_commit, BuildConfig.VERSION_NAME, BuildConfig.GIT_COMMIT_HASH), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), + ) } } } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 29a7202..1d0ad2e 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -167,12 +167,6 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga }.onFailure { supportError = activity.uiText(R.string.no_browser) } } supportError?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Text( - stringResource(R.string.version_and_commit, BuildConfig.VERSION_NAME, BuildConfig.GIT_COMMIT_HASH), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), - ) } if (showLanguageDialog) { From 398b972a7c18d3f507263c6a564bbbc3ce761bf3 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 17:42:07 +0300 Subject: [PATCH 4/6] Fix editing, saving and transfer lifecycle UX --- AGENTS.md | 4 + .../net/megaproxy487/ConnectionTestScreen.kt | 7 +- .../net/megaproxy487/DiagnosticLogScreen.kt | 30 +-- .../java/net/megaproxy487/EditingContracts.kt | 17 ++ .../main/java/net/megaproxy487/EditingUi.kt | 62 +++++ .../java/net/megaproxy487/MainActivity.kt | 20 +- .../net/megaproxy487/ProfileEditorScreen.kt | 211 ++++++++++------- .../megaproxy487/ProfileTransferContracts.kt | 29 +++ .../java/net/megaproxy487/ProfilesScreen.kt | 223 +++++++++++------- .../java/net/megaproxy487/SettingsScreens.kt | 49 ++-- .../net/megaproxy487/SplitTunnelScreen.kt | 10 +- .../java/net/megaproxy487/data/ConfigStore.kt | 38 ++- .../net/megaproxy487/data/ConfigWrites.kt | 51 ++++ app/src/main/res/values-ru/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + .../net/megaproxy487/EditingContractsTest.kt | 37 +++ .../net/megaproxy487/data/ConfigWritesTest.kt | 84 +++++++ 17 files changed, 650 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/EditingContracts.kt create mode 100644 app/src/main/java/net/megaproxy487/EditingUi.kt create mode 100644 app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt create mode 100644 app/src/main/java/net/megaproxy487/data/ConfigWrites.kt create mode 100644 app/src/test/java/net/megaproxy487/EditingContractsTest.kt create mode 100644 app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt diff --git a/AGENTS.md b/AGENTS.md index d38fe98..cb6deaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,10 @@ branch names, credentials, signing material, or other secrets. 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, diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index b92f0c5..71069a5 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -42,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 @@ -110,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() } diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index 8c57bb1..6595487 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -42,6 +42,8 @@ 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 net.megaproxy487.data.ConfigWrites import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -74,7 +76,7 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { val scope = rememberCoroutineScope() var lines by remember { mutableStateOf(emptyList()) } var autoScroll by remember { mutableStateOf(true) } - var limitText by remember { mutableStateOf(store.diagnosticLogLimitMb().toString()) } + var limitText by rememberSaveable { mutableStateOf(store.diagnosticLogLimitMb().toString()) } var showClearConfirmation by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } val isAtBottom by remember { @@ -138,24 +140,16 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { Modifier.fillMaxSize().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = limitText, - onValueChange = { value -> - if (value.all(Char::isDigit) && value.length <= 3) { - limitText = value - value.toIntOrNull()?.takeIf { - it in PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB - }?.let { - store.setDiagnosticLogLimitMb(it) - PersistentDiagnosticLog.setLimitMb(it) - } + SaveStatusBanner() + IntegerInputField(limitText, { limitText = it }, + PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB, + stringResource(R.string.rotated_log_limit), onValidValue = { limit -> + ConfigWrites.submit("diagnostic-limit") { + store.setDiagnosticLogLimitMb(limit) + PersistentDiagnosticLog.setLimitMb(limit) } - }, - label = { FieldLabel(stringResource(R.string.rotated_log_limit)) }, - supportingText = { Text(stringResource(R.string.rotated_log_limit_description)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + }, modifier = Modifier.fillMaxWidth()) + Text(stringResource(R.string.rotated_log_limit_description), style = MaterialTheme.typography.bodySmall) WrappingActions(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(shape = RoundedCornerShape(12.dp), onClick = { exportDocument.launch("MegaProxy-diagnostic.log") }) { Text(stringResource(R.string.export_action)) diff --git a/app/src/main/java/net/megaproxy487/EditingContracts.kt b/app/src/main/java/net/megaproxy487/EditingContracts.kt new file mode 100644 index 0000000..7fa38c7 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/EditingContracts.kt @@ -0,0 +1,17 @@ +package net.megaproxy487 + +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyType + +internal fun ProxyConfig.withType(selected: ProxyType): ProxyConfig { + if (selected == type) return this + return copy( + type = selected, + port = if (port == type.defaultPort) selected.defaultPort else port, + jumpPort = if (jumpPort == type.defaultPort) selected.defaultPort else jumpPort, + ) +} + +internal fun validIntegerInput(text: String, range: IntRange): Int? = + text.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } } + ?.toIntOrNull()?.takeIf { it in range } diff --git a/app/src/main/java/net/megaproxy487/EditingUi.kt b/app/src/main/java/net/megaproxy487/EditingUi.kt new file mode 100644 index 0000000..e6eba9e --- /dev/null +++ b/app/src/main/java/net/megaproxy487/EditingUi.kt @@ -0,0 +1,62 @@ +package net.megaproxy487 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import net.megaproxy487.data.ConfigWrites + +@Composable +internal fun SaveStatusBanner() { + val status by ConfigWrites.status.collectAsState() + if (status.pending > 0 || status.failed) { + Column(Modifier.fillMaxWidth().padding(8.dp)) { + Text(uiStringResource(if (status.failed) R.string.save_failed else R.string.saving_changes), + style = MaterialTheme.typography.bodySmall, + color = if (status.failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + if (status.failed) TextButton(shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), onClick = { ConfigWrites.retry() }, enabled = status.pending == 0) { + Text(uiStringResource(R.string.retry_save)) + } + } + } +} + +@Composable +internal fun PasswordField(value: String, onValueChange: (String) -> Unit, label: String, + error: String? = null, modifier: Modifier = Modifier) { + // Never persist either the secret or its visible state in an Android saved-state bundle. + var visible by remember { mutableStateOf(false) } + OutlinedTextField(value, onValueChange, label = { FieldLabel(label) }, singleLine = true, + modifier = modifier, isError = error != null, + supportingText = error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { IconButton(onClick = { visible = !visible }) { + Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + uiStringResource(if (visible) R.string.hide_password else R.string.show_password)) + } }) +} + +@Composable +internal fun IntegerInputField(text: String, onTextChange: (String) -> Unit, range: IntRange, + label: String, onValidValue: (Int) -> Unit, modifier: Modifier = Modifier) { + val valid = validIntegerInput(text, range) != null + OutlinedTextField(text, { value -> + onTextChange(value) + validIntegerInput(value, range)?.let(onValidValue) + }, label = { FieldLabel(label) }, singleLine = true, modifier = modifier, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = !valid, + supportingText = if (!valid) { { Text(uiStringResource(R.string.integer_not_saved, range.first, range.last)) } } else null) +} diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 497efb2..d8b22cc 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -52,6 +52,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import net.megaproxy487.data.ConfigWrites import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -250,6 +252,7 @@ internal fun MainScreen( val transportProtocol by VpnRuntimeState.transportProtocol val pendingHostKey by SshHostKeyPromptState.pending val store = remember { ConfigStore(activity) } + val writeStatus by ConfigWrites.status.collectAsState() var error by remember { mutableStateOf(null) } var profileMenuExpanded by remember { mutableStateOf(false) } var profiles by remember { mutableStateOf(store.sortedProfiles()) } @@ -293,8 +296,15 @@ internal fun MainScreen( lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - LaunchedEffect(connection) { - pendingReconnect = store.hasPendingReconnect() + LaunchedEffect(connection, writeStatus) { + if (writeStatus.pending == 0) { + val saved = withContext(ConfigIoDispatcher) { + Triple(store.sortedProfiles(), store.globalConnectionSettings(), store.hasPendingReconnect()) + } + profiles = saved.first + globalSettings = saved.second + pendingReconnect = saved.third + } } LaunchedEffect(lifecycleOwner, connection) { if (connection != VpnConnectionState.CONNECTED) { @@ -389,6 +399,7 @@ internal fun MainScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { + SaveStatusBanner() val connected = connection == VpnConnectionState.CONNECTED val statusLabel = when (connection) { VpnConnectionState.CONNECTED -> activity.uiText(R.string.status_connected) @@ -524,7 +535,7 @@ internal fun MainScreen( if (useAsAlwaysOn || connection != VpnConnectionState.DISCONNECTED) { ProxyVpnService.switchProfile(activity, profile.id, useAsAlwaysOn) } else { - scope.launch(ConfigIoDispatcher) { store.setActiveProfile(profile.id) } + ConfigWrites.submit("active-profile") { store.setActiveProfile(profile.id) } } profileMenuExpanded = false }, @@ -558,7 +569,7 @@ internal fun MainScreen( connect() } }, - enabled = !alwaysOn && + enabled = !alwaysOn && writeStatus.pending == 0 && !writeStatus.failed && (connection != VpnConnectionState.DISCONNECTED || activeProfileError == null), modifier = Modifier.fillMaxWidth(), ) { @@ -573,6 +584,7 @@ internal fun MainScreen( if (connected) { FilledTonalButton(shape = RoundedCornerShape(12.dp), onClick = { ProxyVpnService.reconnect(activity) }, + enabled = writeStatus.pending == 0 && !writeStatus.failed, modifier = Modifier.fillMaxWidth(), ) { WrappingActions(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)) { diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index fa98326..3fb7920 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -72,57 +73,91 @@ import net.megaproxy487.vpn.ProxyVpnService import net.megaproxy487.vpn.readAlwaysOnVpnStatus import net.megaproxy487.ui.theme.MegaProxyTheme import java.util.Locale +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.runtime.saveable.rememberSaveable +import net.megaproxy487.data.ConfigWrites +import androidx.compose.runtime.collectAsState +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.imePadding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private class ProfileEditorState(initialProfile: net.megaproxy487.model.ProxyProfile) : ViewModel() { + var profile by mutableStateOf(initialProfile) + var config by mutableStateOf(profile.config) + var portText by mutableStateOf(config.port.toString()) + var jumpPortText by mutableStateOf(config.jumpPort.toString()) + var error by mutableStateOf(null) + var importingKey by mutableStateOf(false) + var countryExpanded by mutableStateOf(false) + var dnsExpanded by mutableStateOf(false) + var invalidCertificateIsJump by mutableStateOf(false) + var showInvalidCertificateWarning by mutableStateOf(false) + var typeExpanded by mutableStateOf(false) + var unsafeHostKeyHop by mutableStateOf(null) + var showReconnectPrompt by mutableStateOf(false) + var showAlwaysOnNotice by mutableStateOf(false) + var connectionChangeDeferred by mutableStateOf(false) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } - val coroutineScope = rememberCoroutineScope() DisposableEffect(activity) { activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } - var profile by remember { mutableStateOf(store.profile(profileId.orEmpty()) ?: store.activeProfile()) } - var config by remember { mutableStateOf(profile.config) } - var portText by remember { mutableStateOf(config.port.toString()) } - var jumpPortText by remember { mutableStateOf(config.jumpPort.toString()) } - var error by remember { mutableStateOf(null) } - var countryExpanded by remember { mutableStateOf(false) } - var dnsExpanded by remember { mutableStateOf(false) } - var invalidCertificateIsJump by remember { mutableStateOf(false) } - var showInvalidCertificateWarning by remember { mutableStateOf(false) } - var typeExpanded by remember { mutableStateOf(false) } - var unsafeHostKeyHop by remember { mutableStateOf(null) } - var showReconnectPrompt by remember { mutableStateOf(false) } - var showAlwaysOnNotice by remember { mutableStateOf(false) } - var connectionChangeDeferred by remember { mutableStateOf(false) } + val draftId = rememberSaveable { java.util.UUID.randomUUID().toString() } + val isNew = profileId == "new" + val editorState = viewModel { ProfileEditorState( + if (isNew) store.profile(draftId) ?: store.newProfileDraft(draftId) + else store.profile(profileId.orEmpty()) ?: store.activeProfile(), + ) } + val coroutineScope = editorState.viewModelScope + var profile by editorState::profile + var config by editorState::config + var portText by editorState::portText + var jumpPortText by editorState::jumpPortText + var error by editorState::error + var importingKey by editorState::importingKey + var countryExpanded by editorState::countryExpanded + var dnsExpanded by editorState::dnsExpanded + var invalidCertificateIsJump by editorState::invalidCertificateIsJump + var showInvalidCertificateWarning by editorState::showInvalidCertificateWarning + var typeExpanded by editorState::typeExpanded + var unsafeHostKeyHop by editorState::unsafeHostKeyHop + var showReconnectPrompt by editorState::showReconnectPrompt + var showAlwaysOnNotice by editorState::showAlwaysOnNotice + var connectionChangeDeferred by editorState::connectionChangeDeferred val countries = remember { Locale.getISOCountries().map { code -> code to Locale.Builder().setRegion(code).build().getDisplayCountry(systemFormattingLocale()) }.sortedBy { it.second.lowercase(systemFormattingLocale()) } } - val globalSettings = remember { store.globalConnectionSettings() } - val editedConnectionProfileId = remember { store.connectionProfile().id } - val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } - fun saveProfile() { + val globalSettings = store.globalConnectionSettings() + val editedConnectionProfileId = store.connectionProfile().id + val alwaysOnActive = ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled + fun saveProfile(affectsConnection: Boolean = false) { val snapshot = profile - coroutineScope.launch(ConfigIoDispatcher) { store.saveProfile(snapshot) } + ConfigWrites.submit("profile:${snapshot.id}") { + store.saveProfile(snapshot, createIfMissing = isNew) + if (affectsConnection && ProxyVpnService.isRunning && snapshot.id == store.connectionProfile().id) store.markPendingReconnect() + } } fun acceptText(value: String, maxLength: Int, update: (String) -> Unit) { if (value.length <= maxLength) update(value) } fun updateConfig(updated: ProxyConfig) { + if (updated == config) return config = updated profile = profile.copy(config = updated) - saveProfile() + saveProfile(affectsConnection = true) error = globalSettings.applyTo(updated).connectionValidationError()?.let { activity.uiText(it) } - if (ProxyVpnService.isRunning && profile.id == editedConnectionProfileId) { - coroutineScope.launch(ConfigIoDispatcher) { store.markPendingReconnect() } - } if (ProxyVpnService.isRunning && !connectionChangeDeferred && profile.id == editedConnectionProfileId) { if (alwaysOnActive) { connectionChangeDeferred = true @@ -133,7 +168,10 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } fun importPrivateKey(uri: Uri, jump: Boolean) { + if (importingKey) return + importingKey = true coroutineScope.launch { + try { val result = runCatching { withContext(Dispatchers.IO) { activity.contentResolver.openInputStream(uri)?.use { it.readPrivateKeyText() } @@ -146,6 +184,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: }.onFailure { failure -> error = failure.userMessage(activity, R.string.key_import_failed) } + } finally { importingKey = false } } } val destinationKeyPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -155,6 +194,13 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: uri?.let { importPrivateKey(it, jump = true) } } + val validationRes = globalSettings.applyTo(config).connectionValidationError() + fun fieldError(vararg ids: Int): String? = validationRes?.takeIf { it in ids }?.let { activity.uiText(it) } + val validPorts = validIntegerInput(portText, 1..65535) != null && + (!config.type.hasJump || validIntegerInput(jumpPortText, 1..65535) != null) + val writeStatus by ConfigWrites.status.collectAsState() + val canReconnect = validationRes == null && validPorts && writeStatus.pending == 0 && !writeStatus.failed + Scaffold( topBar = { TopAppBar( @@ -166,6 +212,34 @@ 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)) + } + if (!showAlwaysOnNotice) TextButton(shape = RoundedCornerShape(12.dp), enabled = canReconnect, onClick = { + showReconnectPrompt = false; connectionChangeDeferred = true + ProxyVpnService.reconnect(activity) + }) { Text(stringResource(R.string.reconnect_now)) } + } + } + } + } + }, contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> LazyColumn( @@ -220,8 +294,8 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: 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.copy(type = type, port = type.defaultPort, jumpPort = if (type.hasJump && (!config.type.hasJump || type.isHttps != config.type.isHttps)) type.defaultPort else config.jumpPort)) - portText = type.defaultPort.toString() + updateConfig(config.withType(type)) + portText = config.port.toString() if (type.hasJump) jumpPortText = config.jumpPort.toString() typeExpanded = false }) } @@ -229,21 +303,18 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } 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.isHttps) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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.isHttps) 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 { - OutlinedTextField(portText, { value -> - portText = value - val port = value.toIntOrNull() - if (port == null) error = activity.uiText(R.string.validation_port) - else updateConfig(config.copy(port = port)) - }, label = { FieldLabel(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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.isHttps) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type.isHttps) R.string.basic_auth_username else R.string.ssh_username)) }, isError = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username) != null, supportingText = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { FieldLabel(stringResource(if (config.type.isHttps) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + PasswordField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = stringResource(if (config.type.isHttps) R.string.password else R.string.ssh_password_optional), + error = fieldError(R.string.validation_basic_password), modifier = Modifier.fillMaxWidth()) } if (config.type.isHttps) item { SettingCheckboxRow( checked = config.allowInvalidProxyCertificate, @@ -262,7 +333,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { OutlinedButton(shape = RoundedCornerShape(12.dp), - onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, + enabled = !importingKey, onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.import_destination_key)) } } @@ -288,15 +359,11 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: 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)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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 { - OutlinedTextField(jumpPortText, { value -> - if (value.length <= 5 && value.all(Char::isDigit)) { - jumpPortText = value - updateConfig(config.copy(jumpPort = value.toIntOrNull() ?: 0)) - } - }, label = { FieldLabel(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), + onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) } item { SettingCheckboxRow(config.sameJumpAuthentication, stringResource(R.string.https_jump_same_auth), stringResource(R.string.https_jump_same_auth_description)) { @@ -305,10 +372,11 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } 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)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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 { - OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_password)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = stringResource(R.string.https_jump_password), + error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) } } item { @@ -327,15 +395,11 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: 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)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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 { - OutlinedTextField(jumpPortText, { value -> - if (value.length <= 5 && value.all(Char::isDigit)) { - jumpPortText = value - value.toIntOrNull()?.let { updateConfig(config.copy(jumpPort = it)) } - } - }, label = { FieldLabel(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), + onValidValue = { updateConfig(config.copy(jumpPort = it)) }, modifier = Modifier.fillMaxWidth()) } item { SettingCheckboxRow(config.sameJumpAuthentication, activity.uiText(R.string.ssh_same_auth), activity.uiText(R.string.ssh_same_auth_help)) { @@ -344,17 +408,18 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } 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)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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 { - OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, 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)) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { OutlinedButton(shape = RoundedCornerShape(12.dp), - onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, + enabled = !importingKey, onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.import_jump_key)) } } @@ -393,40 +458,10 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } } - if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } - error?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } - if (showReconnectPrompt) { - item { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { - Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text(stringResource(R.string.reconnect_apply_changes), style = MaterialTheme.typography.bodyMedium) - WrappingActions() { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { - showReconnectPrompt = false - connectionChangeDeferred = true - }) { Text(stringResource(R.string.next_connection)) } - TextButton(shape = RoundedCornerShape(12.dp), onClick = { - showReconnectPrompt = false - connectionChangeDeferred = true - ProxyVpnService.reconnect(activity) - }) { Text(stringResource(R.string.reconnect_now)) } - } - } - } - } - } - if (showAlwaysOnNotice) { - item { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { - Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text(stringResource(R.string.always_on_changes_next_connection)) - TextButton(shape = RoundedCornerShape(12.dp), onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } - } - } - } - } + if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, isError = fieldError(R.string.validation_doh_url) != null, supportingText = fieldError(R.string.validation_doh_url)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) } + item { - Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) + Text(stringResource(if (isNew && store.profile(profile.id) == null) R.string.draft_profile_hint else R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt b/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt new file mode 100644 index 0000000..d816822 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt @@ -0,0 +1,29 @@ +package net.megaproxy487 + +import net.megaproxy487.data.* + +internal fun requireExportContent(content: String?): String { + if (content == null) throw UiException(R.string.export_expired) + if (content.isEmpty()) throw UiException(R.string.export_empty) + return content +} + +internal sealed interface ParsedProfileImport { + data class Configuration(val value: PortableConfiguration) : ParsedProfileImport + data class ProxyList(val value: ProxyListImportResult, val summaryRes: Int) : ParsedProfileImport +} + +/** Read/parse off the main thread before presenting review dialogs or committing changes. */ +internal fun parseProfileImport(text: String, mimeType: String?, fileName: String): ParsedProfileImport { + val json = mimeType == "application/json" || fileName.substringAfterLast('.', "").equals("json", true) || + text.trimStart().startsWith('{') + if (json) { + val root = org.json.JSONObject(text) + return if (ConfigTransfer.isSupportedSchema(root.optString("schema"))) { + ParsedProfileImport.Configuration(ConfigTransfer.importJson(text)) + } else ParsedProfileImport.ProxyList(FoxyProxyParser.parse(text).getOrThrow(), R.string.imported_foxyproxy) + } + return if (SuperProxyParser.matches(text)) { + ParsedProfileImport.ProxyList(SuperProxyParser.parse(text).getOrThrow(), R.string.imported_super_proxy) + } else ParsedProfileImport.ProxyList(ProxyListParser.parse(text).getOrThrow(), R.string.imported_https_profiles) +} diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 48e0cd6..fca110c 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -98,6 +98,13 @@ import net.megaproxy487.model.ProxyType import net.megaproxy487.vpn.PersistentDiagnosticLog import net.megaproxy487.vpn.ProxyVpnService import net.megaproxy487.ui.theme.MegaProxyTheme +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import net.megaproxy487.data.ConfigWrites +import androidx.compose.material3.LinearProgressIndicator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -200,36 +207,76 @@ private fun applySelectedProfileOptions( return result } +private class ProfilesUiState(private val context: android.content.Context) : ViewModel() { + val profiles = mutableStateListOf() + var deleteProfile by mutableStateOf(null) + var importedProfileCount by mutableStateOf(0) + var importError by mutableStateOf(null) + var skippedNonHttps by mutableStateOf(0) + var showImportFilterNotice by mutableStateOf(false) + var showExportDialog by mutableStateOf(false) + var showPasswordExportWarning by mutableStateOf(false) + var exportFormat by mutableStateOf(ConfigExportFormat.JSON) + var includePasswords by mutableStateOf(false) + var includePrivateKeys by mutableStateOf(false) + var pendingExportContent by mutableStateOf(null) + var exportReady by mutableStateOf(false) + var transferMessage by mutableStateOf(null) + var pendingUnsafeImport by mutableStateOf(null) + var missingProfilesReview by mutableStateOf(null) + val selectedMissingProfileIds = mutableStateListOf() + var importOptionsReview by mutableStateOf(null) + val selectedImportOptionKeys = mutableStateListOf() + var applyImportedGlobalOptions by mutableStateOf(false) + private var pendingOperations by mutableStateOf(0) + val busy: Boolean get() = pendingOperations > 0 + + fun launchOperation(block: suspend CoroutineScope.() -> Unit) { + pendingOperations++ + viewModelScope.launch { + try { block() } + catch (cancelled: CancellationException) { throw cancelled } + catch (failure: Exception) { + pendingExportContent = null + exportReady = false + importError = failure.userMessage(context, R.string.transfer_failed) + } finally { pendingOperations-- } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfile: (String) -> Unit) { val store = remember { ConfigStore(activity) } - val scope = rememberCoroutineScope() - val profiles = remember { mutableStateListOf() } - var deleteProfile by remember { mutableStateOf(null) } - var importedProfileCount by remember { mutableStateOf(0) } - var importError by remember { mutableStateOf(null) } - var skippedNonHttps by remember { mutableStateOf(0) } - var showImportFilterNotice by remember { mutableStateOf(false) } - var showExportDialog by remember { mutableStateOf(false) } - var showPasswordExportWarning by remember { mutableStateOf(false) } - var exportFormat by remember { mutableStateOf(ConfigExportFormat.JSON) } - var includePasswords by remember { mutableStateOf(false) } - var includePrivateKeys by remember { mutableStateOf(false) } - var pendingExportContent by remember { mutableStateOf("") } - var transferMessage by remember { mutableStateOf(null) } - var pendingUnsafeImport by remember { mutableStateOf(null) } - var missingProfilesReview by remember { mutableStateOf(null) } - val selectedMissingProfileIds = remember { mutableStateListOf() } - var importOptionsReview by remember { mutableStateOf(null) } - val selectedImportOptionKeys = remember { mutableStateListOf() } - var applyImportedGlobalOptions by remember { mutableStateOf(false) } + val uiState = viewModel { ProfilesUiState(activity.applicationContext) } + val busy = uiState.busy + val profiles = uiState.profiles + var deleteProfile by uiState::deleteProfile + var importedProfileCount by uiState::importedProfileCount + var importError by uiState::importError + var skippedNonHttps by uiState::skippedNonHttps + var showImportFilterNotice by uiState::showImportFilterNotice + var showExportDialog by uiState::showExportDialog + var showPasswordExportWarning by uiState::showPasswordExportWarning + var exportFormat by uiState::exportFormat + var includePasswords by uiState::includePasswords + var includePrivateKeys by uiState::includePrivateKeys + var pendingExportContent by uiState::pendingExportContent + var exportReady by uiState::exportReady + var transferMessage by uiState::transferMessage + var pendingUnsafeImport by uiState::pendingUnsafeImport + var missingProfilesReview by uiState::missingProfilesReview + val selectedMissingProfileIds = uiState.selectedMissingProfileIds + var importOptionsReview by uiState::importOptionsReview + val selectedImportOptionKeys = uiState.selectedImportOptionKeys + var applyImportedGlobalOptions by uiState::applyImportedGlobalOptions val lifecycleOwner = LocalLifecycleOwner.current val listState = rememberLazyListState() var draggedProfileId by remember { mutableStateOf(null) } fun refresh() { - scope.launch { + uiState.launchOperation { val loaded = withContext(ConfigIoDispatcher) { store.sortedProfiles() } profiles.clear() profiles.addAll(loaded) @@ -237,21 +284,24 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } LaunchedEffect(Unit) { refresh() } fun moveProfile(profileId: String, delta: Int): Boolean { + if (uiState.busy) return false val sourceIndex = profiles.indexOfFirst { it.id == profileId } val targetIndex = sourceIndex + delta if (sourceIndex < 0 || targetIndex !in profiles.indices) return false profiles.add(targetIndex, profiles.removeAt(sourceIndex)) val order = profiles.map(ProxyProfile::id) - scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + ConfigWrites.submit("profile-order") { store.reorderProfiles(order) } return true } fun edit(profile: ProxyProfile) { onEditProfile(profile.id) } fun writeExport(uri: Uri?) { + val prepared = pendingExportContent + pendingExportContent = null if (uri == null) return - val content = pendingExportContent - scope.launch { + uiState.launchOperation { + val content = requireExportContent(prepared) val result = withContext(Dispatchers.IO) { runCatching { activity.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { it.write(content) } @@ -268,7 +318,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } } fun applyJsonImport(configuration: PortableConfiguration) { - scope.launch { + uiState.launchOperation { val result = withContext(ConfigIoDispatcher) { store.importConfiguration(configuration).also { if (ProxyVpnService.isRunning) store.markPendingReconnect() @@ -297,7 +347,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } } fun prepareJsonImport(configuration: PortableConfiguration) { - scope.launch { + uiState.launchOperation { val review = withContext(ConfigIoDispatcher) { val existing = store.profiles().associateBy(ProxyProfile::id) val profileReviews = configuration.profiles.mapNotNull { imported -> @@ -340,73 +390,50 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil val exportTxtDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain"), ::writeExport) val exportJsonDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json"), ::writeExport) fun launchExport() { - scope.launch { + if (uiState.busy || pendingExportContent != null) return + uiState.launchOperation { pendingExportContent = withContext(ConfigIoDispatcher) { when (exportFormat) { ConfigExportFormat.PROXY_LIST -> ConfigTransfer.exportProxyList(store.profiles(), includePasswords) ConfigExportFormat.JSON -> ConfigTransfer.exportJson(store, includePasswords, includePrivateKeys) } } + requireExportContent(pendingExportContent) + exportReady = true + } + } + LaunchedEffect(exportReady) { + if (exportReady) { + exportReady = false if (exportFormat == ConfigExportFormat.PROXY_LIST) exportTxtDocument.launch("ProxyList.txt") else exportJsonDocument.launch("MegaProxy-config.json") } } val importDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - runCatching { - val text = activity.contentResolver.openInputStream(uri)?.buffered()?.use { it.readConfigText() } - ?: throw UiException(R.string.error_read_file) - val isJson = activity.contentResolver.getType(uri) == "application/json" || - uri.lastPathSegment.orEmpty().substringAfterLast('.', "").equals("json", true) || - text.trimStart().startsWith('{') - if (isJson) { - val isMegaProxy = runCatching { - ConfigTransfer.isSupportedSchema(org.json.JSONObject(text).optString("schema")) - }.getOrDefault(false) - if (isMegaProxy) { - val configuration = ConfigTransfer.importJson(text) + if (uri != null && !uiState.busy) { + uiState.launchOperation { + val parsed = withContext(Dispatchers.IO) { + val text = activity.contentResolver.openInputStream(uri)?.buffered()?.use { it.readConfigText() } + ?: throw UiException(R.string.error_read_file) + parseProfileImport(text, activity.contentResolver.getType(uri), uri.lastPathSegment.orEmpty()) + } + when (parsed) { + is ParsedProfileImport.Configuration -> { + val configuration = parsed.value if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.jumpAllowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) { pendingUnsafeImport = configuration - } else { - prepareJsonImport(configuration) - } - } else { - val imported = FoxyProxyParser.parse(text).getOrThrow() - val added = store.importProfiles(imported.proxies) + } else prepareJsonImport(configuration) + } + is ParsedProfileImport.ProxyList -> { + val added = withContext(ConfigIoDispatcher) { store.importProfiles(parsed.value.proxies) } refresh() importedProfileCount = added.size - skippedNonHttps = imported.skippedNonHttps - showImportFilterNotice = imported.skippedNonHttps > 0 - if (imported.skippedNonHttps == 0) { - transferMessage = activity.uiText(R.string.imported_foxyproxy, added.size) - } - } - } else { - val isSuperProxy = SuperProxyParser.matches(text) - val imported = if (isSuperProxy) { - SuperProxyParser.parse(text).getOrThrow() - } else { - ProxyListParser.parse(text).getOrThrow() - } - val added = store.importProfiles(imported.proxies) - refresh() - importedProfileCount = added.size - skippedNonHttps = imported.skippedNonHttps - showImportFilterNotice = imported.skippedNonHttps > 0 - if (imported.skippedNonHttps == 0) { - transferMessage = if (isSuperProxy) { - activity.uiText(R.string.imported_super_proxy, added.size) - } else { - activity.uiText(R.string.imported_https_profiles, added.size) - } + skippedNonHttps = parsed.value.skippedNonHttps + showImportFilterNotice = skippedNonHttps > 0 + if (!showImportFilterNotice) transferMessage = activity.uiText(parsed.summaryRes, added.size) } } importError = null - }.onFailure { - importError = activity.uiText( - R.string.configuration_import_failed, - it.userMessage(activity, R.string.error_invalid_input), - ) } } } @@ -421,27 +448,31 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } }, actions = { - IconButton(onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) { + IconButton(enabled = !busy, onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) { Icon(Icons.Default.FileDownload, contentDescription = stringResource(R.string.import_action)) } - IconButton(onClick = { showExportDialog = true }) { Icon(Icons.Default.FileUpload, contentDescription = stringResource(R.string.export_action)) } + IconButton(enabled = !busy, onClick = { showExportDialog = true }) { Icon(Icons.Default.FileUpload, contentDescription = stringResource(R.string.export_action)) } }, ) }, bottomBar = { + Column { + if (busy) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + 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 = { - scope.launch { - val profile = withContext(ConfigIoDispatcher) { store.addProfile() } - refresh() - edit(profile) - } + onEditProfile("new") }, ) { Text(stringResource(R.string.add_profile)) } } + } }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> @@ -480,13 +511,13 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil dragOffset = 0f draggedProfileId = null val order = profiles.map(ProxyProfile::id) - scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + ConfigWrites.submit("profile-order") { store.reorderProfiles(order) } }, onDragEnd = { dragOffset = 0f draggedProfileId = null val order = profiles.map(ProxyProfile::id) - scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + ConfigWrites.submit("profile-order") { store.reorderProfiles(order) } }, onDrag = { change, amount -> change.consume() @@ -516,9 +547,10 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil } } }, + enabled = !busy, onConfigure = { edit(profile) }, onClone = { - scope.launch { + uiState.launchOperation { withContext(ConfigIoDispatcher) { store.cloneProfile(profile.id) } refresh() } @@ -526,12 +558,20 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil onDelete = { deleteProfile = profile }, ) } - importError?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 16.dp)) } } } } + importError?.let { message -> + AlertDialog(onDismissRequest = { importError = null }, + title = { DialogTitle(stringResource(R.string.configuration_transfer)) }, + text = { ScrollableDialogText(message) }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { importError = null }) { + Text(stringResource(R.string.ok)) + } }) + } + deleteProfile?.let { profile -> AlertDialog( onDismissRequest = { deleteProfile = null }, @@ -539,7 +579,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil text = { ScrollableDialogText(profile.localizedName(activity) + "\n\n" + stringResource(R.string.delete_profile_message)) }, confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { deleteProfile = null - scope.launch { + uiState.launchOperation { val reconnect = withContext(ConfigIoDispatcher) { val needed = store.isConnectionDesired() && store.connectionProfile().id == profile.id store.deleteProfile(profile.id) @@ -750,7 +790,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil val selected = selectedMissingProfileIds.toSet() missingProfilesReview = null selectedMissingProfileIds.clear() - scope.launch { + uiState.launchOperation { val reconnect = withContext(ConfigIoDispatcher) { val desired = store.isConnectionDesired() store.deleteProfiles(selected).also { @@ -779,6 +819,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil @Composable private fun ProfileCard( profile: ProxyProfile, + enabled: Boolean = true, modifier: Modifier = Modifier, onConfigure: () -> Unit, onClone: () -> Unit, @@ -811,9 +852,9 @@ private fun ProfileCard( trailing = { ProfileTypeBadge(profile.config.type, foreground) }, ) WrappingActions() { - TextButton(shape = RoundedCornerShape(12.dp), onClick = onConfigure) { Text(stringResource(R.string.configure), color = foreground) } - TextButton(shape = RoundedCornerShape(12.dp), onClick = onClone) { Text(stringResource(R.string.clone), color = foreground) } - TextButton(shape = RoundedCornerShape(12.dp), onClick = onDelete) { Text(stringResource(R.string.delete), color = foreground) } + TextButton(enabled = enabled, shape = RoundedCornerShape(12.dp), onClick = onConfigure) { Text(stringResource(R.string.configure), color = foreground) } + TextButton(enabled = enabled, shape = RoundedCornerShape(12.dp), onClick = onClone) { Text(stringResource(R.string.clone), color = foreground) } + TextButton(enabled = enabled, shape = RoundedCornerShape(12.dp), onClick = onDelete) { Text(stringResource(R.string.delete), color = foreground) } } } } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 1d0ad2e..43be032 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -51,6 +51,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -66,6 +70,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigWrites import net.megaproxy487.data.ConfigIoDispatcher import net.megaproxy487.model.Ja3Spec import net.megaproxy487.model.TlsProfile @@ -256,7 +261,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { fun save(mode: FailoverMode = settings.failoverMode, ids: List = settings.failoverProfileIds) { settings = settings.copy(failoverMode = mode, failoverProfileIds = ids) val snapshot = settings - scope.launch(ConfigIoDispatcher) { store.saveGlobalConnectionSettings(snapshot) } + ConfigWrites.submit("global") { store.saveGlobalConnectionSettings(snapshot) } } SettingsScaffold(onBack, stringResource(R.string.failover)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { @@ -339,7 +344,7 @@ internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { store.sortedProfiles().forEach { profile -> DropdownMenuItem(text = { Text(profile.localizedNameWithFlag(activity)) }, onClick = { selected = profile - scope.launch(ConfigIoDispatcher) { store.setAlwaysOnProfile(profile.id) } + ConfigWrites.submit("always-on") { store.setAlwaysOnProfile(profile.id) } expanded = false if (alwaysOnActive && ProxyVpnService.isRunning) { ProxyVpnService.switchProfile(activity, profile.id, true) @@ -372,10 +377,12 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { var showAlwaysOnNotice by remember { mutableStateOf(false) } var deferred by remember { mutableStateOf(false) } + val invalidFields = remember { mutableStateMapOf() } + val writeStatus by ConfigWrites.status.collectAsState() fun saveSettings(updated: net.megaproxy487.model.GlobalConnectionSettings) { settings = updated val snapshot = settings - scope.launch(ConfigIoDispatcher) { + ConfigWrites.submit("global") { store.saveGlobalConnectionSettings(snapshot) if (ProxyVpnService.isRunning) store.markPendingReconnect() } @@ -454,16 +461,16 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { }) } } } - IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, stringResource(R.string.ssh_keepalive)) { + IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, stringResource(R.string.ssh_keepalive), onValidityChange = { invalidFields["sshKeepaliveSeconds"] = !it }) { saveSettings(settings.copy(sshKeepaliveSeconds = it)) } - IntegerSettingField(settings.sshMaxChannels, 1..256, stringResource(R.string.ssh_max_channels)) { + IntegerSettingField(settings.sshMaxChannels, 1..256, stringResource(R.string.ssh_max_channels), onValidityChange = { invalidFields["sshMaxChannels"] = !it }) { saveSettings(settings.copy(sshMaxChannels = it)) } - IntegerSettingField(settings.sshRotationMinutes, 0..1440, stringResource(R.string.ssh_rotation_minutes)) { + IntegerSettingField(settings.sshRotationMinutes, 0..1440, stringResource(R.string.ssh_rotation_minutes), onValidityChange = { invalidFields["sshRotationMinutes"] = !it }) { saveSettings(settings.copy(sshRotationMinutes = it)) } - IntegerSettingField(settings.sshRotationMb, 0..10240, stringResource(R.string.ssh_rotation_mb)) { + IntegerSettingField(settings.sshRotationMb, 0..10240, stringResource(R.string.ssh_rotation_mb), onValidityChange = { invalidFields["sshRotationMb"] = !it }) { saveSettings(settings.copy(sshRotationMb = it)) } Text(stringResource(R.string.ssh_profile_description), style = MaterialTheme.typography.bodySmall) @@ -473,7 +480,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { Text(stringResource(R.string.reconnect_apply_changes)) WrappingActions() { TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; deferred = true }) { Text(stringResource(R.string.next_connection)) } - TextButton(shape = RoundedCornerShape(12.dp), onClick = { + TextButton(shape = RoundedCornerShape(12.dp), enabled = error == null && invalidFields.values.none { it } && writeStatus.pending == 0 && !writeStatus.failed, onClick = { showReconnectPrompt = false deferred = true ProxyVpnService.reconnect(activity) @@ -495,25 +502,11 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { } @Composable -private fun IntegerSettingField(initialValue: Int, range: IntRange, label: String, onValidValue: (Int) -> Unit) { - var text by remember { mutableStateOf(initialValue.toString()) } - val parsed = text.toIntOrNull() - OutlinedTextField( - value = text, - onValueChange = { value -> - if (value.length <= range.last.toString().length && value.all(Char::isDigit)) { - text = value - value.toIntOrNull()?.takeIf { it in range }?.let(onValidValue) - } - }, - label = { FieldLabel(label) }, - supportingText = if (text.isNotEmpty() && parsed !in range) { - { Text(stringResource(R.string.allowed_range, range.first, range.last)) } - } else null, - isError = text.isNotEmpty() && parsed !in range, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) +private fun IntegerSettingField(initialValue: Int, range: IntRange, label: String, + onValidityChange: (Boolean) -> Unit = {}, onValidValue: (Int) -> Unit) { + var text by rememberSaveable { mutableStateOf(initialValue.toString()) } + SideEffect { onValidityChange(validIntegerInput(text, range) != null) } + IntegerInputField(text, { text = it }, range, label, onValidValue, Modifier.fillMaxWidth()) } @OptIn(ExperimentalMaterial3Api::class) @@ -541,7 +534,7 @@ private fun SettingsScaffold(onBack: () -> Unit, title: String, content: @Compos .padding(16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), - content = content, + content = { SaveStatusBanner(); content() }, ) } } diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index adf3b2a..9e88e39 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -41,6 +41,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.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,6 +53,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import net.megaproxy487.uiStringResource as stringResource import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigWrites import net.megaproxy487.data.ConfigIoDispatcher import net.megaproxy487.model.GlobalConnectionSettings import net.megaproxy487.vpn.ProxyVpnService @@ -67,6 +69,7 @@ private data class AppItem(val packageName: String, val label: String) @Composable internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } + val writeStatus by ConfigWrites.status.collectAsState() var settings by remember { mutableStateOf(store.globalConnectionSettings()) } var appSearch by remember { mutableStateOf("") } var showReconnectPrompt by remember { mutableStateOf(false) } @@ -80,7 +83,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { settings.copy(selectedPackages = updated.selectedPackages) == updated val affectsActiveRouting = !(settings.routeAllApps && updated.routeAllApps && selectedPackagesOnly) settings = updated - coroutineScope.launch(ConfigIoDispatcher) { + ConfigWrites.submit("global") { store.saveGlobalConnectionSettings(updated) if (ProxyVpnService.isRunning && affectsActiveRouting) store.markPendingReconnect() } @@ -124,6 +127,8 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { ) }, bottomBar = { + Column { + SaveStatusBanner() if (showReconnectPrompt || showAlwaysOnDeferredNotice) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), @@ -141,7 +146,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { deferChangesUntilNextConnection = true }) { Text(stringResource(if (showAlwaysOnDeferredNotice) R.string.dismiss else R.string.next_connection)) } if (showReconnectPrompt) { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { + TextButton(shape = RoundedCornerShape(12.dp), enabled = writeStatus.pending == 0 && !writeStatus.failed, onClick = { showReconnectPrompt = false deferChangesUntilNextConnection = true ProxyVpnService.reconnect(activity) @@ -151,6 +156,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { } } } + } }, // Use one inset source. Applying safeDrawing in Scaffold and imePadding to // its child can double-apply or misreport the IME area on some OEM builds. diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 6630e48..978224c 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -4,6 +4,7 @@ import net.megaproxy487.uiText import net.megaproxy487.localizedName import android.content.Context +import android.content.SharedPreferences import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 @@ -95,14 +96,14 @@ class ConfigStore(context: Context) { } fun setActiveProfile(id: String) { - if (profile(id) != null) prefs.edit().putString(ACTIVE_PROFILE_ID, id).apply() + if (profile(id) != null) prefs.edit().putString(ACTIVE_PROFILE_ID, id).commitOrThrow() } fun setAlwaysOnProfile(id: String) { if (profile(id) != null) prefs.edit() .putString(ALWAYS_ON_PROFILE_ID, id) .putBoolean(FAILOVER_ACTIVE, false) - .apply() + .commitOrThrow() } fun connectionProfile(): ProxyProfile = @@ -187,21 +188,23 @@ class ConfigStore(context: Context) { sshRotationMb = settings.sshRotationMb.coerceIn(0, 10240), failoverProfileIds = settings.failoverProfileIds.distinct(), ) - prefs.edit().putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(normalized)).apply() + prefs.edit().putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(normalized)).commitOrThrow() } @Synchronized - fun addProfile(): ProxyProfile { + fun newProfileDraft(id: String = UUID.randomUUID().toString()): ProxyProfile { val existing = profiles() val profile = ProxyProfile( - id = UUID.randomUUID().toString(), + id = id, colorIndex = nextColorIndex(existing), config = ProxyConfig(port = 443), ) - writeProfiles(existing + profile) return profile } + @Synchronized + fun addProfile(): ProxyProfile = newProfileDraft().also { writeProfiles(profiles() + it) } + @Synchronized fun cloneProfile(id: String): ProxyProfile? { val existing = profiles() @@ -308,10 +311,12 @@ class ConfigStore(context: Context) { ).joinToString("\u0000") @Synchronized - fun saveProfile(profile: ProxyProfile) { + fun saveProfile(profile: ProxyProfile, createIfMissing: Boolean = false) { val current = profiles() - val updated = current.map { if (it.id == profile.id) profile else it } - if (updated != current) writeProfiles(updated) + val updated = if (createIfMissing && current.none { it.id == profile.id }) current + profile + else current.map { if (it.id == profile.id) profile else it } + // A failed commit may already have updated SharedPreferences in memory. Retry the disk write too. + writeProfiles(updated) } @Synchronized @@ -344,7 +349,8 @@ class ConfigStore(context: Context) { if (activeProfileId() == id) editor.putString(ACTIVE_PROFILE_ID, replacement) if (alwaysOnProfileId() == id) editor.putString(ALWAYS_ON_PROFILE_ID, replacement) if (connectionWasDeleted) editor.putString(CONNECTION_PROFILE_ID, replacement) - editor.apply() + editor.commitOrThrow() + ConfigWrites.discard("profile:$id") val settings = globalConnectionSettings() if (id in settings.failoverProfileIds) { saveGlobalConnectionSettings(settings.copy(failoverProfileIds = settings.failoverProfileIds - id)) @@ -366,7 +372,8 @@ class ConfigStore(context: Context) { if (activeProfileId() in removedIds) editor.putString(ACTIVE_PROFILE_ID, replacement) if (alwaysOnProfileId() in removedIds) editor.putString(ALWAYS_ON_PROFILE_ID, replacement) if (connectionWasDeleted) editor.putString(CONNECTION_PROFILE_ID, replacement) - editor.apply() + editor.commitOrThrow() + removedIds.forEach { ConfigWrites.discard("profile:$it") } val settings = globalConnectionSettings() saveGlobalConnectionSettings(settings.copy( failoverProfileIds = settings.failoverProfileIds.filterNot(removedIds::contains), @@ -390,7 +397,7 @@ class ConfigStore(context: Context) { fun diagnosticLogLimitMb(): Int = prefs.getInt(DIAGNOSTIC_LOG_LIMIT_MB, 3).coerceIn(1, 100) fun setDiagnosticLogLimitMb(value: Int) { - prefs.edit().putInt(DIAGNOSTIC_LOG_LIMIT_MB, value.coerceIn(1, 100)).apply() + prefs.edit().putInt(DIAGNOSTIC_LOG_LIMIT_MB, value.coerceIn(1, 100)).commitOrThrow() } private fun ensureMigrated() { @@ -438,7 +445,7 @@ class ConfigStore(context: Context) { } private fun writeProfiles(profiles: List) { - prefs.edit().putString(PROFILES, encodeProfiles(profiles)).apply() + prefs.edit().putString(PROFILES, encodeProfiles(profiles)).commitOrThrow() } private fun encodeProfiles(profiles: List) = JSONArray().apply { @@ -613,3 +620,8 @@ class ConfigStore(context: Context) { private const val PENDING_RECONNECT = "pending_reconnect" } } + +/** Call from the ordered I/O queue so a reported success includes the disk write. */ +private fun SharedPreferences.Editor.commitOrThrow() { + check(commit()) { "Configuration disk write failed" } +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt b/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt new file mode 100644 index 0000000..2273946 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt @@ -0,0 +1,51 @@ +package net.megaproxy487.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ConfigWriteStatus(val pending: Int = 0, val failed: Boolean = false) + +/** One ordered queue owned by the application, never by a disappearing Compose screen. */ +class ConfigWriteQueue(dispatcher: CoroutineDispatcher) { + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val generations = mutableMapOf() + private val failures = linkedMapOf Unit>() + private val mutableStatus = MutableStateFlow(ConfigWriteStatus()) + val status = mutableStatus.asStateFlow() + + @Synchronized + fun submit(key: String, write: () -> Unit) { + mutableStatus.value = mutableStatus.value.copy(pending = mutableStatus.value.pending + 1) + val generation = generations[key] ?: 0L + scope.launch { + val current = synchronized(this@ConfigWriteQueue) { generation == (generations[key] ?: 0L) } + val result = if (current) runCatching(write) else null + synchronized(this@ConfigWriteQueue) { + if (generation == (generations[key] ?: 0L) && result != null) { + if (result.isSuccess) failures.remove(key) else failures[key] = write + } + mutableStatus.value = ConfigWriteStatus(mutableStatus.value.pending - 1, failures.isNotEmpty()) + } + } + } + + /** A deleted entity must not be recreated by retrying an older failed draft write. */ + @Synchronized + fun discard(key: String) { + generations[key] = (generations[key] ?: 0L) + 1 + failures.remove(key) + mutableStatus.value = mutableStatus.value.copy(failed = failures.isNotEmpty()) + } + + @Synchronized + fun retry() { + if (mutableStatus.value.pending != 0) return + failures.toMap().forEach { (key, write) -> submit(key, write) } + } +} + +val ConfigWrites = ConfigWriteQueue(ConfigIoDispatcher) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7ef5a31..9ec2c95 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -448,4 +448,16 @@ Нет ответа (промежуточный узел) (конечный узел) + Сохранение изменений… + Не удалось сохранить часть изменений. Повторите сохранение перед переподключением. + Повторить сохранение + Показать пароль + Скрыть пароль + Не сохранено. Введите целое число от %1$d до %2$d. + Исправьте отмеченные поля, чтобы использовать эту конфигурацию. + Подготовленный экспорт утрачен после перезапуска приложения. Повторите экспорт; данные не записаны. + Нет профилей для выбранного формата экспорта. + Не удалось завершить операцию. Повторите попытку. + Обработка конфигурации… + Профиль сохранится после первого изменения. Чтобы отменить создание, вернитесь назад. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fd4952..43549f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,4 +443,16 @@ No response (jump host) (destination host) + Saving changes… + Some changes could not be saved. Retry before reconnecting. + Retry saving + Show password + Hide password + Not saved. Enter a whole number from %1$d to %2$d. + Correct the highlighted fields to use this configuration. + The prepared export was lost when the app restarted. Start the export again; no data was written. + There are no profiles available for this export format. + The operation could not be completed. Please try again. + Processing configuration… + This profile will be saved after your first change. Go back to cancel. diff --git a/app/src/test/java/net/megaproxy487/EditingContractsTest.kt b/app/src/test/java/net/megaproxy487/EditingContractsTest.kt new file mode 100644 index 0000000..cbd4afa --- /dev/null +++ b/app/src/test/java/net/megaproxy487/EditingContractsTest.kt @@ -0,0 +1,37 @@ +package net.megaproxy487 + +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyType +import org.junit.Assert.* +import org.junit.Test + +class EditingContractsTest { + @Test fun selectingSameTypeKeepsCustomPorts() { + val config = ProxyConfig(type = ProxyType.HTTPS_JUMP, port = 8443, jumpPort = 9443) + assertEquals(config, config.withType(ProxyType.HTTPS_JUMP)) + } + @Test fun changingTypeKeepsCustomPortsAndUpdatesDefaults() { + assertEquals(8443, ProxyConfig(port = 8443).withType(ProxyType.SSH).port) + val changed = ProxyConfig(type = ProxyType.HTTPS_JUMP).withType(ProxyType.SSH_JUMP) + assertEquals(22, changed.port) + assertEquals(22, changed.jumpPort) + assertEquals(9443, ProxyConfig(type = ProxyType.HTTPS_JUMP, jumpPort = 9443).withType(ProxyType.SSH_JUMP).jumpPort) + } + @Test fun invalidIntegerDraftsHaveNoValueToPersist() { + listOf("", "abc", "-1", "0", "65536", "99999999999999999").forEach { + assertNull(it, validIntegerInput(it, 1..65535)) + } + assertEquals(443, validIntegerInput("443", 1..65535)) + assertEquals(0, validIntegerInput("0", 0..3600)) + } + @Test fun interruptedOrEmptyExportsCannotReachWriter() { + assertEquals(R.string.export_expired, assertThrows(UiException::class.java) { requireExportContent(null) }.textId) + assertEquals(R.string.export_empty, assertThrows(UiException::class.java) { requireExportContent("") }.textId) + assertEquals("snapshot", requireExportContent("snapshot")) + } + @Test fun parserDistinguishesProxyListAndMalformedJsonBeforeCommit() { + val parsed = parseProfileImport("https://user:secret@proxy.example:443", "text/plain", "proxies.txt") + assertTrue(parsed is ParsedProfileImport.ProxyList) + assertThrows(org.json.JSONException::class.java) { parseProfileImport("{broken", "application/json", "config.json") } + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt new file mode 100644 index 0000000..94dc274 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt @@ -0,0 +1,84 @@ +package net.megaproxy487.data + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test + +class ConfigWritesTest { + private class ManualDispatcher : CoroutineDispatcher() { + val tasks = ArrayDeque() + override fun dispatch(context: CoroutineContext, block: Runnable) { tasks.addLast(block) } + fun drain() { while (tasks.isNotEmpty()) tasks.removeFirst().run() } + } + + @Test fun queuedWritesSurviveCallerCancellationAndKeepOrder() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + val saved = mutableListOf() + val screen = CoroutineScope(Job() + Dispatchers.Unconfined) + screen.launch { + queue.submit("profile") { saved += 1 } + queue.submit("profile") { saved += 2 } + } + screen.cancel() + assertEquals(2, queue.status.value.pending) + dispatcher.drain() + assertEquals(listOf(1, 2), saved) + assertEquals(ConfigWriteStatus(), queue.status.value) + } + + @Test fun failedWriteCanBeRetried() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var fail = true + var saved = false + queue.submit("profile") { if (fail) error("disk unavailable") else saved = true } + dispatcher.drain() + assertTrue(queue.status.value.failed) + fail = false + queue.retry() + dispatcher.drain() + assertTrue(saved) + assertEquals(ConfigWriteStatus(), queue.status.value) + } + + @Test fun deletingEntityDiscardsItsFailedWriteBeforeRetry() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var attempts = 0 + queue.submit("profile:deleted") { attempts++; error("disk unavailable") } + dispatcher.drain() + queue.discard("profile:deleted") + queue.retry() + dispatcher.drain() + assertEquals(1, attempts) + assertFalse(queue.status.value.failed) + } + + @Test fun deletionAlsoInvalidatesAnAlreadyQueuedRetry() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var attempts = 0 + queue.submit("profile:deleted") { attempts++; error("disk unavailable") } + dispatcher.drain() + queue.retry() + queue.discard("profile:deleted") + dispatcher.drain() + assertEquals(1, attempts) + assertEquals(ConfigWriteStatus(), queue.status.value) + } + + @Test fun newerSuccessfulSnapshotReplacesFailedOlderSnapshot() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var saved = 0 + queue.submit("profile") { error("old failure") } + queue.submit("profile") { saved = 2 } + dispatcher.drain() + queue.retry() + dispatcher.drain() + assertEquals(2, saved) + assertFalse(queue.status.value.failed) + } +} From c98e93c73342ba4da0374407a21ecd58567e737f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 17:58:15 +0300 Subject: [PATCH 5/6] Simplify main screen actions and improve profile contrast --- .../java/net/megaproxy487/MainActivity.kt | 128 ++++++++++-------- .../java/net/megaproxy487/ProfileContrast.kt | 12 ++ .../java/net/megaproxy487/ProfilesScreen.kt | 3 +- app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../net/megaproxy487/ProfileContrastTest.kt | 20 +++ 6 files changed, 106 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/ProfileContrast.kt create mode 100644 app/src/test/java/net/megaproxy487/ProfileContrastTest.kt diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index d8b22cc..c0cdb46 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -33,6 +33,8 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.IconButton import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.outlined.RadioButtonUnchecked @@ -73,7 +75,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import net.megaproxy487.uiStringResource as stringResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextAlign @@ -165,16 +166,16 @@ class MainActivity : LocalizedActivity() { internal fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modifier = Modifier) { Surface( modifier = modifier, - color = foreground.copy(alpha = 0.14f), + color = Color.Transparent, contentColor = foreground, shape = RoundedCornerShape(50), border = BorderStroke(1.dp, foreground.copy(alpha = 0.5f)), ) { Text( stringResource(type.titleRes), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.sp), - maxLines = 1, - softWrap = false, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.sp), + maxLines = 2, + softWrap = true, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), ) @@ -254,6 +255,7 @@ internal fun MainScreen( val store = remember { ConfigStore(activity) } val writeStatus by ConfigWrites.status.collectAsState() var error by remember { mutableStateOf(null) } + var actionsMenuExpanded by remember { mutableStateOf(false) } var profileMenuExpanded by remember { mutableStateOf(false) } var profiles by remember { mutableStateOf(store.sortedProfiles()) } var activeProfileId by remember { mutableStateOf(store.activeProfileId()) } @@ -390,7 +392,44 @@ internal fun MainScreen( } Scaffold( - topBar = { TopAppBar(title = { ScreenTitle(stringResource(R.string.app_name)) }) }, + topBar = { + TopAppBar( + title = { ScreenTitle(stringResource(R.string.app_name)) }, + actions = { + Box { + IconButton(onClick = { actionsMenuExpanded = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.main_actions)) + } + DropdownMenu(actionsMenuExpanded, { actionsMenuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.test_connection)) }, + onClick = { actionsMenuExpanded = false; onOpenConnectionTest() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.settings)) }, + onClick = { actionsMenuExpanded = false; onOpenSettings() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.feedback)) }, + onClick = { + actionsMenuExpanded = false + scope.launch { + runCatching { + val intent = withContext(Dispatchers.IO) { + FeedbackEmail.createIntent(activity, connection, alwaysOn, lockdown) + } + activity.startActivity(intent) + }.onFailure { + error = activity.uiText(R.string.could_not_open_email) + } + } + }, + ) + } + } + }, + ) + }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { @@ -412,20 +451,26 @@ internal fun MainScreen( stateDescription = statusLabel }) { Column( - Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 20.dp), + Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - if (connected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked, - contentDescription = null, - tint = statusColor, - ) - Text( - if (connection == VpnConnectionState.CONNECTING) activity.uiText(R.string.status_connecting_progress) else statusLabel, - style = MaterialTheme.typography.headlineSmall, - color = statusColor, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + if (connected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + tint = statusColor, + ) + Text( + if (connection == VpnConnectionState.CONNECTING) activity.uiText(R.string.status_connecting_progress) else statusLabel, + style = MaterialTheme.typography.titleLarge, + color = statusColor, + modifier = Modifier.weight(1f, fill = false), + ) + } if (alwaysOn) { Text( if (lockdown) activity.uiText(R.string.always_on_lockdown) else activity.uiText(R.string.always_on_badge), @@ -440,37 +485,26 @@ internal fun MainScreen( VpnTransportProtocol.SSH_MULTIPLEXED -> stringResource(R.string.transport_ssh_multiplexed) VpnTransportProtocol.UNKNOWN -> "" } - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - shape = RoundedCornerShape(12.dp), - ) { - Text( - transportLabel, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - } + Text( + transportLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } - Spacer(Modifier.height(4.dp)) networkWarning?.let { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), modifier = Modifier.fillMaxWidth()) { Text(it, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(14.dp)) } Spacer(Modifier.height(12.dp)) } - connectionStats?.let { stats -> - ConnectionStatsCard(stats) - Spacer(Modifier.height(12.dp)) - } val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first() val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId } val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()?.let { activity.uiText(it) } val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)]) - val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White + val onProfileColor = profileForeground(profileColor) Box(Modifier.fillMaxWidth()) { Card( Modifier.fillMaxWidth().clickable { profileMenuExpanded = true }, @@ -605,29 +639,6 @@ internal fun MainScreen( } } } - FilledTonalButton(shape = RoundedCornerShape(12.dp), - onClick = onOpenConnectionTest, - modifier = Modifier.fillMaxWidth(), - ) { Text(stringResource(R.string.test_connection)) } - FilledTonalButton(shape = RoundedCornerShape(12.dp), - onClick = onOpenSettings, - modifier = Modifier.fillMaxWidth(), - ) { Text(stringResource(R.string.settings)) } - FilledTonalButton(shape = RoundedCornerShape(12.dp), - onClick = { - scope.launch { - runCatching { - val intent = withContext(Dispatchers.IO) { - FeedbackEmail.createIntent(activity, connection, alwaysOn, lockdown) - } - activity.startActivity(intent) - }.onFailure { - error = activity.uiText(R.string.could_not_open_email) - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) { Text(stringResource(R.string.feedback)) } error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 12.dp)) } @@ -639,6 +650,9 @@ internal fun MainScreen( modifier = Modifier.padding(top = 12.dp), ) } + connectionStats?.let { stats -> + ConnectionStatsCard(stats) + } Text( stringResource(R.string.version_and_commit, BuildConfig.VERSION_NAME, BuildConfig.GIT_COMMIT_HASH), style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/net/megaproxy487/ProfileContrast.kt b/app/src/main/java/net/megaproxy487/ProfileContrast.kt new file mode 100644 index 0000000..aa5b38a --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ProfileContrast.kt @@ -0,0 +1,12 @@ +package net.megaproxy487 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance + +/** Choose the stronger contrast against an opaque profile color. */ +internal fun profileForeground(background: Color): Color { + val luminance = background.luminance() + val blackContrast = (luminance + 0.05f) / 0.05f + val whiteContrast = 1.05f / (luminance + 0.05f) + return if (blackContrast >= whiteContrast) Color.Black else Color.White +} diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index fca110c..136c66b 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -74,7 +74,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.zIndex import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics @@ -827,7 +826,7 @@ private fun ProfileCard( ) { val activity = androidx.compose.ui.platform.LocalContext.current val background = Color(ProfileColors.argb[Math.floorMod(profile.colorIndex, ProfileColors.argb.size)]) - val foreground = if (background.luminance() > 0.45f) Color.Black else Color.White + val foreground = profileForeground(background) Card( colors = CardDefaults.cardColors(containerColor = background, contentColor = foreground), modifier = modifier.fillMaxWidth(), diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ec2c95..ecbae71 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -460,4 +460,5 @@ Не удалось завершить операцию. Повторите попытку. Обработка конфигурации… Профиль сохранится после первого изменения. Чтобы отменить создание, вернитесь назад. + Другие действия diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43549f8..22851a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,4 +455,5 @@ The operation could not be completed. Please try again. Processing configuration… This profile will be saved after your first change. Go back to cancel. + More actions diff --git a/app/src/test/java/net/megaproxy487/ProfileContrastTest.kt b/app/src/test/java/net/megaproxy487/ProfileContrastTest.kt new file mode 100644 index 0000000..764d3b8 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/ProfileContrastTest.kt @@ -0,0 +1,20 @@ +package net.megaproxy487 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import net.megaproxy487.model.ProfileColors +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileContrastTest { + @Test + fun allProfileColorsMeetSmallTextContrast() { + for (argb in ProfileColors.argb) { + val background = Color(argb) + val foreground = profileForeground(background) + val light = maxOf(background.luminance(), foreground.luminance()) + val dark = minOf(background.luminance(), foreground.luminance()) + assertTrue("Profile color $argb must meet 4.5:1", (light + 0.05f) / (dark + 0.05f) >= 4.5f) + } + } +} From bd028bbc4864ede47d0fcf587784075682d4c5c2 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 17:59:48 +0300 Subject: [PATCH 6/6] Keep multiline proxy badges clear of rounded corners --- app/src/main/java/net/megaproxy487/MainActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index c0cdb46..607feb9 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -168,7 +168,7 @@ internal fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modi modifier = modifier, color = Color.Transparent, contentColor = foreground, - shape = RoundedCornerShape(50), + shape = RoundedCornerShape(8.dp), border = BorderStroke(1.dp, foreground.copy(alpha = 0.5f)), ) { Text(