diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts
index 0d70153..c07989b 100644
--- a/V2rayNG/app/build.gradle.kts
+++ b/V2rayNG/app/build.gradle.kts
@@ -16,7 +16,7 @@ android {
minSdk = 24
targetSdk = 37
versionCode = 749
- versionName = "2.3.9-cosmo.16"
+ versionName = "2.3.9-cosmo.17"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt
index ea445a4..08cbf26 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt
@@ -144,8 +144,21 @@ fun CosmoMainScreen(
else onAction(MainAction.UpdateSubscriptions)
}
- // No bottom bar: settings (gear, top-left) and "My subscription" (ⓘ in the card) open as separate screens.
- Scaffold { padding ->
+ // Bottom bar: Home + My subscription; Settings opens from the gear (top-left) as a separate screen.
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ CosmoTab.entries.forEachIndexed { index, item ->
+ NavigationBarItem(
+ selected = tab == index,
+ onClick = { tab = index },
+ icon = { Icon(painterResource(item.icon), contentDescription = null) },
+ label = { Text(stringResource(item.label)) },
+ )
+ }
+ }
+ }
+ ) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (tab) {
0 -> CosmoHomeTab(
@@ -156,14 +169,12 @@ fun CosmoMainScreen(
onOpenSubscription = { tab = 1 },
onOpenSettings = { tab = 2 },
)
- 1 -> CosmoSubPage(onBack = { tab = 0 }) {
- CosmoSubscriptionTab(
- ui = ui,
- serverCount = group.rows.size,
- onAction = onAction,
- onRenew = { showRenew = true },
- )
- }
+ 1 -> CosmoSubscriptionTab(
+ ui = ui,
+ serverCount = group.rows.size,
+ onAction = onAction,
+ onRenew = { showRenew = true },
+ )
else -> CosmoSubPage(onBack = { tab = 0 }) {
CosmoSettingsTab(
onAction = onAction,
@@ -176,7 +187,12 @@ fun CosmoMainScreen(
}
}
-/** Secondary screen with a back arrow (Home has no bottom bar). */
+private enum class CosmoTab(@DrawableRes val icon: Int, @StringRes val label: Int) {
+ Home(R.drawable.ic_cosmo_home_24dp, R.string.cosmo_tab_home),
+ Subscription(R.drawable.ic_subscriptions_24dp, R.string.cosmo_tab_subscription),
+}
+
+/** Secondary screen with a back arrow (Settings, opened from the gear). */
@Composable
private fun CosmoSubPage(onBack: () -> Unit, content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize()) {
@@ -335,26 +351,44 @@ private fun CosmoHomeTab(
color = if (state == CosmoConnState.Connected) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
+ // Make it obvious that the rocket circle is the on/off button.
+ Text(
+ stringResource(
+ when (state) {
+ CosmoConnState.Connected -> R.string.cosmo_tap_to_disconnect
+ CosmoConnState.Connecting -> R.string.cosmo_hint_connecting
+ CosmoConnState.Disconnected -> R.string.cosmo_tap_to_connect
+ }
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
}
}
// ---- One card: header (name + update/test icons), info line, fastest, servers ----
item {
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(cardBg)) {
- Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp), verticalAlignment = Alignment.CenterVertically) {
- Text(
- stringResource(R.string.app_name),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
+ Row(
+ Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CosmoSmallButton(
+ icon = R.drawable.ic_cosmo_refresh_24dp,
+ text = stringResource(R.string.cosmo_update_sub),
+ onClick = { onAction(MainAction.UpdateSubscriptions) },
+ modifier = Modifier.weight(1f),
+ )
+ CosmoSmallButton(
+ icon = R.drawable.ic_cosmo_speed_24dp,
+ text = stringResource(R.string.cosmo_check_servers),
+ busy = ui.isTesting && !findingFastest,
+ enabled = !ui.isTesting,
+ onClick = { onAction(MainAction.TestRealAllServers) },
modifier = Modifier.weight(1f),
)
- IconButton(onClick = { onAction(MainAction.UpdateSubscriptions) }) {
- Icon(painterResource(R.drawable.ic_cosmo_refresh_24dp), contentDescription = stringResource(R.string.cosmo_update_sub), tint = MaterialTheme.colorScheme.primary)
- }
- IconButton(onClick = { onAction(MainAction.TestRealAllServers) }, enabled = !ui.isTesting) {
- if (ui.isTesting && !findingFastest) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
- else Icon(painterResource(R.drawable.ic_cosmo_speed_24dp), contentDescription = stringResource(R.string.cosmo_check_servers), tint = MaterialTheme.colorScheme.primary)
- }
IconButton(onClick = { listExpanded = !listExpanded }) {
Icon(
painterResource(R.drawable.ic_expand_more_24dp),
@@ -363,7 +397,7 @@ private fun CosmoHomeTab(
)
}
}
- CosmoSubscriptionInfoRow(subInfo, onOpenSubscription)
+ CosmoSubscriptionInfoRow(subInfo)
}
}
if (listExpanded) {
@@ -422,18 +456,46 @@ private fun CosmoHomeTab(
}
}
-/** Compact line under the card header: traffic | expiry | details (ⓘ) | support. */
+/** Small outlined button with an icon and a short label (card header). */
@Composable
-private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?, onOpenSubscription: () -> Unit) {
- val context = LocalContext.current
- Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically) {
+private fun CosmoSmallButton(
+ @DrawableRes icon: Int,
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ busy: Boolean = false,
+) {
+ OutlinedButton(
+ onClick = onClick,
+ enabled = enabled,
+ shape = RoundedCornerShape(12.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
+ modifier = modifier.height(40.dp),
+ ) {
+ if (busy) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
+ else Icon(painterResource(icon), contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(text, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+/** Compact line under the card buttons: used traffic / limit (∞) on the left, expiry date on the right. */
+@Composable
+private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
val traffic = when {
info == null -> "—"
- info.isUnlimitedTraffic -> stringResource(R.string.cosmo_sub_unlimited)
- else -> stringResource(R.string.cosmo_sub_traffic_left, info.trafficLeftBytes.toTrafficString())
+ info.isUnlimitedTraffic -> "${(info.upload + info.download).toTrafficString()} / ∞"
+ else -> "${(info.upload + info.download).toTrafficString()} / ${info.total.toTrafficString()}"
}
- Text(traffic, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
- Spacer(Modifier.width(10.dp))
+ Box(
+ Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), RoundedCornerShape(8.dp))
+ .padding(horizontal = 10.dp, vertical = 3.dp),
+ ) {
+ Text(traffic, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
+ }
+ Spacer(Modifier.weight(1f))
val expire = info?.expire?.takeIf { it > 0 }?.times(1000)
val expired = info?.isExpired() == true
if (expire != null && expire - System.currentTimeMillis() < NO_EXPIRY_THRESHOLD_DAYS * 86_400_000L) {
@@ -442,23 +504,8 @@ private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?, onOpenSubscri
style = MaterialTheme.typography.bodyMedium,
color = if (expired) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
- modifier = Modifier.weight(1f),
)
- } else {
- Spacer(Modifier.weight(1f))
}
- Icon(
- painterResource(R.drawable.ic_cosmo_info_24dp),
- contentDescription = stringResource(R.string.cosmo_tab_subscription),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp).clip(CircleShape).clickable(onClick = onOpenSubscription).padding(6.dp),
- )
- Icon(
- painterResource(R.drawable.ic_telegram_24dp),
- contentDescription = stringResource(R.string.cosmo_mysub_support),
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp).clip(CircleShape).clickable { Utils.openUri(context, "https://t.me/cosmonetvpn_bot") }.padding(6.dp),
- )
}
}
@@ -544,15 +591,27 @@ private fun CosmoStatusPill(state: CosmoConnState, large: Boolean = false) {
CosmoConnState.Connecting -> Triple(
MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer, R.string.cosmo_state_connecting
)
- CosmoConnState.Connected -> Triple(Color(0xFFC8F5DC), Color(0xFF0F4A2C), R.string.cosmo_state_connected)
+ CosmoConnState.Connected -> Triple(Color(0xFF1FA35C), Color.White, R.string.cosmo_state_connected)
+ }
+ // Status dot: green = connected, red = not connected, amber = connecting.
+ val dot = when (state) {
+ CosmoConnState.Connected -> Color(0xFF7CFFB0)
+ CosmoConnState.Connecting -> Color(0xFFFFC14D)
+ CosmoConnState.Disconnected -> Color(0xFFFF5252)
}
Surface(color = bg, contentColor = fg, shape = RoundedCornerShape(50)) {
- Text(
- stringResource(label),
- style = if (large) MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium,
- modifier = if (large) Modifier.padding(horizontal = 16.dp, vertical = 7.dp)
- else Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
- )
+ Row(
+ Modifier.padding(horizontal = if (large) 16.dp else 10.dp, vertical = if (large) 7.dp else 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(if (large) 10.dp else 8.dp).clip(CircleShape).background(dot))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ stringResource(label),
+ style = if (large) MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium,
+ fontWeight = if (state == CosmoConnState.Connected) FontWeight.SemiBold else FontWeight.Normal,
+ )
+ }
}
}
diff --git a/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml b/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml
index 6627ea9..38f4cbf 100644
--- a/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml
+++ b/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml
@@ -3,11 +3,13 @@
Главная
Страны
Моя подписка
+ Нажмите на ракету, чтобы подключиться
+ Нажмите на ракету, чтобы отключиться
Свернуть список
Показать серверы
Истекает: %1$s
Обновить подписку
- Проверить доступность
+ Проверить серверы
Выберите сервер из списка ниже
Подписка активна
Подписка закончилась
diff --git a/V2rayNG/app/src/main/res/values/strings_cosmo.xml b/V2rayNG/app/src/main/res/values/strings_cosmo.xml
index c3cc88e..4d68747 100644
--- a/V2rayNG/app/src/main/res/values/strings_cosmo.xml
+++ b/V2rayNG/app/src/main/res/values/strings_cosmo.xml
@@ -4,6 +4,8 @@
Home
Countries
My subscription
+ Tap the rocket to connect
+ Tap the rocket to disconnect
Collapse list
Show servers
Expires: %1$s