Implement Notification Settings

- Add UI and logic to inform the user of mismatched notification settings and help them correct it.
- Display a warning when the system notification settings are disabled and a link out to the app settings.
- A toggle to disable notifications for the device
- A screen for editing the group and direct chat notification defaults.
- A toggle for switching on/off atRoom and call notifications.
This commit is contained in:
David Langley 2023-09-12 01:11:13 +01:00
parent 9e27c2bdf0
commit 2f6f28bfbb
27 changed files with 924 additions and 59 deletions

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.featureflag.ui) implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)

View file

@ -34,11 +34,13 @@ import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode import io.element.android.features.preferences.impl.root.PreferencesRootNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -70,6 +72,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object NotificationSettings : NavTarget data object NotificationSettings : NavTarget
@Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -112,7 +117,16 @@ class PreferencesFlowNode @AssistedInject constructor(
createNode<AnalyticsSettingsNode>(buildContext) createNode<AnalyticsSettingsNode>(buildContext)
} }
NavTarget.NotificationSettings -> { NavTarget.NotificationSettings -> {
createNode<NotificationSettingsNode>(buildContext) val notificationSettingsCallback = object : NotificationSettingsNode.Callback {
override fun editDefaultNotificationMode(isOneToOne: Boolean) {
backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne))
}
}
createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback))
}
is NavTarget.EditDefaultNotificationSetting -> {
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input))
} }
} }
} }

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface NotificationSettingsEvents {
data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents
data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data class SetAtRoomNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data class SetDefaultGroupNotificationMode(val mode: RoomNotificationMode) : NotificationSettingsEvents
data class SetDefaultOneToOneNotificationMode(val mode: RoomNotificationMode) : NotificationSettingsEvents
data object FixConfigurationMismatch : NotificationSettingsEvents
data object ClearConfigurationMismatchError : NotificationSettingsEvents
}

View file

@ -21,10 +21,12 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class NotificationSettingsNode @AssistedInject constructor( class NotificationSettingsNode @AssistedInject constructor(
@ -32,13 +34,25 @@ class NotificationSettingsNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: NotificationSettingsPresenter, private val presenter: NotificationSettingsPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun editDefaultNotificationMode(isOneToOne: Boolean)
}
private val callbacks = plugins<Callback>()
private fun openEditDefault(isOneToOne: Boolean) {
callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
NotificationSettingsView( NotificationSettingsView(
state = state, state = state,
onOpenEditDefault = { openEditDefault(it) },
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
modifier = modifier modifier = modifier,
) )
} }
} }

View file

