android: add datastore notification settings and gating usecase
Some checks failed
CI / test (push) Failing after 2m6s

This commit is contained in:
Codex
2026-03-09 15:02:56 +03:00
parent dfa67c34c9
commit 33514265e3
12 changed files with 341 additions and 9 deletions

View File

@@ -301,3 +301,9 @@
- Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention. - Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention.
- Routed mention notifications to mentions channel/priority via `NotificationDispatcher`. - Routed mention notifications to mentions channel/priority via `NotificationDispatcher`.
- Added parser unit test for mention-flag mapping. - Added parser unit test for mention-flag mapping.
### Step 50 - Notification settings storage (DataStore)
- Added domain notification settings models/repository contracts (global + per-chat override).
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
- Added unit tests for DataStore notification settings repository and notification visibility use case.

View File

@@ -0,0 +1,82 @@
package ru.daemonlord.messenger.data.notifications.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreNotificationSettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : NotificationSettingsRepository {
override fun observeSettings(): Flow<NotificationSettings> {
return dataStore.data.map { preferences ->
NotificationSettings(
globalEnabled = preferences[GLOBAL_ENABLED_KEY] ?: true,
previewEnabled = preferences[PREVIEW_ENABLED_KEY] ?: true,
)
}
}
override suspend fun getSettings(): NotificationSettings {
return observeSettings().first()
}
override suspend fun setGlobalEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[GLOBAL_ENABLED_KEY] = enabled
}
}
override suspend fun setPreviewEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PREVIEW_ENABLED_KEY] = enabled
}
}
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
return dataStore.data.map { preferences ->
preferences.chatOverride(chatId)
}
}
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride {
return observeChatOverride(chatId).first()
}
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) {
dataStore.edit { preferences ->
preferences[chatOverrideKey(chatId)] = mode.name
}
}
override suspend fun clearChatOverride(chatId: Long) {
dataStore.edit { preferences ->
preferences.remove(chatOverrideKey(chatId))
}
}
private fun Preferences.chatOverride(chatId: Long): ChatNotificationOverride {
return this[chatOverrideKey(chatId)]
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
?: ChatNotificationOverride.DEFAULT
}
private fun chatOverrideKey(chatId: Long) = stringPreferencesKey("notification_chat_override_$chatId")
private companion object {
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
}
}

View File

@@ -8,10 +8,12 @@ import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -41,4 +43,10 @@ abstract class RepositoryModule {
abstract fun bindMediaRepository( abstract fun bindMediaRepository(
repository: NetworkMediaRepository, repository: NetworkMediaRepository,
): MediaRepository ): MediaRepository
@Binds
@Singleton
abstract fun bindNotificationSettingsRepository(
repository: DataStoreNotificationSettingsRepository,
): NotificationSettingsRepository
} }

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.notifications.model
enum class ChatNotificationOverride {
DEFAULT,
ENABLED,
MUTED,
}

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.domain.notifications.model
data class NotificationSettings(
val globalEnabled: Boolean = true,
val previewEnabled: Boolean = true,
)

View File

@@ -0,0 +1,18 @@
package ru.daemonlord.messenger.domain.notifications.repository
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
interface NotificationSettingsRepository {
fun observeSettings(): Flow<NotificationSettings>
suspend fun getSettings(): NotificationSettings
suspend fun setGlobalEnabled(enabled: Boolean)
suspend fun setPreviewEnabled(enabled: Boolean)
fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride>
suspend fun getChatOverride(chatId: Long): ChatNotificationOverride
suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride)
suspend fun clearChatOverride(chatId: Long)
}

View File

@@ -0,0 +1,28 @@
package ru.daemonlord.messenger.domain.notifications.usecase
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import javax.inject.Inject
class ShouldShowMessageNotificationUseCase @Inject constructor(
private val notificationSettingsRepository: NotificationSettingsRepository,
) {
suspend operator fun invoke(
chatId: Long,
isMention: Boolean,
serverMuted: Boolean,
): Boolean {
val settings = notificationSettingsRepository.getSettings()
if (!settings.globalEnabled) return false
val chatOverride = notificationSettingsRepository.getChatOverride(chatId)
val effectiveMuted = when (chatOverride) {
ChatNotificationOverride.DEFAULT -> serverMuted
ChatNotificationOverride.ENABLED -> false
ChatNotificationOverride.MUTED -> true
}
return !effectiveMuted || isMention
}
}

View File

