diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index d0c5b3397d..e61c08b852 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList @@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList, Immuta ) } +fun hasPushNotificationProvider(): Boolean = true + @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) { +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) { val context = LocalContext.current LoadDistributors { currentDistributor, list, readableListWithExplainer -> - SettingsRow( - R.string.push_server_title, - R.string.push_server_explainer, - selectedItems = readableListWithExplainer, - selectedIndex = list.indexOf(currentDistributor), - ) { index -> - if (list[index] == "None") { - sharedPrefs.dontAskForNotificationPermissions() - sharedPrefs.dontShowPushNotificationSelector() - PushDistributorHandler.forceRemoveDistributor(context) - } else { - PushDistributorHandler.saveDistributor(list[index]) - } + val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0) + SettingsBlockTile( + icon = MaterialSymbols.CloudSync, + title = stringRes(R.string.push_server_title), + description = stringRes(R.string.push_server_explainer), + ) { + TextSpinner( + label = null, + placeholder = readableListWithExplainer[selectedIndex].title, + options = readableListWithExplainer, + onSelect = { index -> + if (list[index] == "None") { + sharedPrefs.dontAskForNotificationPermissions() + sharedPrefs.dontShowPushNotificationSelector() + PushDistributorHandler.forceRemoveDistributor(context) + } else { + PushDistributorHandler.saveDistributor(list[index]) + } + }, + modifier = Modifier.fillMaxWidth(), + ) } } } 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/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 04055ec777..10834421dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen @@ -326,6 +327,7 @@ fun BuildNavigation( composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3d5ece6229..b9e4ae495e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -239,6 +239,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NotificationSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index bf15f61607..7fb17f1bee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -218,6 +218,12 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.Settings) }, ) SettingsDivider() + SettingsItem( + title = R.string.notification_settings, + icon = MaterialSymbols.Notifications, + onClick = { nav.nav(Route.NotificationSettings) }, + ) + SettingsDivider() SettingsItem( title = R.string.compose_settings, icon = MaterialSymbols.Edit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 9be0e54f6b..0d3a39e992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -46,14 +42,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType @@ -65,8 +58,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType -import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper -import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,7 +88,7 @@ fun SettingsScreen( }, ) { Column(Modifier.padding(it)) { - SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel) + SettingsScreen(accountViewModel.settings.uiSettingsFlow) } } } @@ -111,10 +102,7 @@ fun SettingsScreenPreview() { } @Composable -fun SettingsScreen( - sharedPrefs: UiSettingsFlow, - accountViewModel: AccountViewModel? = null, -) { +fun SettingsScreen(sharedPrefs: UiSettingsFlow) { Column( Modifier .fillMaxSize() @@ -128,18 +116,11 @@ fun SettingsScreen( ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) - if (BuildConfig.FLAVOR == "play") { - } ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) GalleryChoice(sharedPrefs) - PushNotificationSettingsRow(sharedPrefs) - if (accountViewModel != null) { - AlwaysOnNotificationServiceChoice(accountViewModel) - SplitNotificationsChoice(accountViewModel) - } } } @@ -489,86 +470,3 @@ fun SettingsRow( } } } - -@Composable -fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.always_on_notif_setting_title, - R.string.always_on_notif_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleAlwaysOnNotificationService() - }, - ) - } - - if (enabled) { - BatteryOptimizationBanner() - } -} - -@Composable -fun SplitNotificationsChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.split_notifications_setting_title, - R.string.split_notifications_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleSplitNotificationsEnabled() - }, - ) - } -} - -@Composable -fun BatteryOptimizationBanner() { - val context = LocalContext.current - val isExempt = - remember { - BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) - } - - if (!isExempt) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringRes(R.string.battery_optimization_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringRes(R.string.battery_optimization_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Button( - onClick = { - BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) - }, - ) { - Text(stringRes(R.string.battery_optimization_fix_now)) - } - } - } - } -} 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 new file mode 100644 index 0000000000..b2914644d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -0,0 +1,276 @@ +/* + * 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.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 +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +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 +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun NotificationSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.notification_settings), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + 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 + + // Read each channel's importance after every resume so toggling + // sound/importance in the system page reflects back here. The map IS + // the state — no key-bump trick needed. + var statuses by remember { + mutableStateOf>(emptyMap()) + } + LifecycleResumeEffect(Unit) { + statuses = + entries.associate { + val id = it.channelId(context) + id to NotificationChannels.statusOf(context, id) + } + onPauseOrDispose {} + } + + SettingsSection(R.string.notification_settings_section_categories) { + entries.forEachIndexed { index, entry -> + if (index > 0) SettingsDivider() + val channelId = remember(entry) { entry.channelId(context) } + // Default to ON for channels not yet created — matches Android's + // own default importance, so the badge isn't misleading before the + // user has interacted with the channel. + val status = statuses[channelId] ?: NotificationChannels.ChannelStatus.ON + SettingsItem( + title = entry.nameRes, + icon = entry.icon, + trailing = { ChannelStatusBadge(status) }, + onClick = { + // Lazy-create the channel right before opening so the system + // per-channel page has something to display; idempotent. + entry.ensure(context) + 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) { + when (status) { + NotificationChannels.ChannelStatus.ON -> + StatusChip( + label = stringRes(R.string.notification_channel_status_on), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + NotificationChannels.ChannelStatus.SILENT -> + StatusChip( + label = stringRes(R.string.notification_channel_status_silent), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NotificationChannels.ChannelStatus.OFF -> + StatusChip( + label = stringRes(R.string.notification_channel_status_off), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@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 + var isExempt by remember { + mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)) + } + LifecycleResumeEffect(Unit) { + isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + onPauseOrDispose {} + } + + if (isExempt) return + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) }, + ) { + Text(stringRes(R.string.battery_optimization_fix_now)) + } + } + } +} + +@Preview +@Composable +fun NotificationSettingsScreenPreview() { + ThemeComparisonColumn { + NotificationSettingsScreen(mockAccountViewModel(), EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 11faac4803..d3f9905105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -31,7 +30,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,7 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType @@ -122,7 +119,7 @@ private fun FilterSpamTile(accountViewModel: AccountViewModel) { .filterSpamFromStrangers .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.FilterAlt, title = R.string.filter_spam_from_strangers_title, description = R.string.filter_spam_from_strangers_explainer, @@ -136,7 +133,7 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Shield, title = R.string.hide_community_rules_violations_title, description = R.string.hide_community_rules_violations_explainer, @@ -151,7 +148,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Report, title = R.string.warn_when_posts_have_reports_from_your_follows_title, description = R.string.warn_when_posts_have_reports_from_your_follows_explainer, @@ -194,24 +191,6 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { } } -@Composable -private fun SwitchTile( - icon: MaterialSymbol, - @StringRes title: Int, - @StringRes description: Int, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - SettingsControlRow( - icon = icon, - title = stringRes(title), - description = stringRes(description), - onClick = { onCheckedChange(!checked) }, - ) { - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - @Composable private fun BlockedContentSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index d190d18803..a188839456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -248,6 +249,25 @@ internal fun SettingsControlRow( } } +/** A [SettingsControlRow] whose trailing control is a [Switch]; tapping anywhere toggles. */ +@Composable +internal fun SettingsSwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, * used for controls hierarchically grouped under the row above (e.g. a threshold diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5eec25da77..35d4afc9aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1676,9 +1676,18 @@ Read-only user No reactions setup + 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. + On + Silent + Off + Select a UnifiedPush App - Push Notification - From installed UnifiedPush apps + Push provider + Pick a UnifiedPush app to deliver notifications when Amethyst is closed. None Disables Push Notifications Uses app %1$s diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 5091181653..dc32830cc4 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -52,4 +52,6 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {} +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} + +fun hasPushNotificationProvider(): Boolean = false