@ -17,18 +17,170 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class NotificationSettingsPresenter @Inject constructor() : Presenter<NotificationSettingsState> { class NotificationSettingsPresenter @Inject constructor(
private val notificationSettingsService: NotificationSettingsService,
private val userPushStoreFactory: UserPushStoreFactory,
private val matrixClient: MatrixClient,
private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider
) : Presenter<NotificationSettingsState> {
@Composable @Composable
override fun present(): NotificationSettingsState { override fun present(): NotificationSettingsState {
val userPushStore = userPushStoreFactory.create(matrixClient.sessionId)
val systemNotificationsEnabled: MutableState<Boolean> = remember {
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
}
val localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore
.getNotificationEnabledForDevice()
.collectAsState(initial = false)
val matrixSettings: MutableState<NotificationSettingsState.MatrixNotificationSettings> = remember {
mutableStateOf(NotificationSettingsState.MatrixNotificationSettings.Uninitialized)
}
LaunchedEffect(Unit) {
fetchSettings(matrixSettings)
observeNotificationSettings(matrixSettings)
}
fun handleEvents(event: NotificationSettingsEvents) {
when (event) {
is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled)
is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled)
is NotificationSettingsEvents.SetDefaultGroupNotificationMode -> localCoroutineScope.setDefaultGroupNotificationMode(event.mode)
is NotificationSettingsEvents.SetDefaultOneToOneNotificationMode -> localCoroutineScope.setDefaultOneToOneNotificationMode(event.mode)
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> matrixSettings.value = NotificationSettingsState.MatrixNotificationSettings.InvalidNotificationSettingsState(fixFailed = false)
NotificationSettingsEvents.FixConfigurationMismatch -> localCoroutineScope.fixConfigurationMismatch(matrixSettings)
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
}
}
return NotificationSettingsState( return NotificationSettingsState(
isEnabled = true, matrixNotificationSettings = matrixSettings.value,
hasSystemPermission = true, appNotificationSettings = NotificationSettingsState.AppNotificationSettings(
notifyMeOnRoom = true, systemNotificationsEnabled = systemNotificationsEnabled.value,
acceptCalls = true appNotificationsEnabled = appNotificationsEnabled.value
),
eventSink = ::handleEvents
) )
} }
@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings(target: MutableState<NotificationSettingsState.MatrixNotificationSettings>) {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
fetchSettings(target)
}
.launchIn(this)
}
private fun CoroutineScope.fetchSettings(target: MutableState<NotificationSettingsState.MatrixNotificationSettings>) = launch {
val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow()
val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow()
val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow()
val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow()
if(groupDefaultMode != encryptedGroupDefaultMode || oneToOneDefaultMode != encryptedOneToOneDefaultMode) {
target.value = NotificationSettingsState.MatrixNotificationSettings.InvalidNotificationSettingsState(fixFailed = false)
return@launch
}
val callNotificationsEnabled = notificationSettingsService.isCallEnabled().getOrThrow()
val atRoomNotificationsEnabled = notificationSettingsService.isRoomMentionEnabled().getOrThrow()
target.value = NotificationSettingsState.MatrixNotificationSettings.ValidNotificationSettingsState(
atRoomNotificationsEnabled = atRoomNotificationsEnabled,
callNotificationsEnabled = callNotificationsEnabled,
defaultGroupNotificationMode = encryptedGroupDefaultMode,
defaultOneToOneNotificationMode = encryptedOneToOneDefaultMode,
)
}
private fun CoroutineScope.fixConfigurationMismatch(target: MutableState<NotificationSettingsState.MatrixNotificationSettings>) = launch {
runCatching {
val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow()
val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow()
if (groupDefaultMode != encryptedGroupDefaultMode) {
notificationSettingsService.setDefaultRoomNotificationMode(
isEncrypted = encryptedGroupDefaultMode != RoomNotificationMode.ALL_MESSAGES,
mode = RoomNotificationMode.ALL_MESSAGES,
isOneToOne = false,
)
}
val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow()
val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow()
if (oneToOneDefaultMode != encryptedOneToOneDefaultMode) {
notificationSettingsService.setDefaultRoomNotificationMode(
isEncrypted = encryptedOneToOneDefaultMode != RoomNotificationMode.ALL_MESSAGES,
mode = RoomNotificationMode.ALL_MESSAGES,
isOneToOne = true,
)
}
}.fold(
onSuccess = {},
onFailure = {
target.value = NotificationSettingsState.MatrixNotificationSettings.InvalidNotificationSettingsState(fixFailed = true)
}
)
}
private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch {
notificationSettingsService.setRoomMentionEnabled(enabled)
}
private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch {
notificationSettingsService.setCallEnabled(enabled)
}
private fun CoroutineScope.setDefaultGroupNotificationMode(mode: RoomNotificationMode) = launch {
notificationSettingsService.setDefaultRoomNotificationMode(false, mode, false)
notificationSettingsService.setDefaultRoomNotificationMode(true, mode, false)
}
private fun CoroutineScope.setDefaultOneToOneNotificationMode(mode: RoomNotificationMode) = launch {
notificationSettingsService.setDefaultRoomNotificationMode(false, mode, true)
notificationSettingsService.setDefaultRoomNotificationMode(true, mode, true)
}
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
userPushStore.setNotificationEnabledForDevice(enabled)
}
private fun CoroutineScope.getDefaultRoomNotificationMode(isOneToOne: Boolean, defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch {
val encryptedMode = notificationSettingsService.getDefaultRoomNotificationMode(true, isOneToOne).getOrThrow()
val unencryptedMode = notificationSettingsService.getDefaultRoomNotificationMode(false, isOneToOne).getOrThrow()
if (encryptedMode == unencryptedMode) {
defaultRoomNotificationMode.value
} else {
defaultRoomNotificationMode.value = null
}
}
} }

View file