@@ -13,6 +13,7 @@ import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import javax.inject.Inject import javax.inject.Inject
@@ -26,6 +27,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -76,7 +78,12 @@ class HandleRealtimeEventsUseCase @Inject constructor(
chatDao.incrementUnread(chatId = event.chatId) chatDao.incrementUnread(chatId = event.chatId)
val activeChatId = activeChatTracker.activeChatId.value val activeChatId = activeChatTracker.activeChatId.value
val muted = chatDao.isChatMuted(event.chatId) == true val muted = chatDao.isChatMuted(event.chatId) == true
if (activeChatId != event.chatId && (!muted || event.isMention)) { val shouldNotify = shouldShowMessageNotificationUseCase(
chatId = event.chatId,
isMention = event.isMention,
serverMuted = muted,
)
if (activeChatId != event.chatId && shouldNotify) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() } val body = event.text?.takeIf { it.isNotBlank() }
?: when (event.type?.lowercase()) { ?: when (event.type?.lowercase()) {

View File

@@ -2,14 +2,14 @@ package ru.daemonlord.messenger.core.token
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okio.Path.Companion.toPath
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DataStoreTokenRepositoryTest { class DataStoreTokenRepositoryTest {
@@ -46,10 +46,18 @@ class DataStoreTokenRepositoryTest {
} }
private fun createTestDataStore(): DataStore<Preferences> { private fun createTestDataStore(): DataStore<Preferences> {
val file = File.createTempFile("tokens", ".preferences_pb") return InMemoryPreferencesDataStore()
file.deleteOnExit() }
return PreferenceDataStoreFactory.createWithPath(
produceFile = { file.absolutePath.toPath() } private class InMemoryPreferencesDataStore : DataStore<Preferences> {
) private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
} }
} }

View File

@@ -0,0 +1,67 @@
package ru.daemonlord.messenger.data.notifications.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
@OptIn(ExperimentalCoroutinesApi::class)
class DataStoreNotificationSettingsRepositoryTest {
@Test
fun saveAndReadGlobalSettings_returnsPersistedValues() = runTest {
val repository = DataStoreNotificationSettingsRepository(createTestDataStore())
repository.setGlobalEnabled(enabled = false)
repository.setPreviewEnabled(enabled = false)
val settings = repository.getSettings()
assertFalse(settings.globalEnabled)
assertFalse(settings.previewEnabled)
}
@Test
fun saveAndReadChatOverride_returnsPersistedValue() = runTest {
val repository = DataStoreNotificationSettingsRepository(createTestDataStore())
repository.setChatOverride(chatId = 42L, mode = ChatNotificationOverride.MUTED)
val mode = repository.getChatOverride(chatId = 42L)
assertEquals(ChatNotificationOverride.MUTED, mode)
}
@Test
fun clearChatOverride_resetsToDefault() = runTest {
val repository = DataStoreNotificationSettingsRepository(createTestDataStore())
repository.setChatOverride(chatId = 42L, mode = ChatNotificationOverride.ENABLED)
repository.clearChatOverride(chatId = 42L)
val mode = repository.getChatOverride(chatId = 42L)
assertTrue(mode == ChatNotificationOverride.DEFAULT)
}
private fun createTestDataStore(): DataStore<Preferences> {
return InMemoryPreferencesDataStore()
}
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
}

View File

@@ -0,0 +1,92 @@
package ru.daemonlord.messenger.domain.notifications.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
@OptIn(ExperimentalCoroutinesApi::class)
class ShouldShowMessageNotificationUseCaseTest {
@Test
fun globalDisabled_blocksNotifications() = runTest {
val repository = FakeNotificationSettingsRepository(
settings = NotificationSettings(globalEnabled = false, previewEnabled = true),
)
val useCase = ShouldShowMessageNotificationUseCase(repository)
val result = useCase(chatId = 1L, isMention = true, serverMuted = false)
assertFalse(result)
}
@Test
fun mutedChat_allowsMention() = runTest {
val repository = FakeNotificationSettingsRepository(
settings = NotificationSettings(globalEnabled = true, previewEnabled = true),
overrides = mutableMapOf(1L to ChatNotificationOverride.MUTED),
)
val useCase = ShouldShowMessageNotificationUseCase(repository)
val result = useCase(chatId = 1L, isMention = true, serverMuted = true)
assertTrue(result)
}
@Test
fun chatOverrideEnabled_unmutesServerMutedChat() = runTest {
val repository = FakeNotificationSettingsRepository(
settings = NotificationSettings(globalEnabled = true, previewEnabled = true),
overrides = mutableMapOf(1L to ChatNotificationOverride.ENABLED),
)
val useCase = ShouldShowMessageNotificationUseCase(repository)
val result = useCase(chatId = 1L, isMention = false, serverMuted = true)
assertTrue(result)
}
private class FakeNotificationSettingsRepository(
settings: NotificationSettings,
overrides: MutableMap<Long, ChatNotificationOverride> = mutableMapOf(),
) : NotificationSettingsRepository {
private val settingsFlow = MutableStateFlow(settings)
private val chatOverrides = overrides
override fun observeSettings(): Flow<NotificationSettings> = settingsFlow
override suspend fun getSettings(): NotificationSettings = settingsFlow.value
override suspend fun setGlobalEnabled(enabled: Boolean) {
settingsFlow.value = settingsFlow.value.copy(globalEnabled = enabled)
}
override suspend fun setPreviewEnabled(enabled: Boolean) {
settingsFlow.value = settingsFlow.value.copy(previewEnabled = enabled)
}
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
return settingsFlow.map { chatOverrides[chatId] ?: ChatNotificationOverride.DEFAULT }
}
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride {
return chatOverrides[chatId] ?: ChatNotificationOverride.DEFAULT
}
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) {
chatOverrides[chatId] = mode
}
override suspend fun clearChatOverride(chatId: Long) {
chatOverrides.remove(chatId)
}
}
}

View File

@@ -93,6 +93,7 @@
- [x] Notification channels (Android) - [x] Notification channels (Android)
- [x] Deep links: open chat/message - [x] Deep links: open chat/message
- [x] Mention override для muted чатов - [x] Mention override для muted чатов
- [x] DataStore настройки уведомлений (global + per-chat override)
## 13. UI/UX и темы ## 13. UI/UX и темы
- [ ] Светлая/темная тема (читаемая) - [ ] Светлая/темная тема (читаемая)