From 4d88444a5920211d2a67bd28ae4a9d5006bdfc9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:49:09 +0000 Subject: [PATCH] feat(notifications): split delivery vs display, add Categories section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../service/call/notification/CallNotifier.kt | 2 +- .../notifications/NotificationChannels.kt | 166 +++++++++++++++++ .../scheduledposts/ScheduledPostNotifier.kt | 2 +- .../settings/NotificationSettingsScreen.kt | 176 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 10 +- 5 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt index 529f11ef78..789f6ff535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt new file mode 100644 index 0000000000..c538def017 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -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 = + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt index 6ac5b47415..d545ae7760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -122,7 +122,7 @@ object ScheduledPostNotifier { } } - private fun ensureChannel(context: Context) { + fun ensureChannel(context: Context) { if (channel != null) return channel = NotificationChannel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index 5c2450d9c7..14a39ad7b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8e7d495a02..3311e94ea4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1675,8 +1675,14 @@ No reactions setup Notifications - In-app notifications - Push notifications + Delivery + In-app display + Categories + Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. + Open Android notification settings + On + Silent + Off Select a UnifiedPush App Push provider