diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 0906070fd..367097696 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -129,6 +129,10 @@
android:name=".ui.RouteCheckActivity"
android:label="@string/route_check_title">
+
+
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
index dfb1f53f3..471b06c7f 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt
@@ -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)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
index 41bdeb43c..c7bd65fb3 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java
@@ -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;
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
index 062da305f..b47259f11 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt
@@ -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)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt
index 62b8465ac..b8188e005 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/HealthCheckManager.kt
@@ -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 ->
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
index 1b339e544..c905da6ff 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt
@@ -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++
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt
new file mode 100644
index 000000000..6587511a6
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt
@@ -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 .
+ *
+ * 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> = _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, now: Long): List =
+ 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
+ {
+ 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
+ }
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt
new file mode 100644
index 000000000..c691c4447
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt
@@ -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 .
+ *
+ * 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",
+ )
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
index e071e8ca0..d910e3af9 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt
@@ -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
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt
new file mode 100644
index 000000000..f8ce5e4d4
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt
@@ -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 .
+ *
+ * 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)
+ {
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt
index 64eeefe58..6209228f1 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt
@@ -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>() }
/* 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(
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt
new file mode 100644
index 000000000..050c86ceb
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt
@@ -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): List>> =
+ 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
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt
index 41258db5a..12da8f96a 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt
@@ -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(),
)
{
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt
index e1d978d2b..2c91dcc97 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeStatus.kt
@@ -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
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt
index 161192045..5d5f060f9 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt
@@ -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 }
/**
diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml
index 73b23acce..886b25f2a 100644
--- a/src/frontends/android/app/src/main/res/values-ru/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml
@@ -582,4 +582,49 @@
Следующая попытка через %1$d с
Другой сервер
Изменить профиль
+
+ События
+ Проблемы подключения
+ Белые списки, недоступный сервер, отклонённый вход — когда VPN не может работать
+ Все
+ Ждут решения
+ Ждут решения · %1$d
+ Подключение
+ Маршруты
+ Приложение
+ Пока ничего не случилось. Здесь появится всё, что приложение заметит и сделает само.
+ Таких событий нет.
+ Храним 30 дней на устройстве. Уведомлениями приходят только проблемы подключения.
+ Настройки уведомлений
+ Сегодня
+ Вчера
+ %1$s ×%2$d
+ Решено
+ Открыть
+ Маршруты
+ Проверить
+ Обновить
+
+ - События, %d новое
+ - События, %d новых
+ - События, %d новых
+ - События, %d новых
+
+ Связь восстановлена
+ VPN снова работает.
+ Оператор включил белые списки
+ Напрямую работают только российские сайты. Банки и Госуслуги открываются; VPN подключится сам, когда списки снимут.
+ Белые списки сняты
+ Зарубежные сайты снова доступны, VPN переподключается.
+ Переподключились
+ Сервер перестал отвечать — туннель перезапущен.
+ Туннель перестал пропускать трафик — он перезапущен.
+ Доверенный Wi-Fi: VPN выключен
+ Включится снова, когда вы уйдёте из этой сети.
+ Недоверенная сеть: VPN включён
+ Автоподключение включило VPN для этой сети.
+ Пауза закончилась
+ VPN снова включён.
+ Доступна версия %1$s
+ Скачайте и установите её из приложения.
diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml
index 2d1e07d45..446e74b57 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -581,4 +581,47 @@
Next attempt in %1$d s
Other server
Edit profile
+
+ Events
+ Connection problems
+ Whitelists, a dead server, rejected sign-in: when the VPN cannot work
+ All
+ Need attention
+ Need attention · %1$d
+ Connection
+ Routes
+ App
+ Nothing has happened yet. What the app notices and does by itself shows up here.
+ No events of this kind.
+ Events are kept on the device for 30 days. Only connection problems arrive as notifications.
+ Notification settings
+ Today
+ Yesterday
+ %1$s ×%2$d
+ Resolved
+ Open
+ Routes
+ Check
+ Update
+
+ - Events, %d new
+ - Events, %d new
+
+ Connection restored
+ The VPN is up again.
+ Your carrier turned on whitelists
+ Only Russian sites work directly. Banks and Gosuslugi open; the VPN reconnects by itself once the whitelist is lifted.
+ Whitelists are off
+ Foreign sites are reachable again, the VPN reconnects.
+ Reconnected
+ The server stopped answering, so the tunnel was restarted.
+ The tunnel stopped passing traffic, so it was restarted.
+ Trusted Wi-Fi: VPN turned off
+ It turns back on when you leave this network.
+ Untrusted network: VPN turned on
+ Auto-connect started the VPN for this network.
+ Pause is over
+ The VPN turned back on.
+ Version %1$s is available
+ Download and install it from the app.
diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/InboxTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/InboxTest.kt
new file mode 100644
index 000000000..8e1a9f5ab
--- /dev/null
+++ b/src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/InboxTest.kt
@@ -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 .
+ *
+ * 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)
+ }
+}