android: Home scenario hints as in the Final 2026 mockup (trusted network pill, rule suggestion), profile update from the error and expiry cards, a stop button on errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 15:58:57 +07:00
1 parent 1a6fb85259
commit dcf8ace6e1
6 files changed
+282 -3

No files matched your search

@@ -263,6 +263,14 @@ fun HomeScreen(
val today by produceState<org.strongswan.android.logic.stats.DayTraffic?>(null, historyChanges) {
value = withContext(Dispatchers.IO) { org.strongswan.android.logic.stats.TrafficHistory.today(context) }
}
val ssid by viewModel.ssid.collectAsStateWithLifecycle()
var hintTick by remember { mutableStateOf(0) }
val hint by produceState<org.strongswan.android.ui.compose.home.ScenarioHint?>(null, ssid, state.vpnState, hintTick) {
value = withContext(Dispatchers.IO) { org.strongswan.android.ui.compose.home.ScenarioHints.hint(context, ssid) }
}
val extras by remember { org.strongswan.android.logic.profiles.ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val shownUuid = (state.activeProfile ?: selected)?.getUUID()?.toString()
val hasLink = shownUuid != null && extras[shownUuid]?.source == org.strongswan.android.logic.profiles.ProfileSourceType.LINK
val model = HomeModel(
state = state,
traffic = traffic,
@@ -280,6 +288,8 @@ fun HomeScreen(
certExpiry = certExpiry,
connectStep = connectStep,
today = today,
hint = hint,
hasLink = hasLink,
)
val actions = HomeActions(
connect = { selected?.let { viewModel.connect(context, it) } },
@@ -320,8 +330,21 @@ fun HomeScreen(
}
},
details = { viewModel.openDetails(true) },
certUpdate = { ShxNavigation.requestAddServer() },
certUpdate = {
if (hasLink && shownUuid != null) context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, shownUuid, checkUpdate = true))
else ShxNavigation.requestAddServer()
},
updateProfile = { shownUuid?.let { context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, it, checkUpdate = true)) } },
stats = { context.startActivity(Intent(context, org.strongswan.android.ui.StatsActivity::class.java)) },
scenarios = { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
suggestYes = {
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.accept(context, it, selected?.getUUID()?.toString()) }
hintTick++
},
suggestNo = {
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.decline(context, it) }
hintTick++
},
certLater = {
certAlias?.let { snoozeCertExpiry(context, it) }
certSnoozes++
@@ -179,6 +179,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
/* (Wi-Fi up, mobile data up) for the "no internet" checklist */
private val _networkKinds = MutableStateFlow(false to false)
val networkKinds: StateFlow<Pair<Boolean, Boolean>> = _networkKinds
/* the current Wi-Fi network (null off Wi-Fi or without location access) */
private val _ssid = MutableStateFlow<String?>(null)
val ssid: StateFlow<String?> = _ssid
private val underlyingNetworks = HashMap<Network, NetworkCapabilities?>()
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
@@ -216,6 +220,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
val caps = underlyingNetworks.values.filterNotNull()
_networkKinds.value = caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } to
caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) }
val wifi = caps.firstOrNull { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }
_ssid.value = wifi?.let { org.strongswan.android.logic.autoconnect.SsidReader.from(getApplication(), it) }
}
/* true while «Fastest» pings the servers */
@@ -436,6 +442,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun connect(context: Context, profile: VpnProfile)
{
org.strongswan.android.ui.compose.home.ScenarioHints.manualOn(context)
val intent = Intent(context, VpnProfileControlActivity::class.java)
intent.action = VpnProfileControlActivity.START_PROFILE
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
@@ -444,6 +451,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun disconnect()
{
/* counted per Wi-Fi for the «turn it off here automatically?» suggestion */
org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null)
service?.disconnect()
}
@@ -15,6 +15,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.ui.semantics.Role
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -66,6 +70,10 @@ internal data class HomeModel(
val connectStep: Int = 1,
/* today's traffic from the history */
val today: org.strongswan.android.logic.stats.DayTraffic? = null,
/* network rules while off (F_HomeScenario / F_ScenarioSuggest) */
val hint: ScenarioHint? = null,
/* the shown profile came from a link (it can be re-read) */
val hasLink: Boolean = false,
)
/** What Home can do. */
@@ -90,6 +98,10 @@ internal class HomeActions(
val certUpdate: () -> Unit = {},
val certLater: () -> Unit = {},
val stats: () -> Unit = {},
val scenarios: () -> Unit = {},
val suggestYes: () -> Unit = {},
val suggestNo: () -> Unit = {},
val updateProfile: () -> Unit = {},
)
/** Two-letter badge of a server: the exit country when known, else the profile initials. */
@@ -199,7 +211,18 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
else ->
{
HeroBlob(heroLookIdle(), ShxIcons.Power, stringResource(R.string.connect), a.connect, enabled = profile != null)
HeroCaption(stringResource(R.string.home_state_off), stringResource(R.string.home_state_off_status))
val hint = m.hint
when
{
hint?.trustedHere == true -> Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp))
{
Text(stringResource(R.string.home_state_off), style = ShxType.heroState, color = c.text)
ScenarioPill(stringResource(R.string.hint_scenario_pill, hint.ssid), a.scenarios)
Text(stringResource(if (hint.autoOnLeave) R.string.hint_on_leave else R.string.hint_trusted_off), style = ShxType.sub, color = c.textSub)
}
hint?.manualOffHere == true -> HeroCaption(stringResource(R.string.home_state_off), stringResource(R.string.hint_manual_off, hint.ssid), dot = c.iconMuted)
else -> HeroCaption(stringResource(R.string.home_state_off), stringResource(R.string.home_state_off_status))
}
}
}
}
@@ -216,6 +239,8 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
connected && m.exit?.ip != null ->
if (m.serverRttMs != null) stringResource(R.string.home_server_ip_rtt, maskIp(m.exit.ip), m.serverRttMs)
else stringResource(R.string.home_server_ip, maskIp(m.exit.ip))
!connected && !connecting && m.hint?.autoOnLeave == true -> stringResource(R.string.hint_server_on_leave, m.hint.ssid)
!connected && !connecting && m.hint?.manualOffHere == true -> stringResource(R.string.hint_last_server)
else -> profile?.gateway ?: ""
}
ServerCard(serverCode(profile, m.exit), name, details, stringResource(R.string.home_server_change), a.pickServer)
@@ -296,13 +321,27 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_text
else -> R.string.home_problem_generic_text
}),
primary = if (auth) stringResource(R.string.home_problem_edit_profile) to a.editProfile else stringResource(R.string.retry) to a.retry,
primary = when
{
/* F_ErrAuth: a profile from a link gets its new version */
(auth || m.problem == HomeProblem.SERVER_CERT) && m.hasLink -> stringResource(R.string.home_problem_update_profile) to a.updateProfile
auth -> stringResource(R.string.home_problem_edit_profile) to a.editProfile
else -> stringResource(R.string.retry) to a.retry
},
secondary = stringResource(R.string.show_log) to a.showLog,
)
if (s.profiles.size > 1)
{
ShxButton(stringResource(R.string.home_other_server), a.pickServer, style = ShxButtonStyle.TONAL, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
/* the retries go on until stopped: a way out without the notification */
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(44.dp).clip(RoundedCornerShape(14.dp)).clickable(role = Role.Button, onClick = a.disconnect),
)
{
Text(stringResource(R.string.home_stop_vpn), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText)
}
}
connected ->
{
@@ -357,6 +396,9 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
QuickTile(ShxIcons.Globe, stringResource(R.string.home_action_switch), a.pickServer, Modifier.weight(1f))
}
}
m.hint?.takeIf { it.suggest > 0 }?.let { h ->
ScenarioSuggestCard(h.ssid, h.suggest, a.suggestYes, a.suggestNo)
}
/* F_HomeScenario: today's traffic stays visible while off */
m.today?.takeIf { it.total > 0 }?.let { today ->
val share = if (today.direct > 0) (today.vpn * 100 / today.total).toInt() else null
@@ -0,0 +1,181 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.home
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** What Home says about the network rules while the VPN is off (F_HomeScenario / F_ScenarioSuggest). */
internal data class ScenarioHint(
val ssid: String,
/** off because this Wi-Fi is trusted */
val trustedHere: Boolean,
/** and it comes back on its own when leaving */
val autoOnLeave: Boolean,
/** the user switched it off by hand here last time */
val manualOffHere: Boolean,
/** how often by hand here, when worth suggesting a rule (≥ 3), else 0 */
val suggest: Int,
)
/** Remembers the manual switch-offs per Wi-Fi network for the suggestion. */
object ScenarioHints
{
private const val PREF_COUNT = "pref_manual_off_count_"
private const val PREF_DECLINED = "pref_manual_off_declined_"
private const val PREF_LAST = "pref_manual_off_last"
internal const val SUGGEST_AFTER = 3
/** The user turned the VPN off on [ssid]. */
fun manualOff(context: Context, ssid: String?)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (ssid == null)
{
prefs.edit().remove(PREF_LAST).apply()
return
}
prefs.edit().putInt(PREF_COUNT + ssid, prefs.getInt(PREF_COUNT + ssid, 0) + 1).putString(PREF_LAST, ssid).apply()
}
/** The user turned it on again: the «off by hand» line goes away. */
fun manualOn(context: Context)
{
PreferenceManager.getDefaultSharedPreferences(context).edit().remove(PREF_LAST).apply()
}
fun decline(context: Context, ssid: String)
{
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(PREF_DECLINED + ssid, true).apply()
}
/** «Yes, create the scenario»: the network becomes trusted and both rules are on. */
fun accept(context: Context, ssid: String, profileUuid: String?)
{
AutoConnectManager.getInstance(context).updateConfig {
it.copy(
enabled = true, parkOnTrusted = true, connectOnUntrusted = true,
trustedSsids = it.trustedSsids + ssid, profileUuid = it.profileUuid ?: profileUuid,
)
}
decline(context, ssid)
}
internal fun hint(context: Context, ssid: String?): ScenarioHint?
{
ssid ?: return null
val config = AutoConnectManager.getInstance(context).currentConfig
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val trusted = config.enabled && config.parkOnTrusted && config.trustedSsids.any { it.equals(ssid, ignoreCase = true) }
val count = prefs.getInt(PREF_COUNT + ssid, 0)
val suggest = if (!trusted && count >= SUGGEST_AFTER && !prefs.getBoolean(PREF_DECLINED + ssid, false)) count else 0
return ScenarioHint(
ssid = ssid,
trustedHere = trusted,
autoOnLeave = trusted && config.connectOnUntrusted,
manualOffHere = prefs.getString(PREF_LAST, null) == ssid,
suggest = suggest,
)
}
}
/** «Scenario · Home_5G» pill under the hero (F_HomeScenario). */
@Composable
internal fun ScenarioPill(text: String, onClick: () -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
.clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 8.dp),
)
{
Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.onAccentContainer, maxLines = 1)
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(c.card))
{
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(14.dp))
}
}
}
/** «Turn the VPN off at home automatically?» (F_ScenarioSuggest). */
@Composable
internal fun ScenarioSuggestCard(ssid: String, count: Int, onYes: () -> Unit, onNo: () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.accentSoft).border(1.dp, c.accentBorder, shape).padding(16.dp),
)
{
Row(horizontalArrangement = Arrangement.spacedBy(12.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(c.accent))
{
Icon(ShxIcons.Wifi, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(stringResource(R.string.suggest_title), style = ShxType.cardTitle, color = c.text)
Text(stringResource(R.string.suggest_text, count, ssid), style = ShxType.note.copy(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(2f).height(44.dp).clip(RoundedCornerShape(14.dp)).background(c.accent).clickable(role = Role.Button, onClick = onYes),
)
{
Text(stringResource(R.string.suggest_yes), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = Color.White, maxLines = 1)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).height(44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp)).clickable(role = Role.Button, onClick = onNo),
)
{
Text(stringResource(R.string.suggest_no), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1)
}
}
}
}
@@ -1280,4 +1280,16 @@
<string name="scenario_often">Часто открываете</string>
<string name="scenario_all_apps">Все приложения</string>
<string name="scenario_done">Готово</string>
<string name="hint_scenario_pill">Сценарий · %1$s</string>
<string name="hint_on_leave">Включится сам, когда уйдёте из этой сети</string>
<string name="hint_trusted_off">В доверенной сети VPN не нужен</string>
<string name="hint_server_on_leave">подключится при выходе из %1$s</string>
<string name="hint_manual_off">Выключен вручную · %1$s</string>
<string name="hint_last_server">последний сервер</string>
<string name="suggest_title">Выключать VPN здесь автоматически?</string>
<string name="suggest_text">Вы уже %1$d-й раз выключаете его в сети %2$s. Вне этой сети VPN будет включаться сам.</string>
<string name="suggest_yes">Да, создать сценарий</string>
<string name="suggest_no">Не надо</string>
<string name="home_problem_update_profile">Обновить профиль</string>
<string name="home_stop_vpn">Отключить VPN</string>
</resources>
@@ -1266,4 +1266,16 @@
<string name="scenario_often">Often opened</string>
<string name="scenario_all_apps">All apps</string>
<string name="scenario_done">Done</string>
<string name="hint_scenario_pill">Scenario · %1$s</string>
<string name="hint_on_leave">Turns on by itself when you leave this network</string>
<string name="hint_trusted_off">Not needed in a trusted network</string>
<string name="hint_server_on_leave">connects when you leave %1$s</string>
<string name="hint_manual_off">Off by hand · %1$s</string>
<string name="hint_last_server">last server</string>
<string name="suggest_title">Turn the VPN off here automatically?</string>
<string name="suggest_text">This is the %1$d. time you turn it off in %2$s. Away from here the VPN turns on by itself.</string>
<string name="suggest_yes">Yes, create the scenario</string>
<string name="suggest_no">No thanks</string>
<string name="home_problem_update_profile">Update profile</string>
<string name="home_stop_vpn">Turn VPN off</string>
</resources>