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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 15:08:13 +07:00
1 parent 92627da459
commit 638c58f0e4
10 files changed
+712 -14

No files matched your search

@@ -79,6 +79,9 @@
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ui.StatsActivity"
android:exported="false" />
<activity
android:name=".ui.BatteryActivity"
android:exported="false" />
@@ -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)
@@ -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 <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.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<String, LongArray>()
private var firstDay: String? = null
private val _changes = MutableStateFlow(0L)
/** Bumps whenever a sample changed the history (for live screens). */
val changes: StateFlow<Long> = _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<DayTraffic>
{
load(context)
val result = ArrayList<DayTraffic>()
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)
}
}
}
@@ -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<DayTraffic>, val title: String, val labels: List<String>, val highlight: Int, val first: String?, val apps: List<AppRow>, 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<StatsData?>(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))
}
}
}
}
@@ -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
@@ -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<org.strongswan.android.logic.stats.DayTraffic?>(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++
@@ -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 ->
@@ -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)
@@ -1137,4 +1137,18 @@
<string name="install_perm_safe">Безопасно: приложение ставит только файлы с подписью SHX и проверенной контрольной суммой.</string>
<string name="install_perm_later">Не сейчас</string>
<string name="install_perm_open">Открыть разрешение</string>
<string name="stats_title">Статистика</string>
<string name="stats_day">День</string>
<string name="stats_week">Неделя</string>
<string name="stats_month">Месяц</string>
<string name="stats_today">Сегодня</string>
<string name="stats_vpn">Через VPN</string>
<string name="stats_direct">Напрямую (РФ)</string>
<string name="stats_apps">Приложения</string>
<string name="stats_apps_access">Разрешите доступ к статистике, чтобы видеть, какие приложения тратят больше всего.</string>
<string name="stats_apps_none">За этот период трафика приложений пока нет.</string>
<string name="stats_fresh_title">История копится с сегодняшнего дня</string>
<string name="stats_fresh_text">Через неделю здесь будет видно, сколько трафика идёт через VPN и сколько напрямую, и какие приложения тратят больше всего.</string>
<string name="home_traffic_today">сегодня</string>
<string name="widget_server_today">%1$s · %2$s сегодня</string>
</resources>
@@ -1123,4 +1123,18 @@
<string name="install_perm_safe">Safe: the app only installs files signed by SHX with a verified checksum.</string>
<string name="install_perm_later">Not now</string>
<string name="install_perm_open">Open permission</string>
<string name="stats_title">Statistics</string>
<string name="stats_day">Day</string>
<string name="stats_week">Week</string>
<string name="stats_month">Month</string>
<string name="stats_today">Today</string>
<string name="stats_vpn">Through VPN</string>
<string name="stats_direct">Direct (RU)</string>
<string name="stats_apps">Apps</string>
<string name="stats_apps_access">Allow usage access to see which apps use the most traffic.</string>
<string name="stats_apps_none">No app traffic in this period yet.</string>
<string name="stats_fresh_title">The history starts today</string>
<string name="stats_fresh_text">In a week you will see how much traffic goes through the VPN and how much direct, and which apps use the most.</string>
<string name="home_traffic_today">today</string>
<string name="widget_server_today">%1$s · %2$s today</string>
</resources>