android: home phase 2 - events inbox and problem notifications

- EventLog keeps what the app noticed and did by itself for 30 days on the
  device (events.json); repeats of the same event within 30 minutes are
  merged (x3), problems that go away are marked resolved.
- Recorded: connection errors and recovery, carrier whitelists on/off,
  health-check reconnects, auto-connect starting/parking the tunnel, the
  end of a pause, available updates.
- Problems are pushed on a new "Connection problems" channel, but only
  while the app is in the background (Home shows them already).
- «Events» screen (InboxActivity): filters (all, need attention,
  connection, routes, app), grouped by day, one action per event; opened
  from a bell with an unread badge in the Home header.
- Amber warning colours in the status palette, used for whitelists, no
  network and an unresponsive server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-11 21:54:01 +07:00
1 parent 75849beb90
commit 60252437ac
18 files changed
+1089 -10

No files matched your search

@@ -129,6 +129,10 @@
android:name=".ui.RouteCheckActivity"
android:label="@string/route_check_title">
</activity>
<activity
android:name=".ui.InboxActivity"
android:label="@string/events_title">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
@@ -26,6 +26,7 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.events.Events
/**
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
@@ -172,6 +173,7 @@ object PauseManager
}
DiagLog.i(TAG, "pause over, reconnecting")
reconnect(ctx, profile)
Events.pauseOver(ctx)
}
private fun reconnect(ctx: Context, profileUuid: String)
@@ -34,6 +34,7 @@ import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.ui.widget.ShxWidgetState;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.events.Events;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.logic.connectivity.WhiteListManager;
@@ -379,6 +380,7 @@ public class VpnStateService extends Service
else
{ /* the user explicitly disconnected, so the watchdog must not restore
* the connection after this */
Events.connectionStopped(this);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
@@ -687,6 +689,10 @@ public class VpnStateService extends Service
if (VpnStateService.this.mState != state)
{
VpnStateService.this.mState = state;
if (state == State.CONNECTED)
{
Events.connectionRestored(VpnStateService.this);
}
if (state == State.CONNECTING)
{ /* reentered while the daemon reestablishes the tunnel; bound
* the total time so a lost alert can't strand us here */
@@ -728,6 +734,10 @@ public class VpnStateService extends Service
resetRetryTimer();
}
VpnStateService.this.mError = error;
if (error != ErrorState.NO_ERROR)
{ /* repeats while retrying are merged into one event */
Events.connectionError(VpnStateService.this, error);
}
return true;
}
return false;
@@ -37,6 +37,7 @@ 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.events.Events
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager
@@ -279,6 +280,7 @@ class AutoConnectManager private constructor(
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
svc.disconnect(true)
Events.autoConnect(context, parked = true)
}
Action.IGNORE ->
{
@@ -349,6 +351,7 @@ class AutoConnectManager private constructor(
/* automatic start: keep the previous session's charon log */
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
svc.connect(extras, true)
Events.autoConnect(context, parked = false)
return
}
val intent = Intent(context, VpnProfileControlActivity::class.java)
@@ -44,6 +44,7 @@ import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events
/** What to do when a check fails. */
enum class HealthAction
@@ -476,6 +477,7 @@ class HealthCheckManager private constructor(
serverAttempts++
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = true)
}
}
Decision.RECONNECT_TUNNEL ->
@@ -485,6 +487,7 @@ class HealthCheckManager private constructor(
tunnelAttempts++
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = false)
}
}
Decision.IGNORE ->
@@ -44,6 +44,7 @@ import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events
import java.util.concurrent.ConcurrentHashMap
/** Whether the ISP currently blocks direct access to foreign hosts. */
@@ -438,6 +439,14 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
val before = machine
machine = before.advance(candidate)
publish(round)
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
{
Events.whitelist(context, true)
}
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
{
Events.whitelist(context, false)
}
if (machine.status == WhiteListStatus.ACTIVE)
{ /* keep watching for the whitelist to clear, at a low rate */
backoffAttempt++
@@ -0,0 +1,348 @@
/*
* 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.events
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.ui.InboxActivity
import java.io.File
import java.util.concurrent.Executors
/** How an event looks and whether it waits for the user. */
enum class EventKind
{
/** something is wrong right now (pushed on the "problems" channel) */
WARN,
/** the app suggests something and waits for a decision */
ASK,
/** the app did something by itself */
INFO,
/** something got better */
OK,
}
enum class EventCategory
{
CONNECTION,
ROUTING,
APP,
}
/** What the event's button does; the Inbox maps these to screens. */
enum class EventAction
{
NONE,
OPEN_HOME,
OPEN_ROUTING,
OPEN_ROUTE_CHECK,
OPEN_LOG,
INSTALL_UPDATE,
}
data class ShxEvent(
val id: Long,
val at: Long,
val kind: EventKind,
val category: EventCategory,
val title: String,
val text: String,
val action: EventAction = EventAction.NONE,
/** groups repeats of the same event; null for one-offs */
val key: String? = null,
/** how many times it happened within the merge window */
val count: Int = 1,
val read: Boolean = false,
/** the problem went away by itself; shown dimmed, no longer "waiting" */
val resolved: Boolean = false,
)
{
/** still needs the user's attention */
val waiting: Boolean
get() = !resolved && (kind == EventKind.WARN || kind == EventKind.ASK)
}
/**
* What the app did and noticed by itself, kept for [RETENTION_MS] on the
* device. Problems are additionally pushed while the app is in the
* background, so the user learns about them without opening it.
*/
class EventLog private constructor(private val context: Context)
{
private val file = File(context.filesDir, FILE_NAME)
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "EventLog").apply { isDaemon = true } }
private val _events = MutableStateFlow(load())
val events: StateFlow<List<ShxEvent>> = _events
/**
* Adds an event. A repeat of an unresolved event with the same [key]
* within [MERGE_WINDOW_MS] bumps the existing entry instead of adding a
* new one (retries would otherwise flood the list).
*/
fun record(
kind: EventKind,
category: EventCategory,
title: String,
text: String,
action: EventAction = EventAction.NONE,
key: String? = null,
)
{
val now = System.currentTimeMillis()
var posted: ShxEvent? = null
_events.update { list ->
val merged = key?.let { k ->
list.indexOfFirst { it.key == k && !it.resolved && now - it.at < MERGE_WINDOW_MS }
} ?: -1
val next = if (merged >= 0)
{
val old = list[merged]
val bumped = old.copy(at = now, title = title, text = text, action = action, count = old.count + 1, read = false)
posted = bumped
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
}
else
{
val event = ShxEvent(
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
title = title, text = text, action = action, key = key,
)
posted = event
listOf(event) + list
}
prune(next, now)
}
persist()
posted?.let { if (it.kind == EventKind.WARN) pushIfBackground(it) }
}
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String)
{
if (_events.value.none { it.key == key })
{
record(kind, category, title, text, action, key)
}
}
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
fun resolve(keyPrefix: String)
{
var changed = false
_events.update { list ->
list.map {
if (!it.resolved && it.key?.startsWith(keyPrefix) == true)
{
changed = true
NotificationManagerCompat.from(context).cancel(TAG, notificationId(it))
it.copy(resolved = true)
}
else it
}
}
if (changed)
{
persist()
}
}
fun markAllRead()
{
if (_events.value.none { !it.read })
{
return
}
val manager = NotificationManagerCompat.from(context)
_events.value.filter { !it.read }.forEach { manager.cancel(TAG, notificationId(it)) }
_events.update { list -> list.map { if (it.read) it else it.copy(read = true) } }
persist()
}
/** Hides an event the user acted on or dismissed. */
fun dismiss(id: Long)
{
_events.update { list -> list.filterNot { it.id == id } }
persist()
}
private fun pushIfBackground(event: ShxEvent)
{
val foreground = try
{
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
catch (_: Exception)
{
false
}
if (foreground)
{ /* the Home screen shows the problem already */
return
}
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled())
{
return
}
ensureChannel(context)
val open = PendingIntent.getActivity(
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, PROBLEMS_CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(event.title)
.setContentText(event.text)
.setStyle(NotificationCompat.BigTextStyle().bigText(event.text))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(open)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.build()
try
{
manager.notify(TAG, notificationId(event), notification)
}
catch (e: SecurityException)
{ /* POST_NOTIFICATIONS was revoked in between */
Log.w(TAG, "Problem notification not allowed", e)
}
}
/* repeats of a keyed event replace their notification instead of stacking */
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
private fun prune(list: List<ShxEvent>, now: Long): List<ShxEvent> =
list.filter { now - it.at < RETENTION_MS }.take(MAX_EVENTS)
private fun persist()
{
val snapshot = _events.value
io.execute {
try
{
val array = JSONArray()
snapshot.forEach { array.put(toJson(it)) }
val tmp = File(file.path + ".tmp")
tmp.writeText(array.toString())
if (!tmp.renameTo(file))
{
file.writeText(array.toString())
tmp.delete()
}
}
catch (e: Exception)
{
Log.w(TAG, "Saving events failed", e)
}
}
}
private fun load(): List<ShxEvent>
{
return try
{
if (!file.exists())
{
return emptyList()
}
val array = JSONArray(file.readText())
val list = (0 until array.length()).mapNotNull { fromJson(array.optJSONObject(it)) }
prune(list, System.currentTimeMillis())
}
catch (e: Exception)
{
Log.w(TAG, "Loading events failed", e)
emptyList()
}
}
companion object
{
private const val TAG = "EventLog"
private const val FILE_NAME = "events.json"
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
const val MERGE_WINDOW_MS = 30L * 60 * 1000
private const val MAX_EVENTS = 300
@Volatile private var instance: EventLog? = null
@JvmStatic
fun getInstance(context: Context): EventLog =
instance ?: synchronized(this) {
instance ?: EventLog(context.applicationContext).also { instance = it }
}
@JvmStatic
fun ensureChannel(context: Context)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val channel = NotificationChannel(
PROBLEMS_CHANNEL, context.getString(R.string.events_channel_problems),
NotificationManager.IMPORTANCE_DEFAULT,
)
channel.description = context.getString(R.string.events_channel_problems_desc)
manager.createNotificationChannel(channel)
}
}
internal fun toJson(e: ShxEvent): JSONObject = JSONObject()
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
.put("title", e.title).put("text", e.text).put("action", e.action.name)
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
internal fun fromJson(o: JSONObject?): ShxEvent?
{
o ?: return null
return try
{
ShxEvent(
id = o.getLong("id"),
at = o.getLong("at"),
kind = EventKind.valueOf(o.getString("kind")),
category = EventCategory.valueOf(o.getString("category")),
title = o.getString("title"),
text = o.optString("text"),
action = runCatching { EventAction.valueOf(o.optString("action")) }.getOrDefault(EventAction.NONE),
key = if (o.isNull("key")) null else o.optString("key"),
count = o.optInt("count", 1),
read = o.optBoolean("read"),
resolved = o.optBoolean("resolved"),
)
}
catch (_: Exception)
{ /* an entry from an older/newer format: skip it rather than lose all */
null
}
}
}
}
@@ -0,0 +1,135 @@
/*
* 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.events
import android.content.Context
import org.strongswan.android.R
import org.strongswan.android.logic.VpnStateService.ErrorState
/**
* The events the app records, one function per occasion, so the call sites
* in the service code stay one-liners.
*/
object Events
{
private const val KEY_ERROR = "error:"
private const val KEY_WHITELIST = "whitelist"
/** The connection failed; repeats while retrying are merged. */
@JvmStatic
fun connectionError(context: Context, error: ErrorState)
{
val (title, text, action) = when (error)
{
ErrorState.UNREACHABLE -> Triple(R.string.home_problem_unreachable_title, R.string.home_problem_unreachable_text, EventAction.OPEN_HOME)
ErrorState.LOOKUP_FAILED -> Triple(R.string.home_problem_lookup_title, R.string.home_problem_lookup_text, EventAction.OPEN_HOME)
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING ->
Triple(R.string.home_problem_auth_title, R.string.home_problem_auth_text, EventAction.OPEN_HOME)
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE ->
Triple(R.string.home_problem_cert_title, R.string.home_problem_cert_text, EventAction.OPEN_LOG)
ErrorState.NO_ERROR -> return
else -> Triple(R.string.home_problem_generic_title, R.string.home_problem_generic_text, EventAction.OPEN_LOG)
}
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(title), context.getString(text),
action, KEY_ERROR + error.name,
)
}
/** The tunnel is up again after a recorded failure. */
@JvmStatic
fun connectionRestored(context: Context)
{
val log = EventLog.getInstance(context)
if (log.events.value.none { !it.resolved && it.key?.startsWith(KEY_ERROR) == true })
{
return
}
log.resolve(KEY_ERROR)
log.record(
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_restored_title),
context.getString(R.string.event_restored_text), key = "restored",
)
}
/** The user stopped the VPN: pending connection problems no longer matter. */
@JvmStatic
fun connectionStopped(context: Context)
{
EventLog.getInstance(context).resolve(KEY_ERROR)
}
@JvmStatic
fun whitelist(context: Context, active: Boolean)
{
val log = EventLog.getInstance(context)
if (active)
{
log.record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_on_title),
context.getString(R.string.event_whitelist_on_text), EventAction.OPEN_HOME, KEY_WHITELIST,
)
}
else
{
log.resolve(KEY_WHITELIST)
log.record(
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_off_title),
context.getString(R.string.event_whitelist_off_text), key = "whitelist-off",
)
}
}
/** The health check restarted the tunnel. */
@JvmStatic
fun healthReconnect(context: Context, serverDown: Boolean)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_health_reconnect_title),
context.getString(if (serverDown) R.string.event_health_reconnect_server else R.string.event_health_reconnect_tunnel),
key = "health-reconnect",
)
}
/** Auto-connect started the tunnel ([parked] = false) or parked it on a trusted Wi-Fi. */
@JvmStatic
fun autoConnect(context: Context, parked: Boolean)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION,
context.getString(if (parked) R.string.event_auto_parked_title else R.string.event_auto_started_title),
context.getString(if (parked) R.string.event_auto_parked_text else R.string.event_auto_started_text),
key = if (parked) "auto-parked" else "auto-started",
)
}
@JvmStatic
fun pauseOver(context: Context)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_pause_over_title),
context.getString(R.string.event_pause_over_text),
)
}
@JvmStatic
fun updateAvailable(context: Context, versionName: String, versionCode: Long)
{
EventLog.getInstance(context).recordOnce(
EventKind.INFO, EventCategory.APP, context.getString(R.string.event_update_title, versionName),
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
)
}
}
@@ -52,6 +52,7 @@ import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
import org.strongswan.android.logic.events.Events
import org.strongswan.android.ui.MainActivity
import org.strongswan.android.utils.Constants
import java.io.File
@@ -262,6 +263,7 @@ class UpdateManager private constructor(private val context: Context)
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
"installed ${currentVersionName()} (${currentVersionCode()})")
mState.value = UpdateState.Available(info)
Events.updateAvailable(context, info.versionName, info.versionCode)
if (mInstallPending)
{
mInstallPending = false
@@ -0,0 +1,98 @@
/*
* 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
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.ui.compose.InboxScreen
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
class InboxActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.events_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
InboxScreen(onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
}
}
}
}
}
override fun onStop()
{
/* everything shown counts as seen once the user leaves the screen */
EventLog.getInstance(this).markAllRead()
super.onStop()
}
private fun perform(action: EventAction)
{
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
when (action)
{
EventAction.NONE -> return
EventAction.OPEN_HOME -> startActivity(main)
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
}
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
{
finish()
}
}
private fun openNotificationSettings()
{
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
EventLog.ensureChannel(this)
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
else
{
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null))
}
try
{
startActivity(intent)
}
catch (_: ActivityNotFoundException)
{
}
}
}
@@ -153,6 +153,8 @@ 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 org.strongswan.android.ui.InboxActivity
import org.strongswan.android.logic.events.EventLog
import android.content.Intent
import android.os.Build
import android.provider.Settings
@@ -187,6 +189,8 @@ fun HomeScreen(
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
val pauseUntil by viewModel.pauseUntil.collectAsStateWithLifecycle()
val networkAvailable by viewModel.networkAvailable.collectAsStateWithLifecycle()
val events by remember { EventLog.getInstance(context).events }.collectAsStateWithLifecycle()
val unreadEvents = events.count { !it.read }
/* 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:
@@ -251,7 +255,7 @@ fun HomeScreen(
val statusColor = when
{
paused -> MaterialTheme.colorScheme.onSurfaceVariant
problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP -> status.connecting
problem == HomeProblem.NO_NETWORK || problem == HomeProblem.UNREACHABLE || problem == HomeProblem.LOOKUP -> status.warning
hasError -> MaterialTheme.colorScheme.error
connected -> status.connected
connecting -> status.connecting
@@ -275,7 +279,7 @@ fun HomeScreen(
val wide = LocalConfiguration.current.screenWidthDp >= 840
val leftItems: LazyListScope.() -> Unit = {
item(key = "header") {
HomeHeader()
HomeHeader(unread = unreadEvents, onInbox = { context.startActivity(Intent(context, InboxActivity::class.java)) })
}
item(key = "hero") {
Column(
@@ -0,0 +1,280 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.text.format.DateUtils
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.NotificationsNone
import androidx.compose.material.icons.rounded.WarningAmber
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.logic.events.ShxEvent
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import java.util.Calendar
/** Inbox filters, in chip order. */
internal enum class InboxFilter
{
ALL,
WAITING,
CONNECTION,
ROUTING,
APP;
fun matches(event: ShxEvent): Boolean = when (this)
{
ALL -> true
WAITING -> event.waiting
CONNECTION -> event.category == EventCategory.CONNECTION
ROUTING -> event.category == EventCategory.ROUTING
APP -> event.category == EventCategory.APP
}
}
/** Groups events by calendar day, newest first: (day start, events). */
internal fun groupByDay(events: List<ShxEvent>): List<Pair<Long, List<ShxEvent>>> =
events.sortedByDescending { it.at }.groupBy { dayStart(it.at) }.toList()
internal fun dayStart(at: Long): Long
{
val c = Calendar.getInstance()
c.timeInMillis = at
c.set(Calendar.HOUR_OF_DAY, 0)
c.set(Calendar.MINUTE, 0)
c.set(Calendar.SECOND, 0)
c.set(Calendar.MILLISECOND, 0)
return c.timeInMillis
}
@Composable
fun InboxScreen(onAction: (EventAction) -> Unit, onNotificationSettings: () -> Unit)
{
val context = LocalContext.current
val log = remember { EventLog.getInstance(context) }
val events by log.events.collectAsStateWithLifecycle()
var filter by rememberSaveable { mutableStateOf(InboxFilter.ALL) }
val waiting = events.count { it.waiting }
val shown = remember(events, filter) { groupByDay(events.filter(filter::matches)) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = ShxSpacing.xxl),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
{
item(key = "filters") {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
contentPadding = PaddingValues(horizontal = ShxSpacing.lg),
)
{
items(InboxFilter.entries.toList(), key = { it.name }) { f ->
val label = when (f)
{
InboxFilter.ALL -> stringResource(R.string.events_filter_all)
InboxFilter.WAITING -> if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting)
else stringResource(R.string.events_filter_waiting)
InboxFilter.CONNECTION -> stringResource(R.string.events_filter_connection)
InboxFilter.ROUTING -> stringResource(R.string.events_filter_routing)
InboxFilter.APP -> stringResource(R.string.events_filter_app)
}
FilterChip(selected = filter == f, onClick = { filter = f }, label = { Text(label) })
}
}
}
if (shown.isEmpty())
{
item(key = "empty") {
ShxEmptyState(
icon = Icons.Rounded.NotificationsNone,
message = stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
modifier = Modifier.padding(top = ShxSpacing.xxl),
)
}
}
shown.forEach { (day, dayEvents) ->
item(key = "day-$day") {
Text(
text = dayLabel(day),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = ShxSpacing.xl, end = ShxSpacing.xl, top = ShxSpacing.md)
.semantics { heading() },
)
}
items(dayEvents, key = { it.id }) { event ->
EventCard(event = event, onAction = { onAction(event.action) })
}
}
item(key = "footer") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.events_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onNotificationSettings) { Text(stringResource(R.string.events_notification_settings)) }
}
}
}
}
@Composable
private fun dayLabel(day: Long): String
{
val context = LocalContext.current
val today = dayStart(System.currentTimeMillis())
return when (day)
{
today -> stringResource(R.string.events_today)
dayStart(today - 12 * 60 * 60 * 1000L) -> stringResource(R.string.events_yesterday)
else -> DateUtils.formatDateTime(context, day, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR)
}
}
@Composable
private fun EventCard(event: ShxEvent, onAction: () -> Unit)
{
val context = LocalContext.current
val status = LocalShxStatusColors.current
val scheme = MaterialTheme.colorScheme
val warn = event.kind == EventKind.WARN && !event.resolved
val container = if (warn) status.warningContainer else scheme.surfaceContainerLow
val (icon: ImageVector, iconBg: Color, iconFg: Color) = when (event.kind)
{
EventKind.WARN -> Triple(Icons.Rounded.WarningAmber, status.warning, if (scheme.background.luminance() > 0.5f) Color.White else Color.Black)
EventKind.ASK -> Triple(Icons.Rounded.HelpOutline, scheme.primary, scheme.onPrimary)
EventKind.INFO -> Triple(Icons.Rounded.Info, scheme.surfaceContainerHigh, scheme.onSurfaceVariant)
EventKind.OK -> Triple(Icons.Rounded.CheckCircle, status.connectedContainer, status.onConnectedContainer)
}
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = container,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg)
.alpha(if (event.resolved) 0.7f else 1f),
)
{
Row(Modifier.padding(ShxSpacing.lg), horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(36.dp).background(iconBg, CircleShape),
)
{
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs))
{
Row(verticalAlignment = Alignment.CenterVertically)
{
Text(
text = if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (event.read) FontWeight.SemiBold else FontWeight.ExtraBold,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.size(ShxSpacing.sm))
Text(
text = formatClock(context, event.at),
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant,
)
}
if (event.text.isNotEmpty())
{
Text(text = event.text, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
}
if (event.resolved)
{
Text(
text = stringResource(R.string.events_resolved),
style = MaterialTheme.typography.labelMedium,
color = status.connected,
fontWeight = FontWeight.Bold,
)
}
else if (event.action != EventAction.NONE)
{
Button(onClick = onAction, modifier = Modifier.padding(top = ShxSpacing.xs))
{
Text(stringResource(event.action.labelRes()))
}
}
}
}
}
}
private fun EventAction.labelRes(): Int = when (this)
{
EventAction.OPEN_HOME -> R.string.events_action_open
EventAction.OPEN_ROUTING -> R.string.events_action_routing
EventAction.OPEN_ROUTE_CHECK -> R.string.events_action_check
EventAction.OPEN_LOG -> R.string.show_log
EventAction.INSTALL_UPDATE -> R.string.events_action_update
EventAction.NONE -> R.string.events_action_open
}
@@ -168,8 +168,8 @@ internal fun WhiteListBanner(modifier: Modifier = Modifier)
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.connectingContainer,
contentColor = status.onConnectingContainer,
color = status.warningContainer,
contentColor = status.onWarningContainer,
modifier = modifier.fillMaxWidth(),
)
{
@@ -34,7 +34,12 @@ 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.material.icons.rounded.NotificationsNone
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
@@ -115,9 +120,9 @@ internal fun HomeProblem.titleRes(): Int = when (this)
HomeProblem.GENERIC -> R.string.home_problem_generic_title
}
/** App bar of the Connect tab: the Shield-S mark and the name. */
/** App bar of the Connect tab: the Shield-S mark, the name and the events bell. */
@Composable
internal fun HomeHeader(modifier: Modifier = Modifier)
internal fun HomeHeader(unread: Int, onInbox: () -> Unit, modifier: Modifier = Modifier)
{
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -145,6 +150,20 @@ internal fun HomeHeader(modifier: Modifier = Modifier)
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.weight(1f).heightIn(min = 44.dp).wrapContentHeight().semantics { heading() },
)
val bellLabel = if (unread > 0) pluralStringResource(R.plurals.home_events_unread, unread, unread)
else stringResource(R.string.events_title)
FilledTonalIconButton(onClick = onInbox, modifier = Modifier.size(44.dp).semantics { contentDescription = bellLabel })
{
BadgedBox(badge = {
if (unread > 0)
{
Badge { Text(if (unread > 99) "99+" else unread.toString()) }
}
})
{
Icon(Icons.Rounded.NotificationsNone, contentDescription = null)
}
}
}
}
@@ -323,8 +342,8 @@ internal fun ProblemCard(
{
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 container = if (warn) status.warningContainer else MaterialTheme.colorScheme.errorContainer
val content = if (warn) status.onWarningContainer else MaterialTheme.colorScheme.onErrorContainer
val icon = when (problem)
{
HomeProblem.NO_NETWORK -> Icons.Rounded.WifiOff
@@ -175,6 +175,10 @@ data class ShxStatusColors(
val disconnected: Color,
val disconnectedContainer: Color,
val onDisconnectedContainer: Color,
/** "something is off, but not an error": whitelists, no network, a silent server */
val warning: Color,
val warningContainer: Color,
val onWarningContainer: Color,
)
private val LightStatusColors = ShxStatusColors(
@@ -187,6 +191,9 @@ private val LightStatusColors = ShxStatusColors(
disconnected = Color(0xFFC0362C),
disconnectedContainer = Color(0xFFFFE1DE),
onDisconnectedContainer = Color(0xFF410001),
warning = Color(0xFFB45309),
warningContainer = Color(0xFFFFF4E0),
onWarningContainer = Color(0xFF6B3A10),
)
private val DarkStatusColors = ShxStatusColors(
@@ -199,10 +206,13 @@ private val DarkStatusColors = ShxStatusColors(
disconnected = Color(0xFFED6B62),
disconnectedContainer = Color(0xFF4A1712),
onDisconnectedContainer = Color(0xFFFFE1DE),
warning = Color(0xFFFFB869),
warningContainer = Color(0xFF3A2A12),
onWarningContainer = Color(0xFFF5C892),
)
/* Kept independent of dynamic color on purpose: the connected/connecting/disconnected
* semantics must stay green/purple/red regardless of the user's wallpaper. */
/* Kept independent of dynamic color on purpose: the connected/connecting/disconnected/warning
* semantics must stay green/purple/red/amber regardless of the user's wallpaper. */
val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors }
/**
@@ -582,4 +582,49 @@
<string name="home_problem_retry_in">Следующая попытка через %1$d с</string>
<string name="home_problem_other_server">Другой сервер</string>
<string name="home_problem_edit_profile">Изменить профиль</string>
<!-- События -->
<string name="events_title">События</string>
<string name="events_channel_problems">Проблемы подключения</string>
<string name="events_channel_problems_desc">Белые списки, недоступный сервер, отклонённый вход — когда VPN не может работать</string>
<string name="events_filter_all">Все</string>
<string name="events_filter_waiting">Ждут решения</string>
<string name="events_filter_waiting_n">Ждут решения · %1$d</string>
<string name="events_filter_connection">Подключение</string>
<string name="events_filter_routing">Маршруты</string>
<string name="events_filter_app">Приложение</string>
<string name="events_empty">Пока ничего не случилось. Здесь появится всё, что приложение заметит и сделает само.</string>
<string name="events_empty_filter">Таких событий нет.</string>
<string name="events_footer">Храним 30 дней на устройстве. Уведомлениями приходят только проблемы подключения.</string>
<string name="events_notification_settings">Настройки уведомлений</string>
<string name="events_today">Сегодня</string>
<string name="events_yesterday">Вчера</string>
<string name="events_title_count">%1$s ×%2$d</string>
<string name="events_resolved">Решено</string>
<string name="events_action_open">Открыть</string>
<string name="events_action_routing">Маршруты</string>
<string name="events_action_check">Проверить</string>
<string name="events_action_update">Обновить</string>
<plurals name="home_events_unread">
<item quantity="one">События, %d новое</item>
<item quantity="few">События, %d новых</item>
<item quantity="many">События, %d новых</item>
<item quantity="other">События, %d новых</item>
</plurals>
<string name="event_restored_title">Связь восстановлена</string>
<string name="event_restored_text">VPN снова работает.</string>
<string name="event_whitelist_on_title">Оператор включил белые списки</string>
<string name="event_whitelist_on_text">Напрямую работают только российские сайты. Банки и Госуслуги открываются; VPN подключится сам, когда списки снимут.</string>
<string name="event_whitelist_off_title">Белые списки сняты</string>
<string name="event_whitelist_off_text">Зарубежные сайты снова доступны, VPN переподключается.</string>
<string name="event_health_reconnect_title">Переподключились</string>
<string name="event_health_reconnect_server">Сервер перестал отвечать — туннель перезапущен.</string>
<string name="event_health_reconnect_tunnel">Туннель перестал пропускать трафик — он перезапущен.</string>
<string name="event_auto_parked_title">Доверенный Wi-Fi: VPN выключен</string>
<string name="event_auto_parked_text">Включится снова, когда вы уйдёте из этой сети.</string>
<string name="event_auto_started_title">Недоверенная сеть: VPN включён</string>
<string name="event_auto_started_text">Автоподключение включило VPN для этой сети.</string>
<string name="event_pause_over_title">Пауза закончилась</string>
<string name="event_pause_over_text">VPN снова включён.</string>
<string name="event_update_title">Доступна версия %1$s</string>
<string name="event_update_text">Скачайте и установите её из приложения.</string>
</resources>
@@ -581,4 +581,47 @@
<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>
<!-- Events (inbox) -->
<string name="events_title">Events</string>
<string name="events_channel_problems">Connection problems</string>
<string name="events_channel_problems_desc">Whitelists, a dead server, rejected sign-in: when the VPN cannot work</string>
<string name="events_filter_all">All</string>
<string name="events_filter_waiting">Need attention</string>
<string name="events_filter_waiting_n">Need attention · %1$d</string>
<string name="events_filter_connection">Connection</string>
<string name="events_filter_routing">Routes</string>
<string name="events_filter_app">App</string>
<string name="events_empty">Nothing has happened yet. What the app notices and does by itself shows up here.</string>
<string name="events_empty_filter">No events of this kind.</string>
<string name="events_footer">Events are kept on the device for 30 days. Only connection problems arrive as notifications.</string>
<string name="events_notification_settings">Notification settings</string>
<string name="events_today">Today</string>
<string name="events_yesterday">Yesterday</string>
<string name="events_title_count">%1$s ×%2$d</string>
<string name="events_resolved">Resolved</string>
<string name="events_action_open">Open</string>
<string name="events_action_routing">Routes</string>
<string name="events_action_check">Check</string>
<string name="events_action_update">Update</string>
<plurals name="home_events_unread">
<item quantity="one">Events, %d new</item>
<item quantity="other">Events, %d new</item>
</plurals>
<string name="event_restored_title">Connection restored</string>
<string name="event_restored_text">The VPN is up again.</string>
<string name="event_whitelist_on_title">Your carrier turned on whitelists</string>
<string name="event_whitelist_on_text">Only Russian sites work directly. Banks and Gosuslugi open; the VPN reconnects by itself once the whitelist is lifted.</string>
<string name="event_whitelist_off_title">Whitelists are off</string>
<string name="event_whitelist_off_text">Foreign sites are reachable again, the VPN reconnects.</string>
<string name="event_health_reconnect_title">Reconnected</string>
<string name="event_health_reconnect_server">The server stopped answering, so the tunnel was restarted.</string>
<string name="event_health_reconnect_tunnel">The tunnel stopped passing traffic, so it was restarted.</string>
<string name="event_auto_parked_title">Trusted Wi-Fi: VPN turned off</string>
<string name="event_auto_parked_text">It turns back on when you leave this network.</string>
<string name="event_auto_started_title">Untrusted network: VPN turned on</string>
<string name="event_auto_started_text">Auto-connect started the VPN for this network.</string>
<string name="event_pause_over_title">Pause is over</string>
<string name="event_pause_over_text">The VPN turned back on.</string>
<string name="event_update_title">Version %1$s is available</string>
<string name="event_update_text">Download and install it from the app.</string>
</resources>
@@ -0,0 +1,64 @@
/*
* 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.ShxEvent
class InboxTest
{
private val day = 24L * 60 * 60 * 1000
private val noon = dayStart(System.currentTimeMillis()) + day / 2
private fun event(id: Long, at: Long, kind: EventKind, category: EventCategory = EventCategory.CONNECTION, resolved: Boolean = false) =
ShxEvent(id = id, at = at, kind = kind, category = category, title = "t$id", text = "", resolved = resolved)
@Test
fun waitingMeansOpenProblemsAndQuestions()
{
assertThat(event(1, noon, EventKind.WARN).waiting).isTrue()
assertThat(event(2, noon, EventKind.ASK).waiting).isTrue()
assertThat(event(3, noon, EventKind.WARN, resolved = true).waiting).isFalse()
assertThat(event(4, noon, EventKind.INFO).waiting).isFalse()
assertThat(event(5, noon, EventKind.OK).waiting).isFalse()
}
@Test
fun filtersByCategory()
{
val routing = event(1, noon, EventKind.ASK, EventCategory.ROUTING)
val app = event(2, noon, EventKind.INFO, EventCategory.APP)
assertThat(InboxFilter.ROUTING.matches(routing)).isTrue()
assertThat(InboxFilter.ROUTING.matches(app)).isFalse()
assertThat(InboxFilter.APP.matches(app)).isTrue()
assertThat(InboxFilter.ALL.matches(app)).isTrue()
}
@Test
fun groupsByDayNewestFirst()
{
val groups = groupByDay(listOf(
event(1, noon - day, EventKind.INFO),
event(2, noon, EventKind.INFO),
event(3, noon + 60_000, EventKind.WARN),
))
assertThat(groups.map { it.first }).containsExactly(dayStart(noon), dayStart(noon - day))
assertThat(groups[0].second.map { it.id }).containsExactly(3L, 2L)
assertThat(groups[1].second.map { it.id }).containsExactly(1L)
}
}