diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 07dd92f..70977dc 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt new file mode 100644 index 0000000..1b7b1ae --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt @@ -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, +) : NotificationSettingsRepository { + + override fun observeSettings(): Flow { + 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 { + 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") + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index 7796f53..66dab50 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/ChatNotificationOverride.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/ChatNotificationOverride.kt new file mode 100644 index 0000000..451b56c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/ChatNotificationOverride.kt @@ -0,0 +1,8 @@ +package ru.daemonlord.messenger.domain.notifications.model + +enum class ChatNotificationOverride { + DEFAULT, + ENABLED, + MUTED, +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/NotificationSettings.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/NotificationSettings.kt new file mode 100644 index 0000000..9036fed --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/model/NotificationSettings.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.domain.notifications.model + +data class NotificationSettings( + val globalEnabled: Boolean = true, + val previewEnabled: Boolean = true, +) + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt new file mode 100644 index 0000000..abb7720 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt @@ -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 + suspend fun getSettings(): NotificationSettings + suspend fun setGlobalEnabled(enabled: Boolean) + suspend fun setPreviewEnabled(enabled: Boolean) + + fun observeChatOverride(chatId: Long): Flow + suspend fun getChatOverride(chatId: Long): ChatNotificationOverride + suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) + suspend fun clearChatOverride(chatId: Long) +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCase.kt new file mode 100644 index 0000000..0f57399 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCase.kt @@ -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 + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 9878e0c..96167f4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -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()) { diff --git a/android/app/src/test/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepositoryTest.kt index aeb3cd4..96d60d2 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepositoryTest.kt @@ -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 { - val file = File.createTempFile("tokens", ".preferences_pb") - file.deleteOnExit() - return PreferenceDataStoreFactory.createWithPath( - produceFile = { file.absolutePath.toPath() } - ) + return InMemoryPreferencesDataStore() + } + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } } } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt new file mode 100644 index 0000000..5f1dab2 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt @@ -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 { + return InMemoryPreferencesDataStore() + } + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } + } +} diff --git a/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt new file mode 100644 index 0000000..d02424c --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt @@ -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 = mutableMapOf(), + ) : NotificationSettingsRepository { + private val settingsFlow = MutableStateFlow(settings) + private val chatOverrides = overrides + + override fun observeSettings(): Flow = 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 { + 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) + } + } +} + diff --git a/docs/android-checklist.md b/docs/android-checklist.md index e6513c9..7ae3393 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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 и темы - [ ] Светлая/темная тема (читаемая)