android: home phase 1 - header, quick actions, pause, problem cards

- Connect tab gets the Shield-S header and, while connected, three quick
  actions: pause, switch server, "site not loading?" (route check).
- Pause VPN for 5/15/30/60 min (PauseManager + PauseReceiver): disconnects
  like a manual stop and reconnects on an alarm; auto-connect waits, any
  connect request cancels the pause. Home shows a countdown, "turn on now"
  and "+15 minutes".
- Connection errors become plain-language cards with one fixing action:
  no internet (tracked via a non-VPN network callback), server not
  responding (with the retry countdown and "other server"), server not
  found, sign-in rejected, server not verified, generic failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-11 21:36:34 +07:00
1 parent 8f21e16277
commit 75849beb90
10 files changed
+998 -3

No files matched your search

@@ -293,6 +293,10 @@
<receiver
android:name=".logic.WatchdogReceiver"
android:exported="false" />
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
<receiver
android:name=".logic.PauseReceiver"
android:exported="false" />
<!-- runs after the app has updated itself (the system kills the
process during the install): cleans up and lets auto-connect
restore the VPN -->
@@ -0,0 +1,232 @@
/*
* 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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.data.VpnProfileDataSource
/**
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
* disconnect (the watchdog stands down) and an alarm brings the same profile
* back when the pause ends. Auto-connect does not start the tunnel while a
* pause is running, and any connect request cancels the pause.
*/
object PauseManager
{
private const val TAG = "PauseManager"
private const val PREF_UNTIL = "pref_pause_until"
private const val PREF_PROFILE = "pref_pause_profile"
private const val REQUEST_CODE = 45
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
private val _until = MutableStateFlow(0L)
val until: StateFlow<Long> = _until
@Volatile private var loaded = false
private fun load(context: Context)
{
if (!loaded)
{
val until = PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_UNTIL, 0L)
_until.value = if (until > System.currentTimeMillis()) until else 0L
loaded = true
}
}
/** The flow of the pause end, initialised from the stored state. */
@JvmStatic
fun until(context: Context): StateFlow<Long>
{
load(context.applicationContext)
return until
}
@JvmStatic
fun isPaused(context: Context): Boolean
{
load(context.applicationContext)
return _until.value > System.currentTimeMillis()
}
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
@JvmStatic
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
{
val ctx = context.applicationContext
service.disconnect()
store(ctx, System.currentTimeMillis() + minutes * 60_000L, profileUuid)
DiagLog.i(TAG, "VPN paused for $minutes min")
}
/** Moves the end of the running pause by [minutes]. */
@JvmStatic
fun extend(context: Context, minutes: Int)
{
val ctx = context.applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
val profile = prefs.getString(PREF_PROFILE, null) ?: return
if (!isPaused(ctx))
{
return
}
store(ctx, _until.value + minutes * 60_000L, profile)
}
/** Ends the pause right away and reconnects the paused profile. */
@JvmStatic
fun resumeNow(context: Context)
{
val ctx = context.applicationContext
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
cancel(ctx)
if (profile != null)
{
reconnect(ctx, profile)
}
}
/**
* Drops the pause without connecting. Called for every connect request,
* so a manual connect (app, tile, widget, shortcut) wins over the pause.
*/
@JvmStatic
fun cancel(context: Context)
{
val ctx = context.applicationContext
load(ctx)
if (_until.value == 0L)
{
return
}
_until.value = 0L
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
alarmManager(ctx).cancel(alarmIntent(ctx))
}
private fun store(ctx: Context, until: Long, profileUuid: String)
{
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.putLong(PREF_UNTIL, until).putString(PREF_PROFILE, profileUuid).apply()
_until.value = until
loaded = true
val am = alarmManager(ctx)
val pi = alarmIntent(ctx)
try
{
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
}
catch (_: SecurityException)
{ /* the exact-alarm grant was revoked in between; a late resume is fine */
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
}
private fun alarmManager(ctx: Context) = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private fun alarmIntent(ctx: Context): PendingIntent =
PendingIntent.getBroadcast(
ctx, REQUEST_CODE, Intent(ctx, PauseReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Called by [PauseReceiver] when the alarm fires. */
internal fun onAlarm(context: Context)
{
val ctx = context.applicationContext
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
cancel(ctx)
if (profile == null)
{
return
}
DiagLog.i(TAG, "pause over, reconnecting")
reconnect(ctx, profile)
}
private fun reconnect(ctx: Context, profileUuid: String)
{
val needsConsent = try
{
VpnService.prepare(ctx) != null
}
catch (_: Exception)
{
true
}
if (needsConsent)
{ /* the VPN permission went to another app meanwhile; only the UI can ask again */
DiagLog.i(TAG, "pause over, but the VPN permission is gone")
return
}
val service = VpnStateService.getLiveInstance()
if (service != null)
{ /* a live process: the regular connect path (keeps retries and state) */
if (service.state == VpnStateService.State.DISABLED)
{
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
service.connect(extras, true)
}
return
}
/* the process is gone (the alarm started a fresh one): start the
* service directly, like the watchdog does */
val start = Intent(ctx, CharonVpnService::class.java)
start.putExtra(VpnProfileDataSource.KEY_UUID, profileUuid)
try
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
ctx.startForegroundService(start)
}
else
{
ctx.startService(start)
}
}
catch (_: IllegalStateException)
{
FgsStartRetry.schedule(ctx, start)
}
}
}
/** Fires when a pause ends and brings the tunnel back. */
class PauseReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent?)
{
PauseManager.onAlarm(context)
}
}
@@ -431,6 +431,8 @@ public class VpnStateService extends Service
{
/* we assume we have the necessary permission */
Context context = getApplicationContext();
/* any connect request (app, tile, widget, shortcut, pause alarm) ends a running pause */
PauseManager.cancel(context);
Intent intent = new Intent(context, CharonVpnService.class);
if (profileInfo == null)
{
@@ -36,6 +36,7 @@ import org.json.JSONObject
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager
@@ -238,11 +239,15 @@ class AutoConnectManager private constructor(
/**
* Pure decision function, unit-testable.
*/
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
evaluateDecision(
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
{
val action = evaluateDecision(
config, network, vpnActive, autoSession,
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
)
/* the user paused the VPN: the pause alarm brings it back, not a network change */
return if (action == Action.CONNECT && PauseManager.isPaused(context)) Action.IGNORE else action
}
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
fun reevaluate()
@@ -140,6 +140,22 @@ import androidx.compose.ui.unit.IntSize
import org.strongswan.android.ui.compose.home.AvailabilityCard
import org.strongswan.android.ui.compose.home.HeroConnect
import org.strongswan.android.ui.compose.home.HomeBannerSlot
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
import org.strongswan.android.ui.compose.home.HomeHeader
import org.strongswan.android.ui.compose.home.HomeProblem
import org.strongswan.android.ui.compose.home.HomeQuickActions
import org.strongswan.android.ui.compose.home.PauseCard
import org.strongswan.android.ui.compose.home.PauseSheet
import org.strongswan.android.ui.compose.home.ProblemCard
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.home.formatCountdown
import org.strongswan.android.ui.compose.home.homeProblem
import org.strongswan.android.ui.compose.home.rememberNow
import org.strongswan.android.ui.compose.home.titleRes
import org.strongswan.android.ui.RouteCheckActivity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import org.strongswan.android.ui.compose.home.ProfileChip
import org.strongswan.android.ui.compose.home.RTT_HISTORY
import org.strongswan.android.ui.compose.home.TrafficTiles
@@ -169,6 +185,8 @@ fun HomeScreen(
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
val exit by viewModel.exit.collectAsStateWithLifecycle()
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
val pauseUntil by viewModel.pauseUntil.collectAsStateWithLifecycle()
val networkAvailable by viewModel.networkAvailable.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
/* the map is updated one target at a time as the parallel probes finish:
@@ -206,14 +224,24 @@ fun HomeScreen(
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
var pickerOpen by remember { mutableStateOf(false) }
var pauseSheetOpen by remember { mutableStateOf(false) }
val connected = state.vpnState == State.CONNECTED
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
val hasError = state.error != ErrorState.NO_ERROR
val selected = state.selected
/* a pause only matters while the tunnel is actually down */
val pauseActive = pauseUntil > 0L && state.vpnState == State.DISABLED
val now = if (pauseActive) rememberNow() else 0L
val paused = pauseActive && pauseUntil > now
val problem = if (paused) null else homeProblem(state, networkAvailable)
/* "a site does not open": the route check shows which way a domain goes */
val openRouteCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
val durationSec = if (connected) traffic.durationSec else 0L
val status = LocalShxStatusColors.current
val statusText = when
{
paused -> stringResource(R.string.home_paused_title, formatCountdown(pauseUntil - now))
problem != null -> stringResource(problem.titleRes())
hasError -> stringResource(state.errorText)
connected -> stringResource(R.string.status_protected)
connecting && state.vpnState == State.DISCONNECTING -> stringResource(R.string.state_disconnecting)
@@ -222,6 +250,8 @@ fun HomeScreen(
}
val statusColor = when
{
paused -> MaterialTheme.colorScheme.onSurfaceVariant
problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP -> status.connecting
hasError -> MaterialTheme.colorScheme.error
connected -> status.connected
connecting -> status.connecting
@@ -229,6 +259,8 @@ fun HomeScreen(
}
val subtitle = when
{
paused -> stringResource(R.string.home_paused_subtitle, formatClock(context, pauseUntil))
problem == HomeProblem.NO_NETWORK -> stringResource(R.string.home_problem_offline_subtitle)
connected -> "${selected?.name ?: ""} · ${formatDuration(durationSec)}"
connecting -> selected?.name ?: ""
else -> stringResource(R.string.status_choose_profile)
@@ -242,6 +274,9 @@ fun HomeScreen(
StatusGlowBackground(color = statusColor, active = connected || connecting)
val wide = LocalConfiguration.current.screenWidthDp >= 840
val leftItems: LazyListScope.() -> Unit = {
item(key = "header") {
HomeHeader()
}
item(key = "hero") {
Column(
modifier = Modifier
@@ -307,6 +342,32 @@ fun HomeScreen(
)
}
}
item(key = "actions") {
AnimatedVisibility(
visible = connected,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
HomeQuickActions(
onPause = { pauseSheetOpen = true },
onSwitch = { pickerOpen = true },
onDiagnose = openRouteCheck,
)
}
}
item(key = "pause") {
AnimatedVisibility(
visible = paused,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
PauseCard(
until = pauseUntil,
onResume = { viewModel.resumeNow() },
onExtend = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
)
}
}
item(key = "traffic") {
AnimatedVisibility(
visible = connected,
@@ -318,9 +379,44 @@ fun HomeScreen(
}
}
val rightItems: LazyListScope.() -> Unit = {
item(key = "problem") {
AnimatedVisibility(
visible = problem != null,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
/* keep the last problem while the card animates out */
val shown = remember { mutableStateOf(HomeProblem.GENERIC) }
problem?.let { shown.value = it }
ProblemCard(
problem = shown.value,
retryIn = state.retryIn,
hasOtherProfiles = state.profiles.size > 1,
onRetry = { viewModel.reconnect() },
onSwitch = { pickerOpen = true },
onEditProfile = { selected?.let(onEditProfile) },
onShowLog = onShowLog,
onNetworkSettings = {
val panel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
else
Intent(Settings.ACTION_WIRELESS_SETTINGS)
try
{
context.startActivity(panel)
}
catch (_: android.content.ActivityNotFoundException)
{
context.startActivity(Intent(Settings.ACTION_SETTINGS))
}
},
)
}
}
item(key = "banner") {
HomeBannerSlot(
error = hasError,
/* connection errors have their own card above */
error = false,
whiteListActive = whiteListActive,
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
@@ -409,6 +505,17 @@ fun HomeScreen(
)
}
if (pauseSheetOpen)
{
PauseSheet(
onDismiss = { pauseSheetOpen = false },
onPick = { minutes ->
pauseSheetOpen = false
viewModel.pause(minutes)
},
)
}
if (state.detailsOpen)
{
ConnectionDetailsSheet(
@@ -24,7 +24,9 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.IBinder
import android.os.SystemClock
import android.widget.Toast
@@ -55,6 +57,7 @@ import org.strongswan.android.data.ProfileExportProvider
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.VpnProfileExporter
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.VpnStateService.ErrorState
@@ -123,6 +126,8 @@ data class HomeUiState(
val vpnState: State = State.DISABLED,
val error: ErrorState = ErrorState.NO_ERROR,
val errorText: Int = 0,
/** seconds until the next automatic retry while an error is shown, 0 if none */
val retryIn: Int = 0,
val activeProfile: VpnProfile? = null,
val profiles: List<VpnProfile> = emptyList(),
val selected: VpnProfile? = null,
@@ -164,6 +169,34 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
/* wall-clock end of a running "pause VPN", 0 if none */
val pauseUntil: StateFlow<Long> = PauseManager.until(app)
/* whether any non-VPN network is up; tells "no internet" apart from a server problem */
private val _networkAvailable = MutableStateFlow(true)
val networkAvailable: StateFlow<Boolean> = _networkAvailable
private val underlyingNetworks = HashSet<Network>()
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network)
{
synchronized(underlyingNetworks)
{
underlyingNetworks.add(network)
_networkAvailable.value = true
}
}
override fun onLost(network: Network)
{
synchronized(underlyingNetworks)
{
underlyingNetworks.remove(network)
_networkAvailable.value = underlyingNetworks.isNotEmpty()
}
}
}
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@@ -210,6 +243,18 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
)
reloadProfiles()
WhiteListManager.getInstance(ctx).refreshIfStale()
/* the default request excludes VPN networks: this tracks the real uplinks */
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
try
{
cm?.registerNetworkCallback(
NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(),
networkCallback,
)
}
catch (_: RuntimeException)
{ /* too many callbacks registered app-wide; keep assuming a network */
}
/* poll traffic only while the Home screen is shown (not on the other
* tabs or behind another activity), the VPN is up, and off the main thread */
viewModelScope.launch {
@@ -334,6 +379,13 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{
}
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
try
{
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(networkCallback)
}
catch (_: IllegalArgumentException)
{
}
super.onCleared()
}
@@ -375,6 +427,24 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
service?.reconnect()
}
/** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */
fun pause(minutes: Int)
{
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes)
}
fun extendPause(minutes: Int)
{
PauseManager.extend(getApplication(), minutes)
}
fun resumeNow()
{
PauseManager.resumeNow(getApplication())
}
fun delete(profile: VpnProfile)
{
viewModelScope.launch(Dispatchers.IO) {
@@ -493,6 +563,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
vpnState = state,
error = svc.errorState,
errorText = svc.errorText,
retryIn = if (svc.errorState != ErrorState.NO_ERROR) svc.retryIn else 0,
activeProfile = svc.profile,
selected = svc.profile ?: it.selected,
)
@@ -0,0 +1,429 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.VerifiedUser
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material.icons.rounded.WifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.strongswan.android.R
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.HomeUiState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import java.util.Date
/** What keeps the tunnel from working right now, in the user's terms. */
internal enum class HomeProblem
{
/** no uplink at all - not a VPN problem */
NO_NETWORK,
/** internet works, the server does not answer */
UNREACHABLE,
/** the server name does not resolve */
LOOKUP,
/** our credentials were rejected or are missing */
AUTH,
/** the server's identity could not be verified */
SERVER_CERT,
GENERIC,
}
internal fun homeProblem(state: HomeUiState, networkAvailable: Boolean): HomeProblem?
{
val tunnelWanted = state.vpnState != State.DISABLED || state.error != ErrorState.NO_ERROR
if (tunnelWanted && state.vpnState != State.CONNECTED && !networkAvailable)
{
return HomeProblem.NO_NETWORK
}
return when (state.error)
{
ErrorState.NO_ERROR -> null
ErrorState.UNREACHABLE -> HomeProblem.UNREACHABLE
ErrorState.LOOKUP_FAILED -> HomeProblem.LOOKUP
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING -> HomeProblem.AUTH
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE -> HomeProblem.SERVER_CERT
else -> HomeProblem.GENERIC
}
}
/** Short headline for the hero while [problem] is shown. */
internal fun HomeProblem.titleRes(): Int = when (this)
{
HomeProblem.NO_NETWORK -> R.string.home_problem_offline_title
HomeProblem.UNREACHABLE -> R.string.home_problem_unreachable_title
HomeProblem.LOOKUP -> R.string.home_problem_lookup_title
HomeProblem.AUTH -> R.string.home_problem_auth_title
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_title
HomeProblem.GENERIC -> R.string.home_problem_generic_title
}
/** App bar of the Connect tab: the Shield-S mark and the name. */
@Composable
internal fun HomeHeader(modifier: Modifier = Modifier)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(start = ShxSpacing.xl - ShxSpacing.xs, end = ShxSpacing.md, top = ShxSpacing.sm),
)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(34.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(10.dp)),
)
{
Icon(
painterResource(R.drawable.ic_notification),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp),
)
}
Spacer(Modifier.size(10.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.weight(1f).heightIn(min = 44.dp).wrapContentHeight().semantics { heading() },
)
}
}
/** The three most common actions while connected. */
@Composable
internal fun HomeQuickActions(
onPause: () -> Unit,
onSwitch: () -> Unit,
onDiagnose: () -> Unit,
modifier: Modifier = Modifier,
)
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
{
QuickAction(Icons.Rounded.Pause, stringResource(R.string.home_action_pause), onPause, Modifier.weight(1f))
QuickAction(Icons.Rounded.SwapHoriz, stringResource(R.string.home_action_switch), onSwitch, Modifier.weight(1f))
QuickAction(Icons.Rounded.HelpOutline, stringResource(R.string.home_action_not_loading), onDiagnose, Modifier.weight(1f))
}
}
@Composable
private fun QuickAction(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier)
{
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.heightIn(min = 72.dp),
)
{
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
modifier = Modifier.padding(horizontal = ShxSpacing.xs, vertical = ShxSpacing.md),
)
{
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Wall-clock "now" that ticks every second while composed. */
@Composable
internal fun rememberNow(): Long
{
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit)
{
while (true)
{
now = System.currentTimeMillis()
delay(1000L - now % 1000L)
}
}
return now
}
internal fun formatCountdown(ms: Long): String
{
val total = (ms.coerceAtLeast(0L) + 999L) / 1000L
return "%d:%02d".format(total / 60, total % 60)
}
internal fun formatClock(context: android.content.Context, at: Long): String =
android.text.format.DateFormat.getTimeFormat(context).format(Date(at))
/** Shown while the VPN is paused: when it comes back, and the two ways to change that. */
@Composable
internal fun PauseCard(until: Long, onResume: () -> Unit, onExtend: () -> Unit, modifier: Modifier = Modifier)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
{
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Button(onClick = onResume, modifier = Modifier.weight(1f).heightIn(min = 56.dp))
{
Text(stringResource(R.string.home_pause_resume), fontWeight = FontWeight.Bold)
}
FilledTonalButton(onClick = onExtend, modifier = Modifier.weight(1f).heightIn(min = 56.dp))
{
Text(stringResource(R.string.home_pause_extend), fontWeight = FontWeight.Bold)
}
}
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.connectingContainer,
contentColor = status.onConnectingContainer,
modifier = Modifier.fillMaxWidth(),
)
{
Text(
text = stringResource(R.string.home_pause_note),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
}
}
}
/** Bottom sheet with the pause durations. */
@Composable
internal fun PauseSheet(onDismiss: () -> Unit, onPick: (Int) -> Unit)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.xl).padding(bottom = ShxSpacing.xl),
)
{
Text(
text = stringResource(R.string.home_pause_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.home_pause_explain),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(ShxSpacing.xs))
PAUSE_MINUTES.forEach { minutes ->
val label = pluralStringResource(R.plurals.home_pause_minutes, minutes, minutes)
if (minutes == DEFAULT_PAUSE_MINUTES)
{
Button(onClick = { onPick(minutes) }, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) { Text(label) }
}
else
{
FilledTonalButton(onClick = { onPick(minutes) }, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) { Text(label) }
}
}
}
}
}
internal const val DEFAULT_PAUSE_MINUTES = 15
private val PAUSE_MINUTES = listOf(5, 15, 30, 60)
/**
* The reason the tunnel is not working, in plain words, with the one action
* most likely to fix it (and a second one where it helps).
*/
@Composable
internal fun ProblemCard(
problem: HomeProblem,
retryIn: Int,
hasOtherProfiles: Boolean,
onRetry: () -> Unit,
onSwitch: () -> Unit,
onEditProfile: () -> Unit,
onShowLog: () -> Unit,
onNetworkSettings: () -> Unit,
modifier: Modifier = Modifier,
)
{
val warn = problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP
val status = LocalShxStatusColors.current
val container = if (warn) status.connectingContainer else MaterialTheme.colorScheme.errorContainer
val content = if (warn) status.onConnectingContainer else MaterialTheme.colorScheme.onErrorContainer
val icon = when (problem)
{
HomeProblem.NO_NETWORK -> Icons.Rounded.WifiOff
HomeProblem.UNREACHABLE -> Icons.Rounded.CloudOff
HomeProblem.LOOKUP -> Icons.Rounded.SearchOff
HomeProblem.AUTH -> Icons.Rounded.Key
HomeProblem.SERVER_CERT -> Icons.Rounded.VerifiedUser
HomeProblem.GENERIC -> Icons.Rounded.WarningAmber
}
val text = when (problem)
{
HomeProblem.NO_NETWORK -> stringResource(R.string.home_problem_offline_text)
HomeProblem.UNREACHABLE -> stringResource(R.string.home_problem_unreachable_text)
HomeProblem.LOOKUP -> stringResource(R.string.home_problem_lookup_text)
HomeProblem.AUTH -> stringResource(R.string.home_problem_auth_text)
HomeProblem.SERVER_CERT -> stringResource(R.string.home_problem_cert_text)
HomeProblem.GENERIC -> stringResource(R.string.home_problem_generic_text)
}
/* primary + secondary action per problem */
val actions: List<Pair<String, () -> Unit>> = when (problem)
{
HomeProblem.NO_NETWORK -> listOf(
stringResource(R.string.home_problem_network_settings) to onNetworkSettings,
)
HomeProblem.UNREACHABLE, HomeProblem.LOOKUP -> buildList {
if (hasOtherProfiles)
{
add(stringResource(R.string.home_problem_other_server) to onSwitch)
}
add(stringResource(R.string.retry) to onRetry)
if (!hasOtherProfiles)
{
add(stringResource(R.string.show_log) to onShowLog)
}
}
HomeProblem.AUTH -> listOf(
stringResource(R.string.home_problem_edit_profile) to onEditProfile,
stringResource(R.string.show_log) to onShowLog,
)
HomeProblem.SERVER_CERT, HomeProblem.GENERIC -> listOf(
stringResource(R.string.retry) to onRetry,
stringResource(R.string.show_log) to onShowLog,
)
}
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = container,
contentColor = content,
modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg),
)
{
Column(
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(ShxSpacing.lg),
)
{
Row(verticalAlignment = Alignment.CenterVertically)
{
Icon(icon, contentDescription = null)
Spacer(Modifier.size(ShxSpacing.md))
Text(
text = stringResource(problem.cardTitleRes()),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.ExtraBold,
)
}
Text(text = text, style = MaterialTheme.typography.bodyMedium)
if (retryIn > 0 && problem != HomeProblem.AUTH)
{
Text(
text = stringResource(R.string.home_problem_retry_in, retryIn),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), modifier = Modifier.padding(top = ShxSpacing.xs))
{
actions.forEachIndexed { index, (label, action) ->
if (index == 0)
{
Button(onClick = action, modifier = Modifier.weight(1f)) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
else
{
FilledTonalButton(onClick = action) { Text(label, maxLines = 1) }
}
}
}
}
}
}
/** Card headline: what is going on, as opposed to the hero's short state. */
private fun HomeProblem.cardTitleRes(): Int = when (this)
{
HomeProblem.NO_NETWORK -> R.string.home_problem_offline_card
HomeProblem.UNREACHABLE -> R.string.home_problem_unreachable_card
HomeProblem.LOOKUP -> R.string.home_problem_lookup_card
HomeProblem.AUTH -> R.string.home_problem_auth_card
HomeProblem.SERVER_CERT -> R.string.home_problem_cert_card
HomeProblem.GENERIC -> R.string.home_problem_generic_card
}
@@ -542,4 +542,44 @@
<string name="auto_connect_location_title">Нужен доступ к геолокации</string>
<string name="auto_connect_location_why">Android сообщает имя Wi-Fi сети только приложениям с доступом к геолокации. Оно нужно лишь для распознавания доверенных сетей; сама геолокация не читается и не передаётся.</string>
<string name="route_check_dns_section">DNS</string>
<!-- Главная: шапка, быстрые действия, пауза, проблемы -->
<string name="home_action_pause">Пауза</string>
<string name="home_action_switch">Сменить сервер</string>
<string name="home_action_not_loading">Не открывается?</string>
<string name="home_pause_title">Пауза VPN</string>
<string name="home_pause_explain">VPN выключится и включится сам. Автоподключение подождёт, пока пауза не закончится.</string>
<plurals name="home_pause_minutes">
<item quantity="one">%d минута</item>
<item quantity="few">%d минуты</item>
<item quantity="many">%d минут</item>
<item quantity="other">%d минуты</item>
</plurals>
<string name="home_paused_title">Пауза · %1$s</string>
<string name="home_paused_subtitle">VPN включится сам в %1$s</string>
<string name="home_pause_resume">Включить сейчас</string>
<string name="home_pause_extend">+15 минут</string>
<string name="home_pause_note">Пока VPN на паузе, трафик идёт без него.</string>
<string name="home_problem_offline_title">Нет интернета</string>
<string name="home_problem_offline_subtitle">Проблема не в VPN. Подключимся сами, как только появится сеть</string>
<string name="home_problem_offline_card">У телефона нет сети</string>
<string name="home_problem_offline_text">Wi-Fi и мобильные данные не подключены. Если постоянный VPN блокирует соединения без него, приложения будут без сети, пока туннель не поднимется.</string>
<string name="home_problem_network_settings">Настройки сети</string>
<string name="home_problem_unreachable_title">Сервер не отвечает</string>
<string name="home_problem_unreachable_card">Интернет есть — не отвечает сам сервер</string>
<string name="home_problem_unreachable_text">Возможно, он перегружен или недоступен из вашей сети. Обычно помогает другой сервер.</string>
<string name="home_problem_lookup_title">Сервер не найден</string>
<string name="home_problem_lookup_card">Адрес сервера не находится</string>
<string name="home_problem_lookup_text">DNS не нашёл сервер. Проверьте адрес в профиле или попробуйте другую сеть.</string>
<string name="home_problem_auth_title">Вход отклонён</string>
<string name="home_problem_auth_card">Сервер не принял логин или пароль</string>
<string name="home_problem_auth_text">Логин или пароль неверны либо устарели. Обновите их в профиле.</string>
<string name="home_problem_cert_title">Сервер не прошёл проверку</string>
<string name="home_problem_cert_card">Не удалось проверить сервер</string>
<string name="home_problem_cert_text">Его сертификат не прошёл проверку. Не игнорируйте это: попросите у владельца сервера обновлённый профиль.</string>
<string name="home_problem_generic_title">Не подключено</string>
<string name="home_problem_generic_card">Не удалось подключиться</string>
<string name="home_problem_generic_text">Подробности — в журнале. Обычно помогает повторная попытка.</string>
<string name="home_problem_retry_in">Следующая попытка через %1$d с</string>
<string name="home_problem_other_server">Другой сервер</string>
<string name="home_problem_edit_profile">Изменить профиль</string>
</resources>
@@ -543,4 +543,42 @@
<string name="auto_connect_location_title">Location access needed</string>
<string name="auto_connect_location_why">Android only reveals the Wi-Fi network name to apps with location access. The app uses it solely to recognize your trusted networks; the location itself is never read or sent.</string>
<string name="route_check_dns_section">DNS</string>
<!-- Home: header, quick actions, pause, problems -->
<string name="home_action_pause">Pause</string>
<string name="home_action_switch">Switch server</string>
<string name="home_action_not_loading">Site not loading?</string>
<string name="home_pause_title">Pause VPN</string>
<string name="home_pause_explain">The VPN turns off and comes back by itself. Auto-connect waits until the pause ends.</string>
<plurals name="home_pause_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<string name="home_paused_title">Paused · %1$s</string>
<string name="home_paused_subtitle">The VPN turns back on at %1$s</string>
<string name="home_pause_resume">Turn on now</string>
<string name="home_pause_extend">+15 minutes</string>
<string name="home_pause_note">While paused, traffic goes without the VPN.</string>
<string name="home_problem_offline_title">No internet</string>
<string name="home_problem_offline_subtitle">Not a VPN problem. We connect as soon as a network is back</string>
<string name="home_problem_offline_card">The phone has no network</string>
<string name="home_problem_offline_text">Wi-Fi and mobile data are not connected. If the always-on VPN blocks connections without it, apps stay offline until the tunnel is up.</string>
<string name="home_problem_network_settings">Network settings</string>
<string name="home_problem_unreachable_title">Server not responding</string>
<string name="home_problem_unreachable_card">Internet works, the server does not answer</string>
<string name="home_problem_unreachable_text">It may be overloaded or unreachable from your network. Another server usually helps.</string>
<string name="home_problem_lookup_title">Server not found</string>
<string name="home_problem_lookup_card">The server address does not resolve</string>
<string name="home_problem_lookup_text">DNS could not find the server. Check the profile address or try another network.</string>
<string name="home_problem_auth_title">Sign-in rejected</string>
<string name="home_problem_auth_card">The server rejected the credentials</string>
<string name="home_problem_auth_text">The login or password is wrong or has expired. Update them in the profile.</string>
<string name="home_problem_cert_title">Server not verified</string>
<string name="home_problem_cert_card">The server could not be verified</string>
<string name="home_problem_cert_text">Its certificate did not pass the check. Do not ignore this: ask the server owner for an updated profile.</string>
<string name="home_problem_generic_title">Not connected</string>
<string name="home_problem_generic_card">The connection failed</string>
<string name="home_problem_generic_text">The log has the details. Retrying usually helps.</string>
<string name="home_problem_retry_in">Next attempt in %1$d s</string>
<string name="home_problem_other_server">Other server</string>
<string name="home_problem_edit_profile">Edit profile</string>
</resources>
@@ -0,0 +1,67 @@
/*
* 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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.home
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.HomeUiState
class HomeProblemTest
{
private fun problem(state: State, error: ErrorState = ErrorState.NO_ERROR, network: Boolean = true) =
homeProblem(HomeUiState(vpnState = state, error = error), network)
@Test
fun noProblemWhileHealthy()
{
assertThat(problem(State.CONNECTED)).isNull()
assertThat(problem(State.CONNECTING)).isNull()
assertThat(problem(State.DISABLED)).isNull()
}
@Test
fun noNetworkOnlyWhileTheTunnelIsWanted()
{
assertThat(problem(State.CONNECTING, network = false)).isEqualTo(HomeProblem.NO_NETWORK)
assertThat(problem(State.DISABLED, ErrorState.UNREACHABLE, network = false)).isEqualTo(HomeProblem.NO_NETWORK)
/* the user turned the VPN off: a missing network is none of our business */
assertThat(problem(State.DISABLED, network = false)).isNull()
/* a live tunnel outranks a stale "no uplink" callback */
assertThat(problem(State.CONNECTED, network = false)).isNull()
}
@Test
fun errorsMapToProblems()
{
assertThat(problem(State.DISABLED, ErrorState.UNREACHABLE)).isEqualTo(HomeProblem.UNREACHABLE)
assertThat(problem(State.DISABLED, ErrorState.LOOKUP_FAILED)).isEqualTo(HomeProblem.LOOKUP)
assertThat(problem(State.DISABLED, ErrorState.AUTH_FAILED)).isEqualTo(HomeProblem.AUTH)
assertThat(problem(State.DISABLED, ErrorState.PASSWORD_MISSING)).isEqualTo(HomeProblem.AUTH)
assertThat(problem(State.DISABLED, ErrorState.PEER_AUTH_FAILED)).isEqualTo(HomeProblem.SERVER_CERT)
assertThat(problem(State.DISABLED, ErrorState.CERTIFICATE_UNAVAILABLE)).isEqualTo(HomeProblem.SERVER_CERT)
assertThat(problem(State.DISABLED, ErrorState.GENERIC_ERROR)).isEqualTo(HomeProblem.GENERIC)
}
@Test
fun countdownRoundsUp()
{
assertThat(formatCountdown(15 * 60_000L)).isEqualTo("15:00")
assertThat(formatCountdown(61_001L)).isEqualTo("1:02")
assertThat(formatCountdown(0L)).isEqualTo("0:00")
assertThat(formatCountdown(-5L)).isEqualTo("0:00")
}
}