diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index f8c10d2dd..0906070fd 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -293,6 +293,10 @@
+
+
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
new file mode 100644
index 000000000..dfb1f53f3
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
@@ -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 .
+ *
+ * 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 = _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
+ {
+ 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)
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
index 78706d228..41bdeb43c 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
@@ -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)
{
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
index 80cf0473a..062da305f 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
@@ -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()
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 ba9791a7a..64eeefe58 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
@@ -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>() }
/* 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(null) }
var sheetProfile by remember { mutableStateOf(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(
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 5658942fd..610407329 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
@@ -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 = emptyList(),
val selected: VpnProfile? = null,
@@ -164,6 +169,34 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _deadTargets = MutableStateFlow>(emptyList())
val deadTargets: StateFlow> = _deadTargets
+ /* wall-clock end of a running "pause VPN", 0 if none */
+ val pauseUntil: StateFlow = 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 = _networkAvailable
+ private val underlyingNetworks = HashSet()
+ 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,
)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt
new file mode 100644
index 000000000..e1d978d2b
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt
@@ -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 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
+}
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 1ce4d3fa2..73b23acce 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
@@ -542,4 +542,44 @@
Нужен доступ к геолокации
Android сообщает имя Wi-Fi сети только приложениям с доступом к геолокации. Оно нужно лишь для распознавания доверенных сетей; сама геолокация не читается и не передаётся.
DNS
+
+ Пауза
+ Сменить сервер
+ Не открывается?
+ Пауза VPN
+ VPN выключится и включится сам. Автоподключение подождёт, пока пауза не закончится.
+
+ - %d минута
+ - %d минуты
+ - %d минут
+ - %d минуты
+
+ Пауза · %1$s
+ VPN включится сам в %1$s
+ Включить сейчас
+ +15 минут
+ Пока VPN на паузе, трафик идёт без него.
+ Нет интернета
+ Проблема не в VPN. Подключимся сами, как только появится сеть
+ У телефона нет сети
+ Wi-Fi и мобильные данные не подключены. Если постоянный VPN блокирует соединения без него, приложения будут без сети, пока туннель не поднимется.
+ Настройки сети
+ Сервер не отвечает
+ Интернет есть — не отвечает сам сервер
+ Возможно, он перегружен или недоступен из вашей сети. Обычно помогает другой сервер.
+ Сервер не найден
+ Адрес сервера не находится
+ DNS не нашёл сервер. Проверьте адрес в профиле или попробуйте другую сеть.
+ Вход отклонён
+ Сервер не принял логин или пароль
+ Логин или пароль неверны либо устарели. Обновите их в профиле.
+ Сервер не прошёл проверку
+ Не удалось проверить сервер
+ Его сертификат не прошёл проверку. Не игнорируйте это: попросите у владельца сервера обновлённый профиль.
+ Не подключено
+ Не удалось подключиться
+ Подробности — в журнале. Обычно помогает повторная попытка.
+ Следующая попытка через %1$d с
+ Другой сервер
+ Изменить профиль
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 4cd85d4f7..2d1e07d45 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -543,4 +543,42 @@
Location access needed
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.
DNS
+
+ Pause
+ Switch server
+ Site not loading?
+ Pause VPN
+ The VPN turns off and comes back by itself. Auto-connect waits until the pause ends.
+
+ - %d minute
+ - %d minutes
+
+ Paused · %1$s
+ The VPN turns back on at %1$s
+ Turn on now
+ +15 minutes
+ While paused, traffic goes without the VPN.
+ No internet
+ Not a VPN problem. We connect as soon as a network is back
+ The phone has no network
+ 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.
+ Network settings
+ Server not responding
+ Internet works, the server does not answer
+ It may be overloaded or unreachable from your network. Another server usually helps.
+ Server not found
+ The server address does not resolve
+ DNS could not find the server. Check the profile address or try another network.
+ Sign-in rejected
+ The server rejected the credentials
+ The login or password is wrong or has expired. Update them in the profile.
+ Server not verified
+ The server could not be verified
+ Its certificate did not pass the check. Do not ignore this: ask the server owner for an updated profile.
+ Not connected
+ The connection failed
+ The log has the details. Retrying usually helps.
+ Next attempt in %1$d s
+ Other server
+ Edit profile
diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/home/HomeProblemTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/home/HomeProblemTest.kt
new file mode 100644
index 000000000..fa9565bf1
--- /dev/null
+++ b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/home/HomeProblemTest.kt
@@ -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 .
+ *
+ * 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")
+ }
+}