From dcf8ace6e1e261fd00f0a3dc791d66fa1d9e09aa Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 15:58:57 +0700 Subject: [PATCH] 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 --- .../android/ui/compose/HomeScreen.kt | 25 ++- .../android/ui/compose/HomeViewModel.kt | 9 + .../android/ui/compose/home/HomeContent.kt | 46 ++++- .../android/ui/compose/home/ScenarioHints.kt | 181 ++++++++++++++++++ .../app/src/main/res/values-ru/strings.xml | 12 ++ .../app/src/main/res/values/strings.xml | 12 ++ 6 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ScenarioHints.kt diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index a9ad54deb..cca2376f1 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -263,6 +263,14 @@ fun HomeScreen( val today by produceState(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(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++ diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt index d1d07eba7..351d47a15 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt @@ -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> = _networkKinds + + /* the current Wi-Fi network (null off Wi-Fi or without location access) */ + private val _ssid = MutableStateFlow(null) + val ssid: StateFlow = _ssid private val underlyingNetworks = HashMap() 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() } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt index ea7a09e54..7ef036e1c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt @@ -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 diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ScenarioHints.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ScenarioHints.kt new file mode 100644 index 000000000..9fd0fa76a --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ScenarioHints.kt @@ -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) + } + } + } +} diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 564e6dc29..89a33ea11 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1280,4 +1280,16 @@ Часто открываете Все приложения Готово + Сценарий · %1$s + Включится сам, когда уйдёте из этой сети + В доверенной сети VPN не нужен + подключится при выходе из %1$s + Выключен вручную · %1$s + последний сервер + Выключать VPN здесь автоматически? + Вы уже %1$d-й раз выключаете его в сети %2$s. Вне этой сети VPN будет включаться сам. + Да, создать сценарий + Не надо + Обновить профиль + Отключить VPN diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index baf999a90..db975a287 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1266,4 +1266,16 @@ Often opened All apps Done + Scenario · %1$s + Turns on by itself when you leave this network + Not needed in a trusted network + connects when you leave %1$s + Off by hand · %1$s + last server + Turn the VPN off here automatically? + This is the %1$d. time you turn it off in %2$s. Away from here the VPN turns on by itself. + Yes, create the scenario + No thanks + Update profile + Turn VPN off