From 60252437acb322a79fa866aec83edd7ce363aee3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 11 Oct 2026 21:54:01 +0700 Subject: [PATCH] android: home phase 2 - events inbox and problem notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../android/app/src/main/AndroidManifest.xml | 4 + .../strongswan/android/logic/PauseManager.kt | 2 + .../android/logic/VpnStateService.java | 10 + .../logic/autoconnect/AutoConnectManager.kt | 3 + .../logic/connectivity/HealthCheckManager.kt | 3 + .../logic/connectivity/WhiteListManager.kt | 9 + .../android/logic/events/EventLog.kt | 348 ++++++++++++++++++ .../strongswan/android/logic/events/Events.kt | 135 +++++++ .../android/logic/updates/UpdateManager.kt | 2 + .../strongswan/android/ui/InboxActivity.kt | 98 +++++ .../android/ui/compose/HomeScreen.kt | 8 +- .../android/ui/compose/InboxScreen.kt | 280 ++++++++++++++ .../android/ui/compose/home/HomeBanners.kt | 4 +- .../android/ui/compose/home/HomeStatus.kt | 27 +- .../android/ui/compose/theme/ShxTheme.kt | 14 +- .../app/src/main/res/values-ru/strings.xml | 45 +++ .../app/src/main/res/values/strings.xml | 43 +++ .../android/ui/compose/InboxTest.kt | 64 ++++ 18 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt create mode 100644 src/frontends/android/app/src/test/java/org/strongswan/android/ui/compose/InboxTest.kt 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) + } +}