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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8880601ef6
commit
1e32410fca
12 files changed
+252
-6
No files matched your search
@@ -321,6 +321,10 @@
|
||||
<receiver
|
||||
android:name=".logic.WatchdogReceiver"
|
||||
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 -->
|
||||
<receiver
|
||||
android:name=".logic.scenarios.ScenarioReceiver"
|
||||
|
||||
+11
-2
@@ -836,6 +836,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{ /* «Protected · Amsterdam» like the mockup shade */
|
||||
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
|
||||
{
|
||||
builder.setContentTitle(getString(s));
|
||||
@@ -865,9 +869,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
intent.setAction(VpnProfileControlActivity.DISCONNECT);
|
||||
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
|
||||
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);
|
||||
}
|
||||
|
||||
+1
@@ -143,6 +143,7 @@ public class StrongSwanApplication extends Application
|
||||
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
||||
/* user scenarios of F_Automation */
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
|
||||
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
|
||||
}
|
||||
|
||||
private void reloadManagedConfigurationAndNotifyListeners()
|
||||
|
||||
+45
-3
@@ -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),
|
||||
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)
|
||||
.setContentTitle(event.title)
|
||||
.setContentText(event.text)
|
||||
@@ -244,7 +244,17 @@ class EventLog private constructor(private val context: Context)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(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
|
||||
{
|
||||
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 */
|
||||
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"
|
||||
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
|
||||
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 MERGE_WINDOW_MS = 30L * 60 * 1000
|
||||
private const val MAX_EVENTS = 300
|
||||
@@ -383,12 +413,24 @@ object NotifPrefs
|
||||
const val PROBLEMS = "pref_notif_problems"
|
||||
const val NEW_APPS = "pref_notif_new_apps"
|
||||
const val UPDATES = "pref_notif_updates"
|
||||
/** «Weekly summary», the only one off by default */
|
||||
const val WEEKLY = "pref_notif_weekly"
|
||||
|
||||
@JvmStatic
|
||||
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
|
||||
fun set(context: Context, key: String, value: Boolean) =
|
||||
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))
|
||||
}
|
||||
}
|
||||
+123
@@ -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()
|
||||
}
|
||||
}
|
||||
+7
@@ -481,6 +481,13 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(pending)
|
||||
.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()
|
||||
try
|
||||
{
|
||||
|
||||
@@ -58,6 +58,11 @@ public class MainActivity extends AppCompatActivity
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -132,6 +137,11 @@ public class MainActivity extends AppCompatActivity
|
||||
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
||||
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))
|
||||
{
|
||||
intent.removeExtra(EXTRA_CONNECT_FASTEST);
|
||||
|
||||
+3
-1
@@ -446,7 +446,8 @@ internal fun NotifSettingsPage()
|
||||
}
|
||||
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(
|
||||
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 ->
|
||||
on = value
|
||||
NotifPrefs.set(context, key, value)
|
||||
if (key == NotifPrefs.WEEKLY) org.strongswan.android.logic.stats.WeeklySummary.schedule(context)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+9
@@ -213,6 +213,15 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}
|
||||
|
||||
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 */
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
||||
|
||||
+15
@@ -63,4 +63,19 @@ object ShxNavigation
|
||||
{
|
||||
_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_trackers">трекеры</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>
|
||||
@@ -1304,4 +1304,16 @@
|
||||
<string name="prot_list_ads">ads</string>
|
||||
<string name="prot_list_trackers">trackers</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>
|
||||
Reference in new issue
Block a user