@ -16,10 +16,35 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable
data class NotificationSettingsState( data class NotificationSettingsState(
val hasSystemPermission: Boolean, val matrixNotificationSettings: MatrixNotificationSettings,
val isEnabled: Boolean, val appNotificationSettings: AppNotificationSettings,
val notifyMeOnRoom: Boolean, val eventSink: (NotificationSettingsEvents) -> Unit,
val acceptCalls: Boolean ) {
// val eventSink: (AnalyticsOptInEvents) -> Unit, sealed interface MatrixNotificationSettings {
) data object Uninitialized : MatrixNotificationSettings
data class ValidNotificationSettingsState(
val atRoomNotificationsEnabled: Boolean,
val callNotificationsEnabled: Boolean,
val defaultGroupNotificationMode: RoomNotificationMode?,
val defaultOneToOneNotificationMode: RoomNotificationMode?,
) : MatrixNotificationSettings
data class InvalidNotificationSettingsState(
val fixFailed: Boolean
) : MatrixNotificationSettings
}
data class AppNotificationSettings(
val systemNotificationsEnabled: Boolean,
val appNotificationsEnabled: Boolean,
)
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> { open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> {
override val values: Sequence<NotificationSettingsState> override val values: Sequence<NotificationSettingsState>
@ -26,8 +27,15 @@ open class NotificationSettingsStateProvider : PreviewParameterProvider<Notifica
} }
fun aNotificationSettingsState() = NotificationSettingsState( fun aNotificationSettingsState() = NotificationSettingsState(
isEnabled = true, matrixNotificationSettings = NotificationSettingsState.MatrixNotificationSettings.ValidNotificationSettingsState(
hasSystemPermission = false, atRoomNotificationsEnabled = true,
notifyMeOnRoom = true, callNotificationsEnabled = true,
acceptCalls = true defaultGroupNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES,
),
appNotificationSettings = NotificationSettingsState.AppNotificationSettings(
systemNotificationsEnabled = false,
appNotificationsEnabled = true,
),
eventSink = {}
) )

View file

