From 638c58f0e41c2320cbe282aa52e8b501c5e82062 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 15:08:13 +0700 Subject: [PATCH] android: daily traffic history and the statistics screen as in the Final 2026 mockup; today's traffic on Home and the widget Co-Authored-By: Claude Opus 5.5 --- .../android/app/src/main/AndroidManifest.xml | 3 + .../android/logic/CharonVpnService.java | 10 + .../android/logic/stats/TrafficHistory.kt | 214 ++++++++++ .../strongswan/android/ui/StatsActivity.kt | 370 ++++++++++++++++++ .../strongswan/android/ui/compose/Format.kt | 16 +- .../android/ui/compose/HomeScreen.kt | 6 + .../android/ui/compose/home/HomeContent.kt | 29 +- .../strongswan/android/ui/widget/ShxWidget.kt | 50 ++- .../app/src/main/res/values-ru/strings.xml | 14 + .../app/src/main/res/values/strings.xml | 14 + 10 files changed, 712 insertions(+), 14 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/stats/TrafficHistory.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/StatsActivity.kt 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