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