@ -16,61 +16,134 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceView import io.element.android.libraries.designsystem.components.preferences.PreferenceView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.R
@Composable @Composable
fun NotificationSettingsView( fun NotificationSettingsView(
state: NotificationSettingsState, state: NotificationSettingsState,
onOpenEditDefault: (Boolean) -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
else -> Unit
}
}
PreferenceView( PreferenceView(
modifier = modifier, modifier = modifier,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
title = stringResource(id = CommonStrings.screen_notification_settings_title) title = stringResource(id = CommonStrings.screen_notification_settings_title)
) { ) {
if (state.isEnabled && !state.hasSystemPermission) { when (state.matrixNotificationSettings) {
is NotificationSettingsState.MatrixNotificationSettings.InvalidNotificationSettingsState -> InvalidNotificationSettingsView(
showError = state.matrixNotificationSettings.fixFailed,
onContinueClicked = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) },
onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) },
modifier = modifier,
)
NotificationSettingsState.MatrixNotificationSettings.Uninitialized -> return@PreferenceView
is NotificationSettingsState.MatrixNotificationSettings.ValidNotificationSettingsState -> NotificationSettingsContentView(
matrixSettings = state.matrixNotificationSettings,
systemSettings = state.appNotificationSettings,
onNotificationsEnabledChanged = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it))},
onGroupChatsClicked = { onOpenEditDefault(false) },
onDirectChatsClicked = { onOpenEditDefault(true) },
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
modifier = modifier,
)
}
}
}
@Composable
private fun NotificationSettingsContentView(
matrixSettings: NotificationSettingsState.MatrixNotificationSettings.ValidNotificationSettingsState,
systemSettings: NotificationSettingsState.AppNotificationSettings,
onNotificationsEnabledChanged: (Boolean) -> Unit,
onGroupChatsClicked: () -> Unit,
onDirectChatsClicked: () -> Unit,
onMentionNotificationsChanged: (Boolean) -> Unit,
onCallsNotificationsChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) {
PreferenceText( PreferenceText(
icon = Icons.Filled.NotificationsOff, icon = Icons.Filled.NotificationsOff,
title = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_turned_off), title = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_turned_off),
subtitle = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required, subtitle = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required,
stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required_content_link)), stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required_content_link)),
onClick = {} onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val uri: Uri = Uri.fromParts("package", context.packageName, null)
intent.data = uri
context.startActivity(intent)
}
) )
} }
PreferenceSwitch( PreferenceSwitch(
modifier = Modifier, modifier = modifier,
title = stringResource(id = CommonStrings.screen_notification_settings_enable_notifications), title = stringResource(id = CommonStrings.screen_notification_settings_enable_notifications),
isChecked = state.isEnabled, isChecked = systemSettings.appNotificationsEnabled,
switchAlignment = Alignment.Top, switchAlignment = Alignment.Top,
onCheckedChange = onNotificationsEnabledChanged
) )
if (state.isEnabled) { if (systemSettings.appNotificationsEnabled) {
PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_notification_section_title)) { PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_notification_section_title)) {
PreferenceText( PreferenceText(
title = stringResource(id = CommonStrings.screen_notification_settings_group_chats), title = stringResource(id = CommonStrings.screen_notification_settings_group_chats),
subtitle = "All messages" subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode),
onClick = onGroupChatsClicked
) )
PreferenceText( PreferenceText(
title = stringResource(id = CommonStrings.screen_notification_settings_direct_chats), title = stringResource(id = CommonStrings.screen_notification_settings_direct_chats),
subtitle = "Mentions and Keywords" subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode),
onClick = onDirectChatsClicked
) )
} }
@ -78,8 +151,9 @@ fun NotificationSettingsView(
PreferenceSwitch( PreferenceSwitch(
modifier = Modifier, modifier = Modifier,
title = stringResource(id = CommonStrings.screen_notification_settings_room_mention_label), title = stringResource(id = CommonStrings.screen_notification_settings_room_mention_label),
isChecked = state.notifyMeOnRoom, isChecked = matrixSettings.atRoomNotificationsEnabled,
switchAlignment = Alignment.Top, switchAlignment = Alignment.Top,
onCheckedChange = onMentionNotificationsChanged
) )
} }
@ -87,22 +161,83 @@ fun NotificationSettingsView(
PreferenceSwitch( PreferenceSwitch(
modifier = Modifier, modifier = Modifier,
title = stringResource(id = CommonStrings.screen_notification_settings_calls_label), title = stringResource(id = CommonStrings.screen_notification_settings_calls_label),
isChecked = state.acceptCalls, isChecked = matrixSettings.callNotificationsEnabled,
switchAlignment = Alignment.Top, switchAlignment = Alignment.Top,
onCheckedChange = onCallsNotificationsChanged
) )
} }
} }
}
@Composable
private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) =
when(mode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords)
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
null -> ""
}
@Composable
private fun InvalidNotificationSettingsView(
showError: Boolean,
onContinueClicked: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row {
Text(
stringResource(R.string.screen_notification_settings_configuration_mismatch),
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Start,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(R.string.screen_notification_settings_configuration_mismatch_description),
style = ElementTheme.typography.fontBodyMdRegular,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
text = stringResource(CommonStrings.action_continue),
size = ButtonSize.Medium,
modifier = Modifier.fillMaxWidth(),
onClick = onContinueClicked,
)
}
}
}
if(showError) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(id = CommonStrings.screen_notification_settings_failed_fixing_configuration),
onDismiss = onDismissError
)
} }
} }
@Preview @Preview
@Composable @Composable
internal fun AboutViewLightPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) = internal fun NotificationSettingsViewLightPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
internal fun AboutViewDarkPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) = internal fun NotificationSettingsViewDarkPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable
@ -110,5 +245,26 @@ private fun ContentToPreview(state: NotificationSettingsState) {
NotificationSettingsView( NotificationSettingsView(
state = state, state = state,
onBackPressed = {}, onBackPressed = {},
onOpenEditDefault = {},
)
}
@Preview
@Composable
internal fun InvalidNotificationSettingsViewightPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewLight { InvalidNotificationSettingsContentToPreview(state) }
@Preview
@Composable
internal fun InvalidNotificationSettingsViewDarkPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewDark { InvalidNotificationSettingsContentToPreview(state) }
@Composable
private fun InvalidNotificationSettingsContentToPreview(state: NotificationSettingsState) {
InvalidNotificationSettingsView(
showError = false,
onContinueClicked = {},
onDismissError = {},
) )
} }

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
interface SystemNotificationsEnabledProvider {
fun notificationsEnabled(): Boolean
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, boundType = SystemNotificationsEnabledProvider::class)
class DefaultSystemNotificationsEnabledProvider @Inject constructor(
@ApplicationContext private val context: Context,
): SystemNotificationsEnabledProvider {
override fun notificationsEnabled(): Boolean {
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.R
@Composable
fun DefaultNotificationSettingOption(
mode: RoomNotificationMode,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onOptionSelected: (RoomNotificationMode) -> Unit = {},
) {
val subtitle = when(mode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords)
else -> ""
}
Row(
modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onOptionSelected(mode) },
role = Role.RadioButton,
)
.padding(8.dp),
) {
Column(
Modifier
.weight(1f)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
) {
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
RadioButton(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(48.dp),
selected = isSelected,
onClick = null // null recommended for accessibility with screenreaders
)
}
}
@DayNightPreviews
@Composable
internal fun DefaultNotificationSettingOptionLightPreview() = ElementPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
DefaultNotificationSettingOption(
mode = RoomNotificationMode.ALL_MESSAGES,
isSelected = true,
)
DefaultNotificationSettingOption(
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
isSelected = false,
)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class EditDefaultNotificationSettingNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditDefaultNotificationSettingPresenter.Factory
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val isOneToOne: Boolean
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.isOneToOne)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditDefaultNotificationSettingView(
state = state,
onBackPressed = ::navigateUp,
modifier = modifier
)
}
}

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private val notificationSettingsService: NotificationSettingsService,
@Assisted private val isOneToOne: Boolean,
) : Presenter<EditDefaultNotificationSettingState> {
@AssistedFactory
interface Factory {
fun create(oneToOne: Boolean): EditDefaultNotificationSettingPresenter
}
@Composable
override fun present(): EditDefaultNotificationSettingState {
val mode: MutableState<RoomNotificationMode?> = remember {
mutableStateOf(null)
}
val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
fetchSettings(mode)
observeNotificationSettings(mode)
}
fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
when (event) {
is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode)
}
}
return EditDefaultNotificationSettingState(
isOneToOne = isOneToOne,
mode = mode.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.fetchSettings(mode: MutableState<RoomNotificationMode?>) = launch {
mode.value = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = isOneToOne).getOrThrow()
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings(mode: MutableState<RoomNotificationMode?>) {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
fetchSettings(mode)
}
.launchIn(this)
}
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch {
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne)
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean,
val mode: RoomNotificationMode?,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
)

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface EditDefaultNotificationSettingStateEvents {
data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.notifications.edit
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditDefaultNotificationSettingView(
state: EditDefaultNotificationSettingState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = if(state.isOneToOne) {
CommonStrings.screen_notification_settings_direct_chats
} else {
CommonStrings.screen_notification_settings_group_chats
}
PreferenceView(
modifier = modifier,
onBackPressed = onBackPressed,
title = stringResource(id = title)
) {
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val categoryTitle = if(state.isOneToOne) {
R.string.screen_notification_settings_edit_screen_direct_section_header
} else {
R.string.screen_notification_settings_edit_screen_group_section_header
}
PreferenceCategory(title = stringResource(id = categoryTitle)) {
if (state.mode != null) {
Column(modifier = modifier.selectableGroup()) {
validModes.forEach { item ->
DefaultNotificationSettingOption(
mode = item,
isSelected = state.mode == item,
onOptionSelected = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) }
)
}
}
}
}
}
}

