diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index f0dfa5cfa..81391e181 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -79,6 +79,9 @@
+
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
index 12fab5cd7..abc592aae 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java
@@ -57,6 +57,7 @@ import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.VpnStateService.ErrorState;
import org.strongswan.android.logic.VpnStateService.State;
import org.strongswan.android.logic.imc.ImcState;
+import org.strongswan.android.logic.stats.TrafficHistory;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.logic.routing.RouteCheck;
import org.strongswan.android.logic.routing.RoutingManager;
@@ -919,6 +920,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
private void setState(State state)
{
+ /* the daily traffic history samples the counters while the tunnel is up */
+ if (state == State.CONNECTED)
+ {
+ TrafficHistory.start(this);
+ }
+ else
+ {
+ TrafficHistory.stop(this);
+ }
synchronized (mServiceLock)
{
if (mService != null)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt
new file mode 100644
index 000000000..087f8b07c
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt
@@ -0,0 +1,214 @@
+/*
+ * 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.stats
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.json.JSONArray
+import org.json.JSONObject
+import org.strongswan.android.logic.CharonVpnService
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+
+/** Bytes of one day: through the tunnel and straight past it (userspace router). */
+data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
+{
+ val total: Long get() = vpn + direct
+}
+
+/**
+ * Daily traffic history (F_Stats). While the tunnel is up the daemon
+ * counters are sampled every [SAMPLE_S] seconds and the growth is added to
+ * the current day; the counters restart with every connection, so a drop
+ * starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
+ */
+object TrafficHistory
+{
+ private const val TAG = "TrafficHistory"
+ private const val FILE_NAME = "traffic.json"
+ private const val SAMPLE_S = 15L
+ private const val KEEP_DAYS = 120
+
+ private val executor = Executors.newSingleThreadScheduledExecutor()
+ private var task: ScheduledFuture<*>? = null
+ private var lastVpn = -1L
+ private var lastDirect = -1L
+ @Volatile private var loaded = false
+ private val days = LinkedHashMap()
+ private var firstDay: String? = null
+
+ private val _changes = MutableStateFlow(0L)
+ /** Bumps whenever a sample changed the history (for live screens). */
+ val changes: StateFlow = _changes
+
+ internal fun dayKey(ms: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(ms)
+
+ /** Starts sampling (the tunnel came up); harmless when already running. */
+ @JvmStatic
+ @Synchronized
+ fun start(context: Context)
+ {
+ val app = context.applicationContext
+ if (task != null)
+ {
+ return
+ }
+ lastVpn = -1L
+ lastDirect = -1L
+ task = executor.scheduleWithFixedDelay({ sample(app) }, 0, SAMPLE_S, TimeUnit.SECONDS)
+ }
+
+ /** Stops sampling after a last sample (the tunnel goes down). */
+ @JvmStatic
+ @Synchronized
+ fun stop(context: Context)
+ {
+ val app = context.applicationContext
+ val running = task ?: return
+ running.cancel(false)
+ task = null
+ executor.execute { sample(app) }
+ }
+
+ private fun sample(context: Context)
+ {
+ val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
+ val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
+ /* direct bytes only exist with the userspace router (s[3]) */
+ val direct = runCatching { CharonVpnService.getRoutingStats() }.getOrNull()?.takeIf { it.size >= 4 }?.get(3) ?: 0L
+ val dVpn = if (lastVpn < 0 || vpn < lastVpn) 0L else vpn - lastVpn
+ val dDirect = if (lastDirect < 0 || direct < lastDirect) 0L else direct - lastDirect
+ /* a new session: count what it already did since its counters started */
+ val addVpn = if (lastVpn >= 0 && vpn < lastVpn) vpn else dVpn
+ val addDirect = if (lastDirect >= 0 && direct < lastDirect) direct else dDirect
+ lastVpn = vpn
+ lastDirect = direct
+ if (addVpn > 0 || addDirect > 0)
+ {
+ add(context, System.currentTimeMillis(), addVpn, addDirect)
+ }
+ }
+
+ @Synchronized
+ internal fun add(context: Context, at: Long, vpn: Long, direct: Long)
+ {
+ load(context)
+ val key = dayKey(at)
+ val row = days.getOrPut(key) { LongArray(2) }
+ row[0] += vpn
+ row[1] += direct
+ if (firstDay == null)
+ {
+ firstDay = key
+ }
+ prune()
+ save(context)
+ _changes.value = System.currentTimeMillis()
+ org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
+ }
+
+ /** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
+ @Synchronized
+ fun range(context: Context, from: Long, to: Long): List
+ {
+ load(context)
+ val result = ArrayList()
+ val c = Calendar.getInstance()
+ c.timeInMillis = from
+ while (c.timeInMillis <= to)
+ {
+ val key = dayKey(c.timeInMillis)
+ val row = days[key]
+ result += DayTraffic(key, row?.get(0) ?: 0L, row?.get(1) ?: 0L)
+ c.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ return result
+ }
+
+ @Synchronized
+ fun today(context: Context): DayTraffic = range(context, System.currentTimeMillis(), System.currentTimeMillis()).first()
+
+ /** First day with any record, null before the first connection. */
+ @Synchronized
+ fun firstDay(context: Context): String?
+ {
+ load(context)
+ return firstDay
+ }
+
+ private fun prune()
+ {
+ val cutoff = dayKey(System.currentTimeMillis() - KEEP_DAYS * 24L * 60 * 60 * 1000)
+ days.keys.filter { it < cutoff }.forEach { days.remove(it) }
+ }
+
+ private fun load(context: Context)
+ {
+ if (loaded)
+ {
+ return
+ }
+ loaded = true
+ try
+ {
+ val file = File(context.filesDir, FILE_NAME)
+ if (!file.exists())
+ {
+ return
+ }
+ val json = JSONObject(file.readText())
+ firstDay = json.optString("first").ifEmpty { null }
+ val array = json.optJSONArray("days") ?: JSONArray()
+ for (i in 0 until array.length())
+ {
+ val o = array.getJSONObject(i)
+ days[o.getString("d")] = longArrayOf(o.optLong("v"), o.optLong("r"))
+ }
+ }
+ catch (e: Exception)
+ {
+ Log.w(TAG, "Loading the traffic history failed", e)
+ }
+ }
+
+ private fun save(context: Context)
+ {
+ try
+ {
+ val array = JSONArray()
+ days.toSortedMap().forEach { (d, row) -> array.put(JSONObject().put("d", d).put("v", row[0]).put("r", row[1])) }
+ val json = JSONObject().put("first", firstDay ?: JSONObject.NULL).put("days", array)
+ val file = File(context.filesDir, FILE_NAME)
+ val tmp = File(file.path + ".tmp")
+ tmp.writeText(json.toString())
+ if (!tmp.renameTo(file))
+ {
+ file.writeText(json.toString())
+ tmp.delete()
+ }
+ }
+ catch (e: Exception)
+ {
+ Log.w(TAG, "Saving the traffic history failed", e)
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/StatsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/StatsActivity.kt
new file mode 100644
index 000000000..2ebb55c9e
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/StatsActivity.kt
@@ -0,0 +1,370 @@
+/*
+ * 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
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+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.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.produceState
+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.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.graphics.drawable.toBitmap
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.strongswan.android.R
+import org.strongswan.android.logic.routing.AppTraffic
+import org.strongswan.android.logic.routing.RoutingManager
+import org.strongswan.android.logic.stats.DayTraffic
+import org.strongswan.android.logic.stats.TrafficHistory
+import org.strongswan.android.ui.compose.SecondaryScaffold
+import org.strongswan.android.ui.compose.design.ShxIcons
+import org.strongswan.android.ui.compose.design.ShxTabs
+import org.strongswan.android.ui.compose.formatBytes
+import org.strongswan.android.ui.compose.theme.LocalShxColors
+import org.strongswan.android.ui.compose.theme.ShxTheme
+import org.strongswan.android.ui.compose.theme.ShxType
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+/** «Statistics» (F_Stats / F_StatsEmpty): traffic per day, VPN vs direct, top apps. */
+class StatsActivity : AppCompatActivity()
+{
+ override fun onCreate(savedInstanceState: Bundle?)
+ {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ ShxTheme {
+ StatsScreen(onBack = { finish() })
+ }
+ }
+ }
+}
+
+private enum class Period(val label: Int) { DAY(R.string.stats_day), WEEK(R.string.stats_week), MONTH(R.string.stats_month) }
+
+/** One app row: label, icon, bytes and whether it goes direct. */
+private class AppRow(val label: String, val icon: android.graphics.drawable.Drawable?, val bytes: Long, val direct: Boolean)
+
+private class StatsData(val days: List, val title: String, val labels: List, val highlight: Int, val first: String?, val apps: List, val usage: Boolean)
+
+private fun startOfDay(ms: Long): Long = Calendar.getInstance().apply {
+ timeInMillis = ms; set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
+}.timeInMillis
+
+private fun load(context: Context, period: Period): StatsData
+{
+ val now = System.currentTimeMillis()
+ val day = 24L * 60 * 60 * 1000
+ val cal = Calendar.getInstance()
+ val (from, to) = when (period)
+ {
+ /* the day view still shows the last week, today highlighted */
+ Period.DAY -> startOfDay(now) - 6 * day to now
+ Period.WEEK ->
+ {
+ cal.timeInMillis = startOfDay(now)
+ cal.firstDayOfWeek = Calendar.MONDAY
+ cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
+ if (cal.timeInMillis > now) cal.add(Calendar.WEEK_OF_YEAR, -1)
+ cal.timeInMillis to cal.timeInMillis + 6 * day
+ }
+ Period.MONTH ->
+ {
+ cal.timeInMillis = startOfDay(now)
+ cal.set(Calendar.DAY_OF_MONTH, 1)
+ val start = cal.timeInMillis
+ start to start + (cal.getActualMaximum(Calendar.DAY_OF_MONTH) - 1) * day
+ }
+ }
+ val days = TrafficHistory.range(context, from, to)
+ val dow = SimpleDateFormat("EEEEEE", Locale.getDefault())
+ val parse = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ val labels = days.mapIndexed { i, d ->
+ val date = parse.parse(d.day)!!
+ when (period)
+ {
+ Period.MONTH -> if (i % 5 == 0) (i + 1).toString() else ""
+ else -> dow.format(date).replaceFirstChar { it.uppercase() }
+ }
+ }
+ val today = TrafficHistory.dayKey(now)
+ val highlight = days.indexOfFirst { it.day == today }
+ val title = when (period)
+ {
+ Period.DAY -> context.getString(R.string.stats_today)
+ Period.WEEK ->
+ {
+ val f = SimpleDateFormat("d MMMM", Locale.getDefault())
+ val a = parse.parse(days.first().day)!!
+ val b = parse.parse(days.last().day)!!
+ SimpleDateFormat("d", Locale.getDefault()).format(a) + " – " + f.format(b)
+ }
+ Period.MONTH -> SimpleDateFormat("LLLL yyyy", Locale.getDefault()).format(parse.parse(days.first().day)!!).replaceFirstChar { it.uppercase() }
+ }
+ /* apps: system counters over the same period (needs usage access) */
+ val usage = AppTraffic.hasAccess(context)
+ val since = if (period == Period.DAY) startOfDay(now) else from
+ val direct = runCatching { RoutingManager.getInstance(context).computeExcludedPackages() }.getOrDefault(emptySet())
+ val pm = context.packageManager
+ val apps = if (!usage) emptyList() else AppTraffic.since(context, since).entries.sortedByDescending { it.value }.take(5).map { (pkg, bytes) ->
+ val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
+ AppRow(info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() }, bytes, pkg in direct)
+ }
+ return StatsData(days, title, labels, highlight, TrafficHistory.firstDay(context), apps, usage)
+}
+
+@Composable
+private fun StatsScreen(onBack: () -> Unit)
+{
+ val c = LocalShxColors.current
+ val context = LocalContext.current
+ var period by rememberSaveable { mutableIntStateOf(Period.WEEK.ordinal) }
+ val changes by TrafficHistory.changes.collectAsStateWithLifecycle()
+ val data by produceState(null, period, changes) {
+ value = withContext(Dispatchers.IO) { load(context, Period.entries[period]) }
+ }
+ SecondaryScaffold(title = stringResource(R.string.stats_title), onBack = onBack) { padding ->
+ Column(
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
+ )
+ {
+ ShxTabs(Period.entries.map { stringResource(it.label) }, period, { period = it })
+ val d = data ?: return@Column
+ val today = TrafficHistory.dayKey(System.currentTimeMillis())
+ /* F_StatsEmpty: nothing older than today yet */
+ val fresh = d.first == null || d.first >= today
+ val shown = if (Period.entries[period] == Period.DAY) d.days.filter { it.day == today } else d.days
+ val vpn = shown.sumOf { it.vpn }
+ val direct = shown.sumOf { it.direct }
+ val shape = RoundedCornerShape(24.dp)
+ Column(
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(18.dp),
+ )
+ {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp))
+ {
+ Text(d.title, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub)
+ Text(formatBytes(vpn + direct), style = ShxType.number.copy(fontSize = 32.sp, lineHeight = 36.sp), color = c.text)
+ }
+ Bars(d, fresh, today)
+ if (!fresh)
+ {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
+ {
+ Tile(stringResource(R.string.stats_vpn), formatBytes(vpn), c.accentContainer, c.onAccentContainer, Modifier.weight(1f))
+ Tile(stringResource(R.string.stats_direct), formatBytes(direct), c.tile, c.textSoft, Modifier.weight(1f))
+ }
+ }
+ }
+ if (fresh)
+ {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.accentSoft).padding(16.dp),
+ )
+ {
+ Icon(ShxIcons.Stats, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(22.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp))
+ {
+ Text(stringResource(R.string.stats_fresh_title), style = ShxType.cardTitle, color = c.text)
+ Text(stringResource(R.string.stats_fresh_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer)
+ }
+ }
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp))
+ {
+ Text(stringResource(R.string.stats_apps), style = ShxType.cardTitle, color = c.text, modifier = Modifier.padding(horizontal = 4.dp))
+ val panel = RoundedCornerShape(22.dp)
+ Column(Modifier.fillMaxWidth().clip(panel).background(c.card).border(1.dp, c.cardBorder, panel).padding(horizontal = 14.dp, vertical = 4.dp))
+ {
+ when
+ {
+ !d.usage -> Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) {
+ if (runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))) }.isFailure)
+ {
+ runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
+ }
+ }.padding(vertical = 14.dp),
+ )
+ {
+ Text(stringResource(R.string.stats_apps_access), style = ShxType.note.copy(fontWeight = FontWeight.Normal), color = c.textSoft, modifier = Modifier.weight(1f))
+ Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.accentText)
+ }
+ d.apps.isEmpty() -> Text(stringResource(R.string.stats_apps_none), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(vertical = 14.dp))
+ else ->
+ {
+ val max = d.apps.maxOf { it.bytes }.coerceAtLeast(1)
+ d.apps.forEach { AppLine(it, it.bytes.toFloat() / max) }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Bars(d: StatsData, fresh: Boolean, today: String)
+{
+ val c = LocalShxColors.current
+ val max = d.days.maxOf { it.total }.coerceAtLeast(1)
+ val month = d.days.size > 7
+ val outline = c.outline
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(if (month) 3.dp else 10.dp),
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier.fillMaxWidth().height(if (fresh) 140.dp else 170.dp),
+ )
+ {
+ d.days.forEachIndexed { i, day ->
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f))
+ {
+ Column(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.Bottom))
+ {
+ val before = d.first == null || day.day < d.first
+ if (day.day > today)
+ { /* days still ahead: nothing to draw */
+ }
+ else if (before)
+ { /* no history yet: a dashed placeholder as on F_StatsEmpty */
+ Box(
+ Modifier.fillMaxWidth().height(34.dp).drawBehind {
+ drawRoundRect(
+ outline, cornerRadius = CornerRadius(6.dp.toPx()),
+ style = Stroke(1.5.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))),
+ )
+ },
+ )
+ }
+ else
+ {
+ val full = 150f
+ val directH = (day.direct.toFloat() / max * full).coerceAtLeast(if (day.direct > 0) 3f else 0f)
+ val vpnH = (day.vpn.toFloat() / max * full).coerceAtLeast(if (day.vpn > 0 || day.direct == 0L) 4f else 0f)
+ if (directH > 0f)
+ {
+ Box(Modifier.fillMaxWidth().height(directH.dp).clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 2.dp, bottomEnd = 2.dp)).background(outline))
+ }
+ Box(
+ Modifier.fillMaxWidth().height(vpnH.dp)
+ .clip(if (directH > 0f) RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp, bottomStart = 6.dp, bottomEnd = 6.dp) else RoundedCornerShape(6.dp))
+ .background(if (fresh && day.day == today) c.accent else if (fresh) c.tile else c.accent),
+ )
+ }
+ }
+ Text(
+ d.labels[i], style = ShxType.meta.copy(fontWeight = if (i == d.highlight) FontWeight.ExtraBold else FontWeight.SemiBold),
+ color = if (i == d.highlight) c.text else c.textSub, maxLines = 1, softWrap = false,
+ /* two-digit dates of the month view are wider than their thin bar */
+ modifier = Modifier.wrapContentWidth(unbounded = true),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun Tile(label: String, value: String, bg: Color, fg: Color, modifier: Modifier)
+{
+ val c = LocalShxColors.current
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier.clip(RoundedCornerShape(16.dp)).background(bg).padding(12.dp))
+ {
+ Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = fg)
+ Text(value, style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text)
+ }
+}
+
+@Composable
+private fun AppLine(app: AppRow, share: Float)
+{
+ val c = LocalShxColors.current
+ val icon = remember(app) { app.icon?.toBitmap(96, 96)?.asImageBitmap() }
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp))
+ {
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile))
+ {
+ if (icon != null)
+ {
+ Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
+ }
+ else
+ {
+ Text(app.label.take(1).uppercase(), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.textSoft)
+ }
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp))
+ {
+ Row()
+ {
+ Text(app.label, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Spacer(Modifier.size(8.dp))
+ Text(formatBytes(app.bytes), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
+ }
+ Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
+ {
+ /* purple: through the VPN, grey: goes direct */
+ Box(Modifier.fillMaxWidth(share.coerceIn(0.02f, 1f)).height(6.dp).clip(RoundedCornerShape(3.dp)).background(if (app.direct) c.textSub else c.accent))
+ }
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/Format.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/Format.kt
index fb0615c9d..104778bee 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/Format.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/Format.kt
@@ -12,13 +12,16 @@ package org.strongswan.android.ui.compose
import java.util.Locale
import kotlin.math.abs
+/* «1,2 ГБ» in Russian, «1.2 GB» otherwise */
+private fun russian() = Locale.getDefault().language == "ru"
+
fun formatBytes(bytes: Long): String
{
+ val units = if (russian()) arrayOf("Б", "КБ", "МБ", "ГБ", "ТБ") else arrayOf("B", "KB", "MB", "GB", "TB")
if (bytes <= 0L)
{
- return "0 B"
+ return "0 " + units[0]
}
- val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex)
@@ -26,17 +29,18 @@ fun formatBytes(bytes: Long): String
value /= 1024
unit++
}
- return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
- else String.format(Locale.US, "%.1f %s", value, units[unit])
+ return if (unit == 0) String.format(Locale.getDefault(), "%d %s", bytes, units[unit])
+ else String.format(Locale.getDefault(), "%.1f %s", value, units[unit])
}
fun formatRate(bytesPerSecond: Long): String
{
+ val perSecond = if (russian()) "/с" else "/s"
if (abs(bytesPerSecond) < 32)
{
- return "0 B/s"
+ return formatBytes(0) + perSecond
}
- return formatBytes(bytesPerSecond) + "/s"
+ return formatBytes(bytesPerSecond) + perSecond
}
fun formatDuration(seconds: Long): String
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 94c7ee221..a9ad54deb 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
@@ -259,6 +259,10 @@ fun HomeScreen(
kotlinx.coroutines.delay(600)
}
}
+ val historyChanges by org.strongswan.android.logic.stats.TrafficHistory.changes.collectAsStateWithLifecycle()
+ val today by produceState(null, historyChanges) {
+ value = withContext(Dispatchers.IO) { org.strongswan.android.logic.stats.TrafficHistory.today(context) }
+ }
val model = HomeModel(
state = state,
traffic = traffic,
@@ -275,6 +279,7 @@ fun HomeScreen(
findingFastest = findingFastest,
certExpiry = certExpiry,
connectStep = connectStep,
+ today = today,
)
val actions = HomeActions(
connect = { selected?.let { viewModel.connect(context, it) } },
@@ -316,6 +321,7 @@ fun HomeScreen(
},
details = { viewModel.openDetails(true) },
certUpdate = { ShxNavigation.requestAddServer() },
+ stats = { context.startActivity(Intent(context, org.strongswan.android.ui.StatsActivity::class.java)) },
certLater = {
certAlias?.let { snoozeCertExpiry(context, it) }
certSnoozes++
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt
index f26d04ed1..ea7a09e54 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt
@@ -64,6 +64,8 @@ internal data class HomeModel(
val certExpiry: CertExpiry? = null,
/* finished steps of the running connection (F_HomeConnecting) */
val connectStep: Int = 1,
+ /* today's traffic from the history */
+ val today: org.strongswan.android.logic.stats.DayTraffic? = null,
)
/** What Home can do. */
@@ -87,6 +89,7 @@ internal class HomeActions(
val details: () -> Unit,
val certUpdate: () -> Unit = {},
val certLater: () -> Unit = {},
+ val stats: () -> Unit = {},
)
/** Two-letter badge of a server: the exit country when known, else the profile initials. */
@@ -310,16 +313,18 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
QuickTile(ShxIcons.Bolt, stringResource(if (m.findingFastest) R.string.home_fastest_searching else R.string.home_action_fastest), a.fastest, Modifier.weight(1f))
QuickTile(ShxIcons.Help, stringResource(R.string.home_action_not_loading), a.routeCheck, Modifier.weight(1f))
}
- val t = m.traffic
- val split = t.split
- val vpnShare = split?.let { 100 - it.directShare }
+ /* today from the history (F_Home «1,24 ГБ сегодня»), the session before the first sample */
+ val today = m.today
+ val todayTotal = today?.total ?: 0L
+ val vpnShare = if (today != null && today.direct > 0 && todayTotal > 0) (today.vpn * 100 / todayTotal).toInt()
+ else m.traffic.split?.let { 100 - it.directShare }
TrafficCard(
- amount = formatBytes(t.bytesIn + t.bytesOut),
- period = stringResource(R.string.home_traffic_session),
+ amount = formatBytes(if (todayTotal > 0) todayTotal else m.traffic.bytesIn + m.traffic.bytesOut),
+ period = stringResource(if (todayTotal > 0) R.string.home_traffic_today else R.string.home_traffic_session),
vpnShare = vpnShare,
caption = if (vpnShare != null) stringResource(R.string.home_traffic_split, vpnShare, 100 - vpnShare)
else stringResource(R.string.home_traffic_all_vpn),
- onClick = a.details,
+ onClick = a.stats,
)
}
connecting || s.vpnState == State.DISCONNECTING ->
@@ -352,6 +357,18 @@ internal fun HomeContent(m: HomeModel, a: HomeActions)
QuickTile(ShxIcons.Globe, stringResource(R.string.home_action_switch), a.pickServer, Modifier.weight(1f))
}
}
+ /* F_HomeScenario: today's traffic stays visible while off */
+ m.today?.takeIf { it.total > 0 }?.let { today ->
+ val share = if (today.direct > 0) (today.vpn * 100 / today.total).toInt() else null
+ TrafficCard(
+ amount = formatBytes(today.total),
+ period = stringResource(R.string.home_traffic_today),
+ vpnShare = share,
+ caption = if (share != null) stringResource(R.string.home_traffic_split, share, 100 - share)
+ else stringResource(R.string.home_traffic_all_vpn),
+ onClick = a.stats,
+ )
+ }
}
}
m.certExpiry?.let { expiry ->
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt
index 0ce46b0e5..a05140907 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt
@@ -80,6 +80,8 @@ internal data class WidgetSnapshot(
val profileUuid: String?,
/* wall-clock start of the running connection, 0 if none */
val connectedSince: Long,
+ /* today's traffic from the history */
+ val today: org.strongswan.android.logic.stats.DayTraffic? = null,
)
/**
@@ -132,6 +134,33 @@ object ShxWidgetState
}
}
+ /** Redraws the widgets (new traffic numbers); at most once a minute. */
+ @JvmStatic
+ fun refresh(context: Context)
+ {
+ val now = System.currentTimeMillis()
+ if (now - lastRefresh < 60_000L)
+ {
+ return
+ }
+ lastRefresh = now
+ val app = context.applicationContext
+ scope.launch {
+ try
+ {
+ if (GlanceAppWidgetManager(app).getGlanceIds(ShxWidget::class.java).isNotEmpty())
+ {
+ ShxWidget().updateAll(app)
+ }
+ }
+ catch (_: Exception)
+ {
+ }
+ }
+ }
+
+ @Volatile private var lastRefresh = 0L
+
internal suspend fun snapshot(context: Context): WidgetSnapshot = withContext(Dispatchers.IO)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
@@ -154,7 +183,8 @@ object ShxWidgetState
}
}
val state = prefs.getString(KEY_STATE, null) ?: VpnStateService.State.DISABLED.name
- WidgetSnapshot(state, prefs.getString(KEY_ERROR, null), name, uuid, prefs.getLong(KEY_SINCE, 0L))
+ val today = runCatching { org.strongswan.android.logic.stats.TrafficHistory.today(context) }.getOrNull()?.takeIf { it.total > 0 }
+ WidgetSnapshot(state, prefs.getString(KEY_ERROR, null), name, uuid, prefs.getLong(KEY_SINCE, 0L), today)
}
}
@@ -371,12 +401,28 @@ private fun LargeWidget(context: Context, s: WidgetSnapshot, look: Look, toggle:
Title(t, problem)
}
Spacer(GlanceModifier.height(2.dp))
- Sub(s.profileName ?: context.getString(R.string.status_choose_profile), problem)
+ val server = s.profileName ?: context.getString(R.string.status_choose_profile)
+ Sub(
+ s.today?.let { context.getString(R.string.widget_server_today, server, org.strongswan.android.ui.compose.formatBytes(it.total)) } ?: server,
+ problem,
+ )
}
Spacer(GlanceModifier.width(8.dp))
ToggleButton(look, toggle)
}
Spacer(GlanceModifier.defaultWeight())
+ /* today's split: purple through the VPN, green direct */
+ s.today?.takeIf { it.direct > 0 }?.let { t ->
+ val width = LocalSize.current.width - 28.dp
+ val vpnWidth = (width - 2.dp) * (t.vpn.toFloat() / t.total)
+ Row(GlanceModifier.fillMaxWidth().height(6.dp))
+ {
+ Box(GlanceModifier.width(vpnWidth).height(6.dp).cornerRadius(3.dp).background(ACCENT)) {}
+ Spacer(GlanceModifier.width(2.dp))
+ Box(GlanceModifier.defaultWeight().height(6.dp).cornerRadius(3.dp).background(cp(0xFF3FBF7F, 0xFF3FBF7F))) {}
+ }
+ Spacer(GlanceModifier.defaultWeight())
+ }
Row(GlanceModifier.fillMaxWidth())
{
val pause = Intent(context, PauseReceiver::class.java).setAction(PauseManager.ACTION_PAUSE)
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 46099049d..d4e8eaee8 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
@@ -1137,4 +1137,18 @@
Безопасно: приложение ставит только файлы с подписью SHX и проверенной контрольной суммой.
Не сейчас
Открыть разрешение
+ Статистика
+ День
+ Неделя
+ Месяц
+ Сегодня
+ Через VPN
+ Напрямую (РФ)
+ Приложения
+ Разрешите доступ к статистике, чтобы видеть, какие приложения тратят больше всего.
+ За этот период трафика приложений пока нет.
+ История копится с сегодняшнего дня
+ Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.
+ сегодня
+ %1$s · %2$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 ab39acf60..e9fa47784 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -1123,4 +1123,18 @@
Safe: the app only installs files signed by SHX with a verified checksum.
Not now
Open permission
+ Statistics
+ Day
+ Week
+ Month
+ Today
+ Through VPN
+ Direct (RU)
+ Apps
+ Allow usage access to see which apps use the most traffic.
+ No app traffic in this period yet.
+ The history starts today
+ In a week you will see how much traffic goes through the VPN and how much direct, and which apps use the most.
+ today
+ %1$s · %2$s today