diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index f5b6ba1c..f6cb3173 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -75,7 +75,6 @@ import chat.stoat.R import chat.stoat.StoatApplication import chat.stoat.api.HitRateLimitException import chat.stoat.api.StoatAPI -import chat.stoat.c2dm.NotificationDeepLink import chat.stoat.api.StoatHttp import chat.stoat.api.api import chat.stoat.api.routes.microservices.geo.queryGeo @@ -85,6 +84,7 @@ import chat.stoat.api.settings.Experiments import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.SyncedSettings +import chat.stoat.c2dm.NotificationDeepLink import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.composables.voice.VoiceSheet @@ -117,6 +117,7 @@ import chat.stoat.screens.settings.ChatSettingsScreen import chat.stoat.screens.settings.DebugSettingsScreen import chat.stoat.screens.settings.ExperimentsSettingsScreen import chat.stoat.screens.settings.LanguagePickerSettingsScreen +import chat.stoat.screens.settings.NotificationsSettingsScreen import chat.stoat.screens.settings.ProfileSettingsScreen import chat.stoat.screens.settings.SessionSettingsScreen import chat.stoat.screens.settings.SettingsScreen @@ -712,6 +713,7 @@ fun AppEntrypoint( composable("settings/sessions") { SessionSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/chat") { ChatSettingsScreen(navController) } + composable("settings/notifications") { NotificationsSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/experiments") { ExperimentsSettingsScreen(navController) } composable("settings/language") { LanguagePickerSettingsScreen(navController) } @@ -738,7 +740,7 @@ fun AppEntrypoint( composable("about/oss") { AttributionScreen(navController) } composable("labs") { LabsRootScreen(navController) } - + composable("changelog/{id}") { ReadChangelogScreen(navController) } } } diff --git a/app/src/main/java/chat/stoat/api/routes/push/Push.kt b/app/src/main/java/chat/stoat/api/routes/push/Push.kt index 9c048c89..5b3a24d4 100644 --- a/app/src/main/java/chat/stoat/api/routes/push/Push.kt +++ b/app/src/main/java/chat/stoat/api/routes/push/Push.kt @@ -23,4 +23,8 @@ suspend fun subscribePush( setBody(data) contentType(ContentType.Application.Json) } +} + +suspend fun unsubscribePush() { + StoatHttp.post("/push/unsubscribe".api()) } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/c2dm/HandlerService.kt b/app/src/main/java/chat/stoat/c2dm/HandlerService.kt index 5246ccff..ed61d7d9 100644 --- a/app/src/main/java/chat/stoat/c2dm/HandlerService.kt +++ b/app/src/main/java/chat/stoat/c2dm/HandlerService.kt @@ -84,19 +84,31 @@ class HandlerService : FirebaseMessagingService() { val messageTimestamp = ULID.asTimestamp(messageId) val db = Database(SqlStorage.driver) - val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let { - when (it.channelType) { - "DirectMessage" -> authorName - "TextChannel" -> "#${it.name}" - else -> it.name ?: authorName + + fun serverPrefix(serverId: String?): String? { + if (serverId == null) return null + return db.serverQueries.findById(serverId).executeAsOneOrNull()?.name + } + + fun formatChannelName(type: String, name: String?, serverId: String?): String { + val base = when (type) { + "DirectMessage" -> return authorName + "TextChannel" -> "#${name}" + else -> name ?: return authorName } + val prefix = serverPrefix(serverId) ?: return base + return "$prefix ยท $base" + } + + val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let { + formatChannelName(it.channelType, it.name, it.server) } ?: runBlocking { runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let { - when (it.channelType) { - ChannelType.DirectMessage -> authorName - ChannelType.TextChannel -> "#${it.name}" - else -> it.name ?: authorName - } + formatChannelName( + it.channelType?.value ?: "", + it.name, + it.server + ) } ?: authorName } diff --git a/app/src/main/java/chat/stoat/composables/generic/CenteredListItem.kt b/app/src/main/java/chat/stoat/composables/generic/CenteredListItem.kt new file mode 100644 index 00000000..c0d4538d --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/generic/CenteredListItem.kt @@ -0,0 +1,56 @@ +package chat.stoat.composables.generic + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun CenteredListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemDefaults.Elevation, + shadowElevation: Dp = ListItemDefaults.Elevation +) { + ListItem( + modifier = modifier.height(IntrinsicSize.Min), + headlineContent = headlineContent, + overlineContent = overlineContent, + supportingContent = supportingContent, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + leadingContent = leadingContent?.let { content -> + { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + content() + } + } + }, + trailingContent = trailingContent?.let { content -> + { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + content() + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/di/ViewModelModule.kt b/app/src/main/java/chat/stoat/di/ViewModelModule.kt index b735555f..c875d451 100644 --- a/app/src/main/java/chat/stoat/di/ViewModelModule.kt +++ b/app/src/main/java/chat/stoat/di/ViewModelModule.kt @@ -8,6 +8,7 @@ import chat.stoat.screens.login.LoginViewModel import chat.stoat.screens.login.MfaScreenViewModel import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel import chat.stoat.screens.settings.DebugSettingsScreenViewModel +import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel import chat.stoat.screens.settings.ProfileSettingsScreenViewModel import chat.stoat.screens.settings.SettingsScreenViewModel import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel @@ -26,6 +27,7 @@ val viewModelModule = module { viewModel { MfaScreenViewModel(get()) } viewModel { SettingsScreenViewModel(get()) } viewModel { DebugSettingsScreenViewModel(get()) } + viewModel { NotificationsSettingsScreenViewModel(get(), androidContext()) } viewModel { LoginViewModel(get()) } viewModel { ProfileSettingsScreenViewModel(androidApplication()) } viewModel { AppearanceSettingsScreenViewModel(androidApplication()) } diff --git a/app/src/main/java/chat/stoat/screens/settings/NotificationsSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/NotificationsSettingsScreen.kt new file mode 100644 index 00000000..7d379ddf --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/settings/NotificationsSettingsScreen.kt @@ -0,0 +1,185 @@ +package chat.stoat.screens.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import chat.stoat.R +import chat.stoat.api.routes.push.subscribePush +import chat.stoat.api.routes.push.unsubscribePush +import chat.stoat.composables.generic.CenteredListItem +import chat.stoat.dialogs.NotificationRationaleDialog +import chat.stoat.persistence.KVStorage +import chat.stoat.settings.dsl.SettingsPage +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel + +@SuppressLint("StaticFieldLeak") +class NotificationsSettingsScreenViewModel( + private val kvStorage: KVStorage, + private val context: Context +) : ViewModel() { + var showRationale by mutableStateOf(false) + var isPushEnabled by mutableStateOf(false) + private set + var isUpdating by mutableStateOf(false) + private set + + init { + viewModelScope.launch { + isPushEnabled = checkPushEnabled() + } + } + + private suspend fun checkPushEnabled(): Boolean { + val hasPermission = NotificationManagerCompat.from(context).areNotificationsEnabled() + val hasToken = kvStorage.get("fcmToken") != null + return hasPermission && hasToken + } + + fun onEnableRequested() { + showRationale = true + } + + fun subscribeIfNeeded() { + if (isUpdating) return + isUpdating = true + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + isUpdating = false + return@OnCompleteListener + } + val newToken = task.result + viewModelScope.launch { + try { + val existingToken = kvStorage.get("fcmToken") + if (existingToken != newToken) { + subscribePush(auth = newToken) + kvStorage.set("fcmToken", newToken) + } + kvStorage.remove("pushNotificationsRejected") + isPushEnabled = checkPushEnabled() + } catch (e: Exception) { + // subscribe failed, leave state unchanged + } finally { + isUpdating = false + } + } + } + ) + } + + fun disablePush() { + if (isUpdating) return + isUpdating = true + viewModelScope.launch { + try { + val token = kvStorage.get("fcmToken") + if (token != null) { + runCatching { unsubscribePush() } + kvStorage.remove("fcmToken") + } + kvStorage.set("pushNotificationsRejected", true) + isPushEnabled = false + } finally { + isUpdating = false + } + } + } +} + +@Composable +fun NotificationsSettingsScreen( + navController: NavController, + viewModel: NotificationsSettingsScreenViewModel = koinViewModel() +) { + val context = LocalContext.current + + val askNotificationsPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) viewModel.subscribeIfNeeded() + } + + if (viewModel.showRationale) { + NotificationRationaleDialog( + onSelected = { accepted -> + if (accepted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + askNotificationsPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.subscribeIfNeeded() + } + } + }, + onDismiss = { viewModel.showRationale = false } + ) + } + + SettingsPage( + navController = navController, + title = { Text(stringResource(R.string.settings_notifications)) } + ) { + CenteredListItem( + headlineContent = { Text(stringResource(R.string.settings_notifications_push)) }, + supportingContent = { Text(stringResource(R.string.settings_notifications_push_description)) }, + trailingContent = { + Switch( + checked = viewModel.isPushEnabled, + onCheckedChange = null, + enabled = !viewModel.isUpdating + ) + }, + modifier = Modifier + .semantics { role = Role.Switch } + .clickable(enabled = !viewModel.isUpdating) { + if (viewModel.isPushEnabled) viewModel.disablePush() + else viewModel.onEnableRequested() + } + ) + CenteredListItem( + headlineContent = { Text(stringResource(R.string.settings_notifications_system)) }, + supportingContent = { Text(stringResource(R.string.settings_notifications_system_description)) }, + trailingContent = { + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_24dp), + contentDescription = null, + modifier = Modifier.padding(horizontal = 16.dp) + ) + }, + modifier = Modifier.clickable { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + ) + } +} diff --git a/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt index c6180f33..8eef0b4b 100644 --- a/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt @@ -211,6 +211,26 @@ fun SettingsScreen( navController.navigate("settings/chat") } ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.settings_notifications) + ) + }, + leadingContent = { + SettingsIcon { + Icon( + painter = painterResource(R.drawable.ic_notifications_24dp), + contentDescription = null, + ) + } + }, + modifier = Modifier + .testTag("settings_view_notifications") + .clickable { + navController.navigate("settings/notifications") + } + ) ListHeader { Text(stringResource(R.string.settings_category_miscellaneous)) diff --git a/app/src/main/res/drawable/ic_notifications_24dp.xml b/app/src/main/res/drawable/ic_notifications_24dp.xml new file mode 100644 index 00000000..77461925 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e61db746..b628dd6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -775,6 +775,12 @@ %1$s (unknown) Reset %1$s settings + Notifications + Push Notifications + Receive notifications for new messages, mentions, and other updates, even when you\'re not actively using the app. + System Settings + Manage notification permissions and preferences from your device\'s system settings. + Feedback Join the Stoat server to give feedback or suggestions and report bugs. diff --git a/app/src/main/sqldelight/chat/revolt/persistence/cache/Server.sq b/app/src/main/sqldelight/chat/revolt/persistence/cache/Server.sq index a2f04aa8..cae01f33 100644 --- a/app/src/main/sqldelight/chat/revolt/persistence/cache/Server.sq +++ b/app/src/main/sqldelight/chat/revolt/persistence/cache/Server.sq @@ -31,4 +31,9 @@ FROM Server; delete: DELETE FROM Server +WHERE id = ?; + +findById: +SELECT * +FROM Server WHERE id = ?; \ No newline at end of file