View file

@ -106,7 +106,7 @@ class RoomDetailsPresenter @Inject constructor(
} }
RoomDetailsEvent.UnmuteNotification -> { RoomDetailsEvent.UnmuteNotification -> {
scope.launch(dispatchers.io) { scope.launch(dispatchers.io) {
client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.activeMemberCount) client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne)
} }
} }
} }

View file

@ -75,8 +75,6 @@ class RoomNotificationSettingsPresenter @Inject constructor(
} }
} }
Timber.d("NotifState: $roomNotificationSettingsState")
return RoomNotificationSettingsState( return RoomNotificationSettingsState(
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
defaultRoomNotificationMode = defaultRoomNotificationMode.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value,
@ -97,7 +95,7 @@ class RoomNotificationSettingsPresenter @Inject constructor(
private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch { private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch {
defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode(
room.isEncrypted, room.isEncrypted,
room.activeMemberCount room.isOneToOne
).getOrThrow() ).getOrThrow()
} }

View file

@ -21,16 +21,22 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettin
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.withContext
interface NotificationSettingsService { interface NotificationSettingsService {
/** /**
* State of the current room notification settings flow ([MatrixRoomNotificationSettingsState.Unknown] if not started). * State of the current room notification settings flow ([MatrixRoomNotificationSettingsState.Unknown] if not started).
*/ */
val notificationSettingsChangeFlow : SharedFlow<Unit> val notificationSettingsChangeFlow : SharedFlow<Unit>
suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings>
suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode>
suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit>
suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit>
suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit>
suspend fun muteRoom(roomId: RoomId): Result<Unit> suspend fun muteRoom(roomId: RoomId): Result<Unit>
suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<Unit> suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<Unit>
suspend fun isRoomMentionEnabled(): Result<Boolean>
suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit>
suspend fun isCallEnabled(): Result<Boolean>
suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
} }

View file

@ -49,6 +49,12 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long val activeMemberCount: Long
val joinedMemberCount: Long val joinedMemberCount: Long
/**
* A one-to-one is a room with exactly 2 members.
* See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).
*/
val isOneToOne: Boolean get() = joinedMemberCount == 2L
/** /**
* The current loaded members as a StateFlow. * The current loaded members as a StateFlow.
* Initial value is [MatrixRoomMembersState.Unknown]. * Initial value is [MatrixRoomMembersState.Unknown].
@ -178,6 +184,7 @@ interface MatrixRoom : Closeable {
suspend fun endPoll(pollStartId: EventId, text: String): Result<Unit> suspend fun endPoll(pollStartId: EventId, text: String): Result<Unit>
override fun close() = destroy() override fun close() = destroy()
} }

View file

@ -47,16 +47,22 @@ class RustNotificationSettingsService(
notificationSettings.setDelegate(notificationSettingsDelegate) notificationSettings.setDelegate(notificationSettingsDelegate)
} }
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> = override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> =
runCatching { runCatching {
notificationSettings.getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne(membersCount)).let(RoomNotificationSettingsMapper::map) notificationSettings.getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::map)
} }
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> = override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode> =
runCatching { runCatching {
notificationSettings.getDefaultRoomNotificationMode(isEncrypted, isOneToOne(membersCount)).let(RoomNotificationSettingsMapper::mapMode) notificationSettings.getDefaultRoomNotificationMode(isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::mapMode)
} }
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
}
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> = withContext(dispatchers.io) { override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> = withContext(dispatchers.io) {
runCatching { runCatching {
notificationSettings.setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode)) notificationSettings.setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode))
@ -71,16 +77,33 @@ class RustNotificationSettingsService(
override suspend fun muteRoom(roomId: RoomId): Result<Unit> = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE) override suspend fun muteRoom(roomId: RoomId): Result<Unit> = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long) = withContext(dispatchers.io) { override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean) = withContext(dispatchers.io) {
runCatching { runCatching {
notificationSettings.unmuteRoom(roomId.value, isEncrypted, isOneToOne(membersCount)) notificationSettings.unmuteRoom(roomId.value, isEncrypted, isOneToOne)
} }
} }
/** override suspend fun isRoomMentionEnabled(): Result<Boolean> = withContext(dispatchers.io) {
* A one-to-one is a room with exactly 2 members. runCatching {
* See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules). notificationSettings.isRoomMentionEnabled()
* @param membersCount The active members count in a room }
*/ }
private fun isOneToOne(membersCount: Long) = membersCount == 2L
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.setRoomMentionEnabled(enabled)
}
}
override suspend fun isCallEnabled(): Result<Boolean> = withContext(dispatchers.io) {
runCatching {
notificationSettings.isCallEnabled()
}
}
override suspend fun setCallEnabled(enabled: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.setCallEnabled(enabled)
}
}
} }

