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 <[email protected]>
This commit is contained in:
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" />
|
||||
|
||||
+10
@@ -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)
|
||||
|
||||
+214
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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
|
||||
|
||||
+6
@@ -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++
|
||||
|
||||
+23
-6
@@ -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 ->
|
||||
|
||||
+48
-2
@@ -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>
|
||||
Reference in new issue
Block a user