feat(notifications): split delivery vs display, add Categories section

Reorganize Notification settings into three sections that reflect what
each control actually does:

- Delivery: how notifications reach the device — push provider (fdroid)
  and the always-on relay service live together here.
- In-app display: how the notifications screen renders incoming activity —
  currently the Split-by-Follows toggle.
- Categories: one row per user-facing Android NotificationChannel (DMs,
  Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls),
  showing the current importance (On / Silent / Off) and opening the
  system per-channel settings page on tap. Foreground-service channels
  are intentionally omitted — disabling them breaks the service contract.

The screen ensures every listed channel exists on first open and
re-reads importance via LifecycleResumeEffect so the badge reflects
changes made in system settings.

API surface bumped for the channel registry:
- CallNotifier.CALL_CHANNEL_ID is now public.
- ScheduledPostNotifier.ensureChannel is now public.
This commit is contained in:
Claude
2026-05-18 21:49:09 +00:00
parent 48d9e80e20
commit 4d88444a59
5 changed files with 320 additions and 36 deletions
@@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext
*/
object CallNotifier {
private var callChannel: NotificationChannel? = null
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* Registry of user-facing notification channels and helpers to read their
* current importance / open the system settings page for them.
*
* Android (post-Oreo) owns channel state — the app cannot toggle channel
* importance directly. The Notifications settings screen surfaces the
* channels here and routes the user to the system per-channel page.
*
* Foreground-service channels (relay-connection, nests audio) are
* intentionally omitted: they're functional indicators, not content
* notifications, and disabling them breaks the foreground service contract.
*/
object NotificationChannels {
private const val TAG = "NotificationChannels"
enum class ChannelStatus { ON, SILENT, OFF }
/**
* A single content-bearing notification channel exposed in the settings UI.
* [ensure] creates the channel if missing — needed so the system per-channel
* settings page has something to open even before the first notification fires.
*/
data class Entry(
val nameRes: Int,
val icon: MaterialSymbol,
val channelId: (Context) -> String,
val ensure: (Context) -> Unit,
)
val contentChannels: List<Entry> =
listOf(
Entry(
nameRes = R.string.app_notification_dms_channel_name,
icon = MaterialSymbols.Mail,
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
),
Entry(
nameRes = R.string.app_notification_mentions_channel_name,
icon = MaterialSymbols.AlternateEmail,
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_replies_channel_name,
icon = MaterialSymbols.Chat,
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
),
Entry(
nameRes = R.string.app_notification_reactions_channel_name,
icon = MaterialSymbols.Favorite,
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_zaps_channel_name,
icon = MaterialSymbols.Bolt,
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
),
Entry(
nameRes = R.string.app_notification_chess_channel_name,
icon = MaterialSymbols.ChessKnight,
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
),
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { ScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
fun statusOf(
context: Context,
channelId: String,
): ChannelStatus {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF
val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF
val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON
return when (channel.importance) {
NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT
else -> ChannelStatus.ON
}
}
/**
* Opens the system per-channel notification settings page. Falls back to
* the app-level notification settings if the per-channel intent isn't
* supported (e.g. the channel was never created, or on stripped-down ROMs).
*/
fun openChannelSettings(
context: Context,
channelId: String,
) {
try {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e)
openAppNotificationSettings(context)
}
}
fun openAppNotificationSettings(context: Context) {
try {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open app notification settings", e)
}
}
}
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
}
}
private fun ensureChannel(context: Context) {
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
@@ -20,11 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -37,7 +40,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -47,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
import com.vitorpamplona.amethyst.service.notifications.NotificationChannels
import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile
import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -73,42 +80,147 @@ fun NotificationSettingsScreen(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
if (hasPushNotificationProvider()) {
SettingsSection(R.string.notification_settings_section_push) {
PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow)
}
}
val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
SettingsSection(R.string.notification_settings_section_in_app) {
SettingsSwitchTile(
icon = MaterialSymbols.Notifications,
title = R.string.always_on_notif_setting_title,
description = R.string.always_on_notif_setting_description,
checked = alwaysOn,
onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.split_notifications_setting_title,
description = R.string.split_notifications_setting_description,
checked = splitByFollows,
onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() },
)
}
if (alwaysOn) {
BatteryOptimizationBanner()
}
DeliverySection(accountViewModel)
DisplaySection(accountViewModel)
CategoriesSection()
}
}
}
@Composable
private fun DeliverySection(accountViewModel: AccountViewModel) {
val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
SettingsSection(R.string.notification_settings_section_delivery) {
if (hasPushNotificationProvider()) {
PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow)
SettingsDivider()
}
SettingsSwitchTile(
icon = MaterialSymbols.Notifications,
title = R.string.always_on_notif_setting_title,
description = R.string.always_on_notif_setting_description,
checked = alwaysOn,
onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() },
)
}
if (alwaysOn) {
BatteryOptimizationBanner()
}
}
@Composable
private fun DisplaySection(accountViewModel: AccountViewModel) {
val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
SettingsSection(R.string.notification_settings_section_display) {
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.split_notifications_setting_title,
description = R.string.split_notifications_setting_description,
checked = splitByFollows,
onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() },
)
}
}
@Composable
private fun CategoriesSection() {
val context = LocalContext.current
val entries = NotificationChannels.contentChannels
// Ensure every channel exists so the system per-channel page has something
// to open even on a fresh install where the user hasn't received that kind
// of notification yet.
remember(entries) {
entries.forEach { runCatching { it.ensure(context) } }
}
// Re-read importance on resume so toggling sound/importance in the system
// page reflects back when the user returns.
var refreshKey by remember { mutableStateOf(0) }
LifecycleResumeEffect(Unit) {
refreshKey++
onPauseOrDispose {}
}
SettingsSection(R.string.notification_settings_section_categories) {
entries.forEachIndexed { index, entry ->
if (index > 0) SettingsDivider()
val channelId = remember(entry) { entry.channelId(context) }
val status =
remember(refreshKey, channelId) {
NotificationChannels.statusOf(context, channelId)
}
SettingsItem(
title = entry.nameRes,
icon = entry.icon,
trailing = { ChannelStatusBadge(status) },
onClick = { NotificationChannels.openChannelSettings(context, channelId) },
)
}
}
Text(
text = stringRes(R.string.notification_settings_categories_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
@Composable
private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) {
val (label, container, content) =
when (status) {
NotificationChannels.ChannelStatus.ON ->
Triple(
R.string.notification_channel_status_on,
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.onSecondaryContainer,
)
NotificationChannels.ChannelStatus.SILENT ->
Triple(
R.string.notification_channel_status_silent,
MaterialTheme.colorScheme.surfaceContainerHigh,
MaterialTheme.colorScheme.onSurfaceVariant,
)
NotificationChannels.ChannelStatus.OFF ->
Triple(
R.string.notification_channel_status_off,
MaterialTheme.colorScheme.errorContainer,
MaterialTheme.colorScheme.onErrorContainer,
)
}
StatusChip(label = stringRes(label), containerColor = container, contentColor = content)
}
@Composable
private fun StatusChip(
label: String,
containerColor: Color,
contentColor: Color,
) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = contentColor,
)
}
}
@Composable
private fun BatteryOptimizationBanner() {
val context = LocalContext.current
+8 -2
View File
@@ -1675,8 +1675,14 @@
<string name="no_reactions_setup">No reactions setup</string>
<string name="notification_settings">Notifications</string>
<string name="notification_settings_section_in_app">In-app notifications</string>
<string name="notification_settings_section_push">Push notifications</string>
<string name="notification_settings_section_delivery">Delivery</string>
<string name="notification_settings_section_display">In-app display</string>
<string name="notification_settings_section_categories">Categories</string>
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
<string name="notification_settings_open_system">Open Android notification settings</string>
<string name="notification_channel_status_on">On</string>
<string name="notification_channel_status_silent">Silent</string>
<string name="notification_channel_status_off">Off</string>
<string name="select_push_server">Select a UnifiedPush App</string>
<string name="push_server_title">Push provider</string>