mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+1
-1
@@ -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 {
|
||||
|
||||
+166
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
|
||||
+144
-32
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user