android: add datastore notification settings and gating usecase
Some checks failed
CI / test (push) Failing after 2m6s
Some checks failed
CI / test (push) Failing after 2m6s
This commit is contained in:
@@ -301,3 +301,9 @@
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.media.repository.NetworkMediaRepository
|
||||
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.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -41,4 +43,10 @@ abstract class RepositoryModule {
|
||||
abstract fun bindMediaRepository(
|
||||
repository: NetworkMediaRepository,
|
||||
): MediaRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNotificationSettingsRepository(
|
||||
repository: DataStoreNotificationSettingsRepository,
|
||||
): NotificationSettingsRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.notifications.model
|
||||
|
||||
enum class ChatNotificationOverride {
|
||||
DEFAULT,
|
||||
ENABLED,
|
||||
MUTED,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.notifications.model
|
||||
|
||||
data class NotificationSettings(
|
||||
val globalEnabled: Boolean = true,
|
||||
val previewEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.entity.MessageEntity
|
||||
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.model.RealtimeEvent
|
||||
import javax.inject.Inject
|
||||
@@ -26,6 +27,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
private val notificationDispatcher: NotificationDispatcher,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -76,7 +78,12 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
chatDao.incrementUnread(chatId = event.chatId)
|
||||
val activeChatId = activeChatTracker.activeChatId.value
|
||||
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 body = event.text?.takeIf { it.isNotBlank() }
|
||||
?: when (event.type?.lowercase()) {
|
||||
|
||||
@@ -2,14 +2,14 @@ package ru.daemonlord.messenger.core.token
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Path.Companion.toPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DataStoreTokenRepositoryTest {
|
||||
@@ -46,10 +46,18 @@ class DataStoreTokenRepositoryTest {
|
||||
}
|
||||
|
||||
private fun createTestDataStore(): DataStore<Preferences> {
|
||||
val file = File.createTempFile("tokens", ".preferences_pb")
|
||||
file.deleteOnExit()
|
||||
return PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { file.absolutePath.toPath() }
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
- [x] Notification channels (Android)
|
||||
- [x] Deep links: open chat/message
|
||||
- [x] Mention override для muted чатов
|
||||
- [x] DataStore настройки уведомлений (global + per-chat override)
|
||||
|
||||
## 13. UI/UX и темы
|
||||
- [ ] Светлая/темная тема (читаемая)
|
||||
|
||||
Reference in New Issue
Block a user