diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt index 905136d85..4e94d9d5a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt @@ -144,7 +144,13 @@ class EventLog private constructor(private val context: Context) prune(next, now) } persist() - posted?.let { if (it.kind == EventKind.WARN) pushIfBackground(it) } + posted?.let { + when + { + it.kind == EventKind.WARN && NotifPrefs.enabled(context, NotifPrefs.PROBLEMS) -> pushIfBackground(it, PROBLEMS_CHANNEL) + it.kind == EventKind.ASK && NotifPrefs.enabled(context, NotifPrefs.NEW_APPS) -> pushIfBackground(it, HINTS_CHANNEL) + } + } } /** Records once per [key] for the whole retention time (e.g. one update notice per version). */ @@ -203,7 +209,7 @@ class EventLog private constructor(private val context: Context) persist() } - private fun pushIfBackground(event: ShxEvent) + private fun pushIfBackground(event: ShxEvent, channel: String) { val foreground = try { @@ -227,7 +233,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, PROBLEMS_CHANNEL) + val notification = NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(event.title) .setContentText(event.text) @@ -300,6 +306,7 @@ class EventLog private constructor(private val context: Context) private const val TAG = "EventLog" private const val FILE_NAME = "events.json" const val PROBLEMS_CHANNEL = "org.strongswan.android.problems" + const val HINTS_CHANNEL = "org.strongswan.android.hints" const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000 const val MERGE_WINDOW_MS = 30L * 60 * 1000 private const val MAX_EVENTS = 300 @@ -324,6 +331,13 @@ class EventLog private constructor(private val context: Context) ) channel.description = context.getString(R.string.events_channel_problems_desc) manager.createNotificationChannel(channel) + /* suggestions arrive quietly (F_NotifSettings «Quiet») */ + val hints = NotificationChannel( + HINTS_CHANNEL, context.getString(R.string.events_channel_hints), + NotificationManager.IMPORTANCE_LOW, + ) + hints.description = context.getString(R.string.events_channel_hints_desc) + manager.createNotificationChannel(hints) } } @@ -360,3 +374,19 @@ class EventLog private constructor(private val context: Context) } } } + +/** Which events are also pushed (F_NotifSettings); all of them stay in «Events» anyway. */ +object NotifPrefs +{ + const val PROBLEMS = "pref_notif_problems" + const val NEW_APPS = "pref_notif_new_apps" + const val UPDATES = "pref_notif_updates" + + @JvmStatic + fun enabled(context: Context, key: String): Boolean = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, true) + + @JvmStatic + fun set(context: Context, key: String, value: Boolean) = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply() +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt index d910e3af9..2acefd48e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/updates/UpdateManager.kt @@ -453,6 +453,10 @@ class UpdateManager private constructor(private val context: Context) /** Posts a notification that the downloaded update is ready to install */ private fun notifyUpdateReady(info: UpdateInfo) { + if (!org.strongswan.android.logic.events.NotifPrefs.enabled(context, org.strongswan.android.logic.events.NotifPrefs.UPDATES)) + { /* switched off in the notification settings; the update stays in Settings and Events */ + return + } val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager if (manager == null) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt index 95442a82b..945ec4f48 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt @@ -59,7 +59,9 @@ import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.design.ShxNote import org.strongswan.android.ui.compose.design.ShxPanel import org.strongswan.android.ui.compose.design.ShxSectionHeader +import org.strongswan.android.ui.compose.design.ShxSwitch import org.strongswan.android.ui.compose.design.ShxToggleRow +import org.strongswan.android.logic.events.NotifPrefs import org.strongswan.android.ui.compose.theme.LocalShxColors import org.strongswan.android.ui.compose.theme.ShxThemeSettings import org.strongswan.android.ui.compose.theme.ShxType @@ -415,3 +417,110 @@ private fun HelpLink(text: String, onClick: () -> Unit) Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp)) } } + +private enum class NotifTag { ALWAYS, IMPORTANT, QUIET } + +/** + * «Notifications» (F_NotifSettings): what is pushed besides the «Events» + * list, in three groups; the status notification cannot be turned off. + */ +@Composable +internal fun NotifSettingsPage() +{ + val c = LocalShxColors.current + val context = LocalContext.current + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) + { + Text( + stringResource(R.string.notif_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.textSoft, + modifier = Modifier.padding(horizontal = 4.dp), + ) + NotifGroup(stringResource(R.string.notif_group_connection)) + { + NotifRow(stringResource(R.string.notif_status), stringResource(R.string.notif_status_sub), NotifTag.ALWAYS, null, divider = true) + NotifRow(stringResource(R.string.notif_problems), stringResource(R.string.notif_problems_sub), NotifTag.IMPORTANT, NotifPrefs.PROBLEMS) + } + NotifGroup(stringResource(R.string.notif_group_routing)) + { + NotifRow(stringResource(R.string.notif_new_apps), stringResource(R.string.notif_new_apps_sub), NotifTag.QUIET, NotifPrefs.NEW_APPS) + } + NotifGroup(stringResource(R.string.notif_group_other)) + { + NotifRow(stringResource(R.string.notif_updates), stringResource(R.string.notif_updates_sub), NotifTag.QUIET, NotifPrefs.UPDATES) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp)).background(c.tile) + .clickable(role = Role.Button) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + else Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + runCatching { context.startActivity(intent) } + } + .padding(horizontal = 16.dp), + ) + { + Text(stringResource(R.string.notif_android), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textTonal, modifier = Modifier.weight(1f)) + Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun NotifGroup(title: String, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) +{ + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) + { + ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) + ShxPanel(content = content) + } +} + +@Composable +private fun NotifRow(title: String, sub: String, tag: NotifTag, key: String?, divider: Boolean = false) +{ + val c = LocalShxColors.current + val context = LocalContext.current + var on by remember { mutableStateOf(key == null || NotifPrefs.enabled(context, key)) } + val (tagBg, tagFg, tagText) = when (tag) + { + NotifTag.ALWAYS -> Triple(c.accentContainer, c.onAccentContainer, stringResource(R.string.notif_tag_always)) + NotifTag.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.notif_tag_important)) + NotifTag.QUIET -> Triple(c.tile, c.textSoft, stringResource(R.string.notif_tag_quiet)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 66.dp).padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + ) + { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) + { + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) + { + Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text) + Text( + tagText.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false, + modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 1.dp), + ) + } + Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) + } + ShxSwitch( + checked = on, + onCheckedChange = if (key == null) null else { value -> + on = value + NotifPrefs.set(context, key, value) + }, + ) + } + if (divider) + { + Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile)) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index 9053e153a..3d041dba7 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -404,6 +404,11 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) } } + if (page == SettingsPage.NOTIFICATIONS) + { + NotifSettingsPage() + } + if (page == SettingsPage.HELP) { HelpPage(onUpdates = { pageState = SettingsPage.UPDATES }) @@ -722,6 +727,7 @@ enum class SettingsPage(val titleRes: Int) UPDATES(R.string.settings_section_updates), KILLSWITCH(R.string.settings_killswitch), HELP(R.string.settings_help), + NOTIFICATIONS(R.string.settings_notifications), } /** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */ @@ -782,10 +788,7 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat stringResource(R.string.settings_app) to listOf( SettingsEntry(stringResource(R.string.settings_stability), stringResource(R.string.settings_stability_sub), ShxIcons.Phone) { onOpen(SettingsPage.STABILITY) }, SettingsEntry(stringResource(R.string.settings_notifications), stringResource(R.string.settings_notifications_sub), ShxIcons.Bell) { - val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - else Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", context.packageName, null)) - runCatching { context.startActivity(intent) } + onOpen(SettingsPage.NOTIFICATIONS) }, SettingsEntry( stringResource(R.string.settings_section_updates), diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index dce5084d8..a60dc6424 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1057,4 +1057,22 @@ Стабильный надёжнее: новые адреса применяются после переподключения. Гибкий обновляет маршруты на лету, но пока в бете. Видит ли SHX мои сайты? Нет. С доступом к статистике приложение считает только объём трафика по приложениям; сайты и содержимое не сохраняются и никуда не отправляются. + Все события всегда сохраняются в «Событиях». Здесь — что ещё приходит пушем. + Подключение + Маршруты + Прочее + Статус VPN + Постоянное уведомление: сервер, время, пауза. Требует Android + Проблемы + Белые списки, обрыв, сервер не отвечает + Новые приложения + Предложить пустить напрямую + Обновления приложения + Новая версия готова к установке + Всегда + Важно + Тихо + Звук и вид — в настройках Android + Подсказки + Предложения, например пустить новое приложение напрямую diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index b75abb1ae..01a9e6103 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1043,4 +1043,22 @@ Stable is more reliable: new addresses apply after a reconnect. Flexible updates routes on the fly, but is still in beta. Does SHX see my sites? No. With usage access the app only counts traffic per app; sites and content are not stored or sent anywhere. + All events are always kept in «Events». Here is what also arrives as a push. + Connection + Routes + Other + VPN status + Persistent notification: server, time, pause. Required by Android + Problems + Whitelists, connection drops, server not responding + New apps + Offer to let them go direct + App updates + A new version is ready to install + Always + Important + Quiet + Sound and look — in Android settings + Hints + Suggestions such as letting a new app go direct