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") + } +}