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