diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 423d738..f8ca0f6 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -663,23 +665,51 @@ private data class RefreshedMainConfig( @Composable 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.SpaceBetween, + horizontalArrangement = Arrangement.SpaceEvenly, ) { - StatValue("Download", "↓ ${formatRate(stats.downloadBytesPerSecond)}") - StatValue("Upload", "↑ ${formatRate(stats.uploadBytesPerSecond)}") + StatValue( + label = stringResource(R.string.traffic_download), + value = "↓ ${formatTrafficRate(stats.downloadBytesPerSecond, unitSystem)}", + supportingValue = stringResource( + R.string.traffic_total, + formatTrafficBytes(stats.native.downloadBytes, unitSystem), + ), + modifier = Modifier.weight(1f), + ) + StatValue( + label = stringResource(R.string.traffic_upload), + value = "↑ ${formatTrafficRate(stats.uploadBytesPerSecond, unitSystem)}", + supportingValue = stringResource( + R.string.traffic_total, + formatTrafficBytes(stats.native.uploadBytes, unitSystem), + ), + modifier = Modifier.weight(1f), + ) val latency = stats.native.proxyLatencyMillis val ageMillis = System.currentTimeMillis() - stats.native.proxyLatencyAtMillis StatValue( - "Proxy latency", - if (latency <= 0) "—" else "${latency.toInt()} ms${formatAge(ageMillis)}", + label = stringResource(R.string.proxy_latency), + value = if (latency <= 0) "—" else stringResource( + R.string.latency_milliseconds, + latency.toInt(), + formatAge(ageMillis), + ), + modifier = Modifier.weight(1f), ) } + val samples = stats.native.connectionSamples Text( - if (stats.native.connectionSamples == 0) "Connection errors: no samples" - else "Connection errors: ${"%.1f".format(stats.native.connectionErrorRate * 100)}% · ${stats.native.connectionSamples} samples", + if (samples == 0) stringResource(R.string.connection_errors_no_samples) + else stringResource( + R.string.connection_errors, + stats.native.connectionErrorRate * 100, + pluralStringResource(R.plurals.connection_samples, samples, samples), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp), @@ -688,21 +718,42 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { } @Composable -private fun StatValue(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(value, style = MaterialTheme.typography.titleSmall) - Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +private fun StatValue( + label: String, + value: String, + modifier: Modifier = Modifier, + supportingValue: String? = null, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.titleSmall, textAlign = TextAlign.Center) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + supportingValue?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } } -private fun formatRate(bytesPerSecond: Double): String = when { - bytesPerSecond >= 1_000_000 -> "%.1f MB/s".format(bytesPerSecond / 1_000_000) - bytesPerSecond >= 1_000 -> "%.1f KB/s".format(bytesPerSecond / 1_000) - else -> "${bytesPerSecond.toInt()} B/s" -} - +@Composable private fun formatAge(ageMillis: Long): String = when { ageMillis < 30_000 -> "" - ageMillis < 120_000 -> " · ${ageMillis / 1_000}s ago" - else -> " · ${ageMillis / 60_000}m ago" + ageMillis < 120_000 -> pluralStringResource( + R.plurals.latency_seconds_ago, + (ageMillis / 1_000).toInt(), + (ageMillis / 1_000).toInt(), + ) + else -> pluralStringResource( + R.plurals.latency_minutes_ago, + (ageMillis / 60_000).toInt(), + (ageMillis / 60_000).toInt(), + ) } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 492b78e..986f8a1 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -87,6 +87,8 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga mutableStateOf(powerManager.isIgnoringBatteryOptimizations(activity.packageName)) } var showLanguageDialog by remember { mutableStateOf(false) } + var showTrafficUnitsDialog by remember { mutableStateOf(false) } + var trafficUnitSystem by remember { mutableStateOf(TrafficUnitPreferences.current(activity)) } var supportError by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -122,6 +124,16 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga stringResource(R.string.language), "${stringResource(R.string.language_description)} · ${if (AppLanguageManager.current(activity) == AppLanguage.RUSSIAN) stringResource(R.string.language_russian) else stringResource(R.string.language_english)}", ) { showLanguageDialog = true } + SettingsButton( + stringResource(R.string.traffic_units), + stringResource( + R.string.traffic_units_description, + stringResource( + if (trafficUnitSystem == TrafficUnitSystem.IEC) R.string.traffic_units_iec + else R.string.traffic_units_si, + ), + ), + ) { showTrafficUnitsDialog = true } Text(stringResource(R.string.support), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) SettingsButton(stringResource(R.string.feedback), stringResource(R.string.feedback_description)) { scope.launch { @@ -188,6 +200,44 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga dismissButton = { TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, ) } + + if (showTrafficUnitsDialog) { + AlertDialog( + onDismissRequest = { showTrafficUnitsDialog = false }, + title = { Text(stringResource(R.string.traffic_units)) }, + text = { + Column { + TrafficUnitSystem.entries.forEach { unitSystem -> + val label = stringResource( + if (unitSystem == TrafficUnitSystem.IEC) R.string.traffic_units_iec + else R.string.traffic_units_si, + ) + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = trafficUnitSystem == unitSystem, + role = Role.RadioButton, + onValueChange = { + trafficUnitSystem = unitSystem + TrafficUnitPreferences.set(activity, unitSystem) + showTrafficUnitsDialog = false + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.RadioButton(trafficUnitSystem == unitSystem, null) + Text(label) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showTrafficUnitsDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/net/megaproxy487/TrafficFormatting.kt b/app/src/main/java/net/megaproxy487/TrafficFormatting.kt new file mode 100644 index 0000000..7c0d409 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/TrafficFormatting.kt @@ -0,0 +1,38 @@ +package net.megaproxy487 + +import java.util.Locale + +internal enum class TrafficUnitSystem { + IEC, + SI, +} + +internal fun formatTrafficBytes( + bytes: Long, + unitSystem: TrafficUnitSystem = TrafficUnitSystem.IEC, + locale: Locale = Locale.getDefault(), +): String { + val base = if (unitSystem == TrafficUnitSystem.IEC) 1_024 else 1_000 + val units = if (unitSystem == TrafficUnitSystem.IEC) { + arrayOf("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB") + } else { + arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB") + } + var value = bytes.coerceAtLeast(0).toDouble() + var unitIndex = 0 + while (value >= base && unitIndex < units.lastIndex) { + value /= base + unitIndex++ + } + return if (unitIndex == 0) { + "${value.toLong()} ${units[unitIndex]}" + } else { + String.format(locale, "%.1f %s", value, units[unitIndex]) + } +} + +internal fun formatTrafficRate( + bytesPerSecond: Double, + unitSystem: TrafficUnitSystem = TrafficUnitSystem.IEC, + locale: Locale = Locale.getDefault(), +): String = "${formatTrafficBytes(bytesPerSecond.coerceAtLeast(0.0).toLong(), unitSystem, locale)}/s" diff --git a/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt b/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt new file mode 100644 index 0000000..e368250 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt @@ -0,0 +1,21 @@ +package net.megaproxy487 + +import android.content.Context + +internal object TrafficUnitPreferences { + private const val PREFERENCES = "display_preferences" + private const val KEY_TRAFFIC_UNITS = "traffic_units" + + fun current(context: Context): TrafficUnitSystem { + val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .getString(KEY_TRAFFIC_UNITS, null) + return TrafficUnitSystem.entries.firstOrNull { it.name == stored } ?: TrafficUnitSystem.IEC + } + + fun set(context: Context, unitSystem: TrafficUnitSystem) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_TRAFFIC_UNITS, unitSystem.name) + .apply() + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 196b777..339d494 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -161,8 +161,8 @@ Обходить локальные сети Прямой TCP-доступ к частным и локальным IP-адресам. Поиск приложений - Размер журнала с ротацией (МБ) - Общий размер двух сегментов журнала: 1–100 МБ. + Размер журнала с ротацией (MiB) + Общий размер двух сегментов журнала: 1–100 MiB. Диагностических событий пока нет. Очистить диагностический журнал? Оба сегмента журнала будут удалены. @@ -216,6 +216,35 @@ MegaProxy активенПроверить ключ SSH Постоянный VPN остаётся активным при автоматическом переключении. Каждое переключение отображается в постоянном уведомлении и на главном экране. Постоянный VPN остаётся активным при автоматическом переключении. В уведомлении и на главном экране отображается фактически используемый профиль. - Интервал keepalive SSH, секунд (0 — отключено)Максимум параллельных каналов SSHСменить SSH-сессию через минут (0 — отключено)Сменить SSH-сессию после МБ (0 — отключено) + Интервал keepalive SSH, секунд (0 — отключено)Максимум параллельных каналов SSHСменить SSH-сессию через минут (0 — отключено)Сменить SSH-сессию после MiB (0 — отключено) Не удалось открыть почтовое приложениеJA3 должен содержать пять полей: версия,шифры,расширения,группы,точки + Получено + Отправлено + Всего: %1$s + Единицы трафика + Единицы скорости и суммарного трафика · %1$s + Двоичные IEC (KiB, MiB, GiB · 1024) + Десятичные SI (KB, MB, GB · 1000) + Задержка прокси + %1$d мс%2$s + Ошибки соединения: нет данных + Ошибки соединения: %1$.1f%% · %2$s + + %1$d попытка + %1$d попытки + %1$d попыток + %1$d попытки + + + · %1$d секунду назад + · %1$d секунды назад + · %1$d секунд назад + · %1$d секунды назад + + + · %1$d минуту назад + · %1$d минуты назад + · %1$d минут назад + · %1$d минуты назад + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e85ddf5..3f47f06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,8 +162,8 @@ Bypass local networks Direct TCP access to private and link-local IP addresses. Search applications - Rotated log limit (MB) - Total size across both log segments; 1–100 MB. + Rotated log limit (MiB) + Total size across both log segments; 1–100 MiB. No diagnostic events yet. Clear diagnostic log? Both rotated log segments will be deleted. @@ -217,6 +217,29 @@ MegaProxy is activeReview SSH key Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen. Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use. - SSH keepalive, seconds (0 = disabled)Maximum parallel SSH channelsRotate SSH session after minutes (0 = disabled)Rotate SSH session after MB (0 = disabled) + SSH keepalive, seconds (0 = disabled)Maximum parallel SSH channelsRotate SSH session after minutes (0 = disabled)Rotate SSH session after MiB (0 = disabled) Could not open an email clientJA3 must contain five fields: version,ciphers,extensions,groups,points + Download + Upload + Total: %1$s + Traffic units + Units used for speed and total traffic · %1$s + IEC binary (KiB, MiB, GiB · 1024) + SI decimal (KB, MB, GB · 1000) + Proxy latency + %1$d ms%2$s + Connection errors: no samples + Connection errors: %1$.1f%% · %2$s + + %1$d sample + %1$d samples + + + · %1$d sec ago + · %1$d sec ago + + + · %1$d min ago + · %1$d min ago + diff --git a/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt b/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt new file mode 100644 index 0000000..cc7b44f --- /dev/null +++ b/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt @@ -0,0 +1,40 @@ +package net.megaproxy487 + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Test + +class TrafficFormattingTest { + @Test + fun formatsByteTotalsUsingIecUnitsByDefault() { + assertEquals("0 B", formatTrafficBytes(0, locale = Locale.US)) + assertEquals("1023 B", formatTrafficBytes(1_023, locale = Locale.US)) + assertEquals("1.0 KiB", formatTrafficBytes(1_024, locale = Locale.US)) + assertEquals("1.5 MiB", formatTrafficBytes(1_572_864, locale = Locale.US)) + assertEquals("2.5 GiB", formatTrafficBytes(2_684_354_560, locale = Locale.US)) + assertEquals("1.0 TiB", formatTrafficBytes(1L shl 40, locale = Locale.US)) + assertEquals("1.0 PiB", formatTrafficBytes(1L shl 50, locale = Locale.US)) + assertEquals("1.0 EiB", formatTrafficBytes(1L shl 60, locale = Locale.US)) + } + + @Test + fun formatsByteTotalsUsingOptionalSiUnits() { + assertEquals("999 B", formatTrafficBytes(999, TrafficUnitSystem.SI, Locale.US)) + assertEquals("1.0 KB", formatTrafficBytes(1_000, TrafficUnitSystem.SI, Locale.US)) + assertEquals("1.5 MB", formatTrafficBytes(1_500_000, TrafficUnitSystem.SI, Locale.US)) + assertEquals("2.5 GB", formatTrafficBytes(2_500_000_000, TrafficUnitSystem.SI, Locale.US)) + } + + @Test + fun usesTheSelectedLocaleForFractions() { + assertEquals("1,5 MiB", formatTrafficBytes(1_572_864, locale = Locale.forLanguageTag("ru"))) + } + + @Test + fun formatsRatesAndClampsInvalidNegativeCounters() { + assertEquals("1.5 MiB/s", formatTrafficRate(1_572_864.0, locale = Locale.US)) + assertEquals("1.5 MB/s", formatTrafficRate(1_500_000.0, TrafficUnitSystem.SI, Locale.US)) + assertEquals("0 B/s", formatTrafficRate(-1.0, locale = Locale.US)) + assertEquals("0 B", formatTrafficBytes(-1, locale = Locale.US)) + } +}