View file

@ -210,7 +210,7 @@ class RustMatrixRoom(
val currentRoomNotificationSettings = currentState.roomNotificationSettings() val currentRoomNotificationSettings = currentState.roomNotificationSettings()
_roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings) _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings)
runCatching { runCatching {
roomNotificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, activeMemberCount).getOrThrow() roomNotificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
}.map { }.map {
_roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(it) _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(it)
}.onFailure { }.onFailure {

View file

@ -36,14 +36,18 @@ class FakeNotificationSettingsService : NotificationSettingsService {
override val notificationSettingsChangeFlow: SharedFlow<Unit> override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _roomNotificationSettingsStateFlow get() = _roomNotificationSettingsStateFlow
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> { override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> {
return getRoomNotificationSettingsResult return getRoomNotificationSettingsResult
} }
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> { override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode> {
return getDefaultRoomNotificationMode return getDefaultRoomNotificationMode
} }
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> {
TODO("Not yet implemented")
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> { override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
return setRoomNotificationMode return setRoomNotificationMode
} }
@ -56,7 +60,24 @@ class FakeNotificationSettingsService : NotificationSettingsService {
return muteRoomResult return muteRoomResult
} }
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<Unit> { override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<Unit> {
return unmuteRoomResult return unmuteRoomResult
} }
override suspend fun isRoomMentionEnabled(): Result<Boolean> {
return Result.success(false)
}
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> {
return Result.success(Unit)
}
override suspend fun isCallEnabled(): Result<Boolean> {
return Result.success(false)
}
override suspend fun setCallEnabled(enabled: Boolean): Result<Unit> {
return Result.success(Unit)
}
} }

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -123,7 +124,7 @@ class DefaultPushHandler @Inject constructor(
} }
val userPushStore = userPushStoreFactory.create(userId) val userPushStore = userPushStoreFactory.create(userId)
if (!userPushStore.areNotificationEnabledForDevice()) { if (!userPushStore.getNotificationEnabledForDevice().first()) {
// TODO We need to check if this is an incoming call // TODO We need to check if this is an incoming call
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
return return

View file

@ -22,5 +22,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }

View file

@ -15,6 +15,8 @@
*/ */
package io.element.android.libraries.pushstore.api package io.element.android.libraries.pushstore.api
import kotlinx.coroutines.flow.Flow
/** /**
* Store data related to push about a user. * Store data related to push about a user.
@ -25,7 +27,7 @@ interface UserPushStore {
suspend fun getCurrentRegisteredPushKey(): String? suspend fun getCurrentRegisteredPushKey(): String?
suspend fun setCurrentRegisteredPushKey(value: String) suspend fun setCurrentRegisteredPushKey(value: String)
suspend fun areNotificationEnabledForDevice(): Boolean fun getNotificationEnabledForDevice(): Flow<Boolean>
suspend fun setNotificationEnabledForDevice(enabled: Boolean) suspend fun setNotificationEnabledForDevice(enabled: Boolean)
/** /**

View file

@ -20,6 +20,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
import kotlin.concurrent.thread import kotlin.concurrent.thread
@ -49,8 +50,8 @@ class DefaultUserPushStoreFactoryTest {
thread1.join() thread1.join()
thread2.join() thread2.join()
runBlocking { runBlocking {
userPushStore1!!.areNotificationEnabledForDevice() userPushStore1!!.getNotificationEnabledForDevice().first()
userPushStore2!!.areNotificationEnabledForDevice() userPushStore2!!.getNotificationEnabledForDevice().first()
} }
} }
} }

View file

@ -26,7 +26,9 @@ import androidx.datastore.preferences.preferencesDataStore
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
/** /**
* Store data related to push about a user. * Store data related to push about a user.
@ -60,8 +62,8 @@ class UserPushStoreDataStore(
} }
} }
override suspend fun areNotificationEnabledForDevice(): Boolean { override fun getNotificationEnabledForDevice(): Flow<Boolean> {
return context.dataStore.data.first()[notificationEnabled].orTrue() return context.dataStore.data.map{ it[notificationEnabled].orTrue() }
} }
override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { override suspend fun setNotificationEnabledForDevice(enabled: Boolean) {