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:
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)
|
||||
}
|
||||
}
|
||||
+2
@@ -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)
|
||||
{
|
||||
|
||||
+7
-2
@@ -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()
|
||||
|
||||
+108
-1
@@ -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(
|
||||
|
||||
+71
@@ -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,
|
||||
)
|
||||
|
||||
+429
@@ -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>
|
||||
+67
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user