android: system notifications as in the Final 2026 mockup: connecting with Stop, answers in suggestion pushes, Update / What is new, weekly summary

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 16:09:51 +07:00
1 parent 8880601ef6
commit 1e32410fca
12 files changed
+252 -6

No files matched your search

@@ -321,6 +321,10 @@
<receiver <receiver
android:name=".logic.WatchdogReceiver" android:name=".logic.WatchdogReceiver"
android:exported="false" /> android:exported="false" />
<!-- «Yes» / «No» of the suggestion notifications -->
<receiver
android:name=".logic.events.EventAnswerReceiver"
android:exported="false" />
<!-- edges of the time windows of the user scenarios --> <!-- edges of the time windows of the user scenarios -->
<receiver <receiver
android:name=".logic.scenarios.ScenarioReceiver" android:name=".logic.scenarios.ScenarioReceiver"
@@ -836,6 +836,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{ /* «Protected · Amsterdam» like the mockup shade */ { /* «Protected · Amsterdam» like the mockup shade */
builder.setContentTitle(getString(R.string.notif_protected, name)); builder.setContentTitle(getString(R.string.notif_protected, name));
} }
else if (state == State.CONNECTING && error == ErrorState.NO_ERROR && !publicVersion && profile != null)
{ /* F_SysNotifs «Connecting to Helsinki…» */
builder.setContentTitle(getString(R.string.notif_connecting_to, name));
}
else else
{ {
builder.setContentTitle(getString(s)); builder.setContentTitle(getString(s));
@@ -865,9 +869,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
intent.setAction(VpnProfileControlActivity.DISCONNECT); intent.setAction(VpnProfileControlActivity.DISCONNECT);
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
flags); flags);
builder.addAction(R.drawable.ic_notification_disconnect, getString(R.string.disconnect), pending); builder.addAction(R.drawable.ic_notification_disconnect,
getString(state == State.CONNECTING ? R.string.notif_stop : R.string.disconnect), pending);
} }
if (error == ErrorState.NO_ERROR) if (error == ErrorState.NO_ERROR && state == State.CONNECTING)
{
builder.setContentText(getString(R.string.notif_connecting_text));
}
else if (error == ErrorState.NO_ERROR)
{ {
builder.setContentText(state == State.CONNECTED && isRuSplitOn(this) ? getString(R.string.notif_ru_direct) : name); builder.setContentText(state == State.CONNECTED && isRuSplitOn(this) ? getString(R.string.notif_ru_direct) : name);
} }
@@ -143,6 +143,7 @@ public class StrongSwanApplication extends Application
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this); org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
/* user scenarios of F_Automation */ /* user scenarios of F_Automation */
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this); org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
} }
private void reloadManagedConfigurationAndNotifyListeners() private void reloadManagedConfigurationAndNotifyListeners()
@@ -235,7 +235,7 @@ class EventLog private constructor(private val context: Context)
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val notification = NotificationCompat.Builder(context, channel) val builder = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(event.title) .setContentTitle(event.title)
.setContentText(event.text) .setContentText(event.text)
@@ -244,7 +244,17 @@ class EventLog private constructor(private val context: Context)
.setContentIntent(open) .setContentIntent(open)
.setAutoCancel(true) .setAutoCancel(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.build() /* F_SysNotifs: answers right in the notification */
if (event.action == EventAction.SET_APP_DIRECT)
{
builder.addAction(0, context.getString(R.string.notif_yes), answerIntent(event, true))
builder.addAction(0, context.getString(R.string.notif_no), answerIntent(event, false))
}
else if (event.kind == EventKind.WARN)
{
builder.addAction(0, context.getString(R.string.notif_details), open)
}
val notification = builder.build()
try try
{ {
manager.notify(TAG, notificationId(event), notification) manager.notify(TAG, notificationId(event), notification)
@@ -255,6 +265,24 @@ class EventLog private constructor(private val context: Context)
} }
} }
private fun answerIntent(event: ShxEvent, yes: Boolean): PendingIntent = PendingIntent.getBroadcast(
context, (event.id % 100000).toInt() * 2 + if (yes) 1 else 0,
Intent(context, EventAnswerReceiver::class.java).putExtra(EXTRA_ID, event.id).putExtra(EXTRA_YES, yes),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** «Yes» / «No» of a suggestion pushed as a notification. */
internal fun answerFromNotification(id: Long, yes: Boolean)
{
val event = _events.value.firstOrNull { it.id == id } ?: return
if (yes && event.action == EventAction.SET_APP_DIRECT)
{
event.target?.let { org.strongswan.android.logic.routing.RoutingManager.getInstance(context).setAppDirect(it, true) }
}
answer(id)
NotificationManagerCompat.from(context).cancel(TAG, notificationId(event))
}
/* repeats of a keyed event replace their notification instead of stacking */ /* repeats of a keyed event replace their notification instead of stacking */
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode() private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
@@ -309,6 +337,8 @@ class EventLog private constructor(private val context: Context)
private const val FILE_NAME = "events.json" private const val FILE_NAME = "events.json"
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems" const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
const val HINTS_CHANNEL = "org.strongswan.android.hints" const val HINTS_CHANNEL = "org.strongswan.android.hints"
internal const val EXTRA_ID = "org.strongswan.android.extra.EVENT_ID"
internal const val EXTRA_YES = "org.strongswan.android.extra.EVENT_YES"
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000 const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
const val MERGE_WINDOW_MS = 30L * 60 * 1000 const val MERGE_WINDOW_MS = 30L * 60 * 1000
private const val MAX_EVENTS = 300 private const val MAX_EVENTS = 300
@@ -383,12 +413,24 @@ object NotifPrefs
const val PROBLEMS = "pref_notif_problems" const val PROBLEMS = "pref_notif_problems"
const val NEW_APPS = "pref_notif_new_apps" const val NEW_APPS = "pref_notif_new_apps"
const val UPDATES = "pref_notif_updates" const val UPDATES = "pref_notif_updates"
/** «Weekly summary», the only one off by default */
const val WEEKLY = "pref_notif_weekly"
@JvmStatic @JvmStatic
fun enabled(context: Context, key: String): Boolean = fun enabled(context: Context, key: String): Boolean =
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, true) androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, key != WEEKLY)
@JvmStatic @JvmStatic
fun set(context: Context, key: String, value: Boolean) = fun set(context: Context, key: String, value: Boolean) =
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply() androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply()
} }
/** Receives «Yes» / «No» from a suggestion notification. */
class EventAnswerReceiver : android.content.BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
val id = intent.getLongExtra(EventLog.EXTRA_ID, 0L)
EventLog.getInstance(context).answerFromNotification(id, intent.getBooleanExtra(EventLog.EXTRA_YES, false))
}
}
@@ -0,0 +1,123 @@
/*
* 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.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.strongswan.android.R
import org.strongswan.android.logic.events.NotifPrefs
import org.strongswan.android.ui.StatsActivity
import org.strongswan.android.ui.compose.formatBytes
import java.util.Calendar
import java.util.concurrent.TimeUnit
/** «Weekly summary» (F_SysNotifs): last week's traffic on Monday morning, off by default. */
object WeeklySummary
{
private const val WORK_NAME = "weekly-summary"
private const val CHANNEL = "org.strongswan.android.weekly"
private const val NOTIFICATION_ID = 0x5717
/** Arms (or cancels) the Monday 10:00 job after the switch. */
@JvmStatic
fun schedule(context: Context)
{
val wm = WorkManager.getInstance(context)
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
{
wm.cancelUniqueWork(WORK_NAME)
return
}
val c = Calendar.getInstance()
c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
c.set(Calendar.HOUR_OF_DAY, 10)
c.set(Calendar.MINUTE, 0)
c.set(Calendar.SECOND, 0)
if (c.timeInMillis <= System.currentTimeMillis())
{
c.add(Calendar.WEEK_OF_YEAR, 1)
}
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
.setInitialDelay(c.timeInMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
internal fun post(context: Context)
{
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
{
return
}
val day = 24L * 60 * 60 * 1000
val now = System.currentTimeMillis()
val week = TrafficHistory.range(context, now - 7 * day, now - day)
val total = week.sumOf { it.total }
if (total <= 0)
{
return
}
val vpnShare = (week.sumOf { it.vpn } * 100 / total).toInt()
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled())
{
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(
NotificationChannel(CHANNEL, context.getString(R.string.notif_weekly), NotificationManager.IMPORTANCE_LOW)
)
}
val open = PendingIntent.getActivity(
context, 0, Intent(context, StatsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.weekly_title, formatBytes(total)))
.setContentText(context.getString(R.string.weekly_text, vpnShare, 100 - vpnShare))
.setContentIntent(open)
.setAutoCancel(true)
.build()
try
{
manager.notify(NOTIFICATION_ID, notification)
}
catch (_: SecurityException)
{
}
}
}
class WeeklySummaryWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
{
override suspend fun doWork(): Result
{
WeeklySummary.post(applicationContext)
return Result.success()
}
}
@@ -481,6 +481,13 @@ class UpdateManager private constructor(private val context: Context)
.setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(pending) .setContentIntent(pending)
.setAutoCancel(true) .setAutoCancel(true)
/* F_SysNotifs: «Update» / «What is new» */
.addAction(0, context.getString(R.string.notif_update), pending)
.addAction(0, context.getString(R.string.notif_whats_new), PendingIntent.getActivity(
context, info.versionCode.toInt() + 1,
Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_UPDATES, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.build() .build()
try try
{ {
@@ -58,6 +58,11 @@ public class MainActivity extends AppCompatActivity
*/ */
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST"; public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
/**
* Set as an intent extra to show the Updates page (the update notification)
*/
public static final String EXTRA_OPEN_UPDATES = "org.strongswan.android.extra.OPEN_UPDATES";
/** /**
* Use "bring your own device" (BYOD) features * Use "bring your own device" (BYOD) features
*/ */
@@ -132,6 +137,11 @@ public class MainActivity extends AppCompatActivity
intent.removeExtra(EXTRA_OPEN_SERVERS); intent.removeExtra(EXTRA_OPEN_SERVERS);
ShxNavigation.INSTANCE.request(ShxTab.PROFILES); ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
} }
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false))
{
intent.removeExtra(EXTRA_OPEN_UPDATES);
ShxNavigation.INSTANCE.requestUpdatesPage();
}
if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false)) if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false))
{ {
intent.removeExtra(EXTRA_CONNECT_FASTEST); intent.removeExtra(EXTRA_CONNECT_FASTEST);
@@ -446,7 +446,8 @@ internal fun NotifSettingsPage()
} }
NotifGroup(stringResource(R.string.notif_group_other)) NotifGroup(stringResource(R.string.notif_group_other))
{ {
NotifRow(stringResource(R.string.notif_updates), stringResource(R.string.notif_updates_sub), NotifTag.QUIET, NotifPrefs.UPDATES) NotifRow(stringResource(R.string.notif_updates), stringResource(R.string.notif_updates_sub), NotifTag.QUIET, NotifPrefs.UPDATES, divider = true)
NotifRow(stringResource(R.string.notif_weekly), stringResource(R.string.notif_weekly_sub), NotifTag.QUIET, NotifPrefs.WEEKLY)
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -516,6 +517,7 @@ private fun NotifRow(title: String, sub: String, tag: NotifTag, key: String?, di
onCheckedChange = if (key == null) null else { value -> onCheckedChange = if (key == null) null else { value ->
on = value on = value
NotifPrefs.set(context, key, value) NotifPrefs.set(context, key, value)
if (key == NotifPrefs.WEEKLY) org.strongswan.android.logic.stats.WeeklySummary.schedule(context)
}, },
) )
} }
@@ -213,6 +213,15 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
} }
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) } var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(updatesRequested)
{
if (updatesRequested)
{
pageState = SettingsPage.UPDATES
ShxNavigation.consumeUpdatesPage()
}
}
/* from a sub page "back" returns to the settings list, not out of the tab */ /* from a sub page "back" returns to the settings list, not out of the tab */
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>() val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>() val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
@@ -63,4 +63,19 @@ object ShxNavigation
{ {
_fastest.value = false _fastest.value = false
} }
/* «What is new» of the update notification: the Updates page of Settings */
private val _updatesPage = MutableStateFlow(false)
val updatesPage: StateFlow<Boolean> = _updatesPage
fun requestUpdatesPage()
{
request(ShxTab.SETTINGS)
_updatesPage.value = true
}
fun consumeUpdatesPage()
{
_updatesPage.value = false
}
} }
@@ -1318,4 +1318,16 @@
<string name="prot_list_ads">реклама</string> <string name="prot_list_ads">реклама</string>
<string name="prot_list_trackers">трекеры</string> <string name="prot_list_trackers">трекеры</string>
<string name="prot_list_phishing">фишинг</string> <string name="prot_list_phishing">фишинг</string>
<string name="notif_connecting_to">Подключаемся к %1$s…</string>
<string name="notif_connecting_text">Сеть, сервер, вход, туннель</string>
<string name="notif_stop">Стоп</string>
<string name="notif_yes">Да</string>
<string name="notif_no">Нет</string>
<string name="notif_details">Подробнее</string>
<string name="notif_update">Обновить</string>
<string name="notif_whats_new">Что нового</string>
<string name="notif_weekly">Итоги недели</string>
<string name="notif_weekly_sub">Трафик за неделю, по понедельникам</string>
<string name="weekly_title">За неделю: %1$s</string>
<string name="weekly_text">%1$d%% через VPN · %2$d%% напрямую</string>
</resources> </resources>
@@ -1304,4 +1304,16 @@
<string name="prot_list_ads">ads</string> <string name="prot_list_ads">ads</string>
<string name="prot_list_trackers">trackers</string> <string name="prot_list_trackers">trackers</string>
<string name="prot_list_phishing">phishing</string> <string name="prot_list_phishing">phishing</string>
<string name="notif_connecting_to">Connecting to %1$s…</string>
<string name="notif_connecting_text">Network, server, sign-in, tunnel</string>
<string name="notif_stop">Stop</string>
<string name="notif_yes">Yes</string>
<string name="notif_no">No</string>
<string name="notif_details">Details</string>
<string name="notif_update">Update</string>
<string name="notif_whats_new">What is new</string>
<string name="notif_weekly">Weekly summary</string>
<string name="notif_weekly_sub">Traffic of the week, on Mondays</string>
<string name="weekly_title">Last week: %1$s</string>
<string name="weekly_text">%1$d%% through the VPN · %2$d%% direct</string>
</resources> </resources>