Merge pull request #1304 from vector-im/dla/feature/connect_sdk_to_global_notifications_ui

Add global notification settings
This commit is contained in:
David Langley 2023-09-14 11:17:11 +01:00 committed by GitHub
commit 433c3554f8
58 changed files with 1661 additions and 61 deletions

1
changelog.d/510.misc Normal file
View file

@ -0,0 +1 @@
Add a sub-screen "Notifications" in the existing application Settings

View file

@ -40,6 +40,8 @@ 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.pushstore.test)
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

@ -33,6 +33,8 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode 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.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
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
@ -69,6 +71,12 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object About : NavTarget data object About : NavTarget
@Parcelize
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 {
@ -94,6 +102,10 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenDeveloperSettings() { override fun onOpenDeveloperSettings() {
backstack.push(NavTarget.DeveloperSettings) backstack.push(NavTarget.DeveloperSettings)
} }
override fun onOpenNotificationSettings() {
backstack.push(NavTarget.NotificationSettings)
}
} }
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback)) createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
} }
@ -114,6 +126,18 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AnalyticsSettings -> { NavTarget.AnalyticsSettings -> {
createNode<AnalyticsSettingsNode>(buildContext) createNode<AnalyticsSettingsNode>(buildContext)
} }
NavTarget.NotificationSettings -> {
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,27 @@
/*
* 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
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 object FixConfigurationMismatch : NotificationSettingsEvents
data object ClearConfigurationMismatchError : NotificationSettingsEvents
}

View file

@ -0,0 +1,57 @@
/*
* 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 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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class NotificationSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: NotificationSettingsPresenter,
) : 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
override fun View(modifier: Modifier) {
val state = presenter.present()
NotificationSettingsView(
state = state,
onOpenEditDefault = { openEditDefault(isOneToOne = it) },
onBackPressed = ::navigateUp,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,168 @@
/*
* 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 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.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 kotlin.time.Duration.Companion.seconds
class NotificationSettingsPresenter @Inject constructor(
private val notificationSettingsService: NotificationSettingsService,
private val userPushStoreFactory: UserPushStoreFactory,
private val matrixClient: MatrixClient,
private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider
) : Presenter<NotificationSettingsState> {
@Composable
override fun present(): NotificationSettingsState {
val userPushStore = remember { 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.MatrixSettings> = remember {
mutableStateOf(NotificationSettingsState.MatrixSettings.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.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> {
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
}
NotificationSettingsEvents.FixConfigurationMismatch -> localCoroutineScope.fixConfigurationMismatch(matrixSettings)
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
}
}
}
return NotificationSettingsState(
matrixSettings = matrixSettings.value,
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value
),
eventSink = ::handleEvents
)
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings(target: MutableState<NotificationSettingsState.MatrixSettings>) {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
fetchSettings(target)
}
.launchIn(this)
}
private fun CoroutineScope.fetchSettings(target: MutableState<NotificationSettingsState.MatrixSettings>) = 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.MatrixSettings.Invalid(fixFailed = false)
return@launch
}
val callNotificationsEnabled = notificationSettingsService.isCallEnabled().getOrThrow()
val atRoomNotificationsEnabled = notificationSettingsService.isRoomMentionEnabled().getOrThrow()
target.value = NotificationSettingsState.MatrixSettings.Valid(
atRoomNotificationsEnabled = atRoomNotificationsEnabled,
callNotificationsEnabled = callNotificationsEnabled,
defaultGroupNotificationMode = encryptedGroupDefaultMode,
defaultOneToOneNotificationMode = encryptedOneToOneDefaultMode,
)
}
private fun CoroutineScope.fixConfigurationMismatch(target: MutableState<NotificationSettingsState.MatrixSettings>) = 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.MatrixSettings.Invalid(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.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
userPushStore.setNotificationEnabledForDevice(enabled)
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable
data class NotificationSettingsState(
val matrixSettings: MatrixSettings,
val appSettings: AppSettings,
val eventSink: (NotificationSettingsEvents) -> Unit,
) {
sealed interface MatrixSettings {
data object Uninitialized : MatrixSettings
data class Valid(
val atRoomNotificationsEnabled: Boolean,
val callNotificationsEnabled: Boolean,
val defaultGroupNotificationMode: RoomNotificationMode?,
val defaultOneToOneNotificationMode: RoomNotificationMode?,
) : MatrixSettings
data class Invalid(
val fixFailed: Boolean
) : MatrixSettings
}
data class AppSettings(
val systemNotificationsEnabled: Boolean,
val appNotificationsEnabled: Boolean,
)
}

View file

@ -0,0 +1,41 @@
/*
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> {
override val values: Sequence<NotificationSettingsState>
get() = sequenceOf(
aNotificationSettingsState(),
)
}
fun aNotificationSettingsState() = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
atRoomNotificationsEnabled = true,
callNotificationsEnabled = true,
defaultGroupNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES,
),
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = false,
appNotificationsEnabled = true,
),
eventSink = {}
)

View file

@ -0,0 +1,263 @@
/*
* 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 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.filled.NotificationsOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
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.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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
/**
* A view that allows a user edit their global notification settings.
*/
@Composable
fun NotificationSettingsView(
state: NotificationSettingsState,
onOpenEditDefault: (isOneToOne: Boolean) -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
else -> Unit
}
}
PreferenceView(
modifier = modifier,
onBackPressed = onBackPressed,
title = stringResource(id = CommonStrings.screen_notification_settings_title)
) {
when (state.matrixSettings) {
is NotificationSettingsState.MatrixSettings.Invalid -> InvalidNotificationSettingsView(
showError = state.matrixSettings.fixFailed,
onContinueClicked = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) },
onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) },
)
NotificationSettingsState.MatrixSettings.Uninitialized -> return@PreferenceView
is NotificationSettingsState.MatrixSettings.Valid -> NotificationSettingsContentView(
matrixSettings = state.matrixSettings,
systemSettings = state.appSettings,
onNotificationsEnabledChanged = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it))},
onGroupChatsClicked = { onOpenEditDefault(false) },
onDirectChatsClicked = { onOpenEditDefault(true) },
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
)
}
}
}
@Composable
private fun NotificationSettingsContentView(
matrixSettings: NotificationSettingsState.MatrixSettings.Valid,
systemSettings: NotificationSettingsState.AppSettings,
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(
icon = Icons.Filled.NotificationsOff,
title = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_turned_off),
subtitle = stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required,
stringResource(id = CommonStrings.screen_notification_settings_system_notifications_action_required_content_link)),
onClick = {
context.startNotificationSettingsIntent()
}
)
}
PreferenceSwitch(
modifier = modifier,
title = stringResource(id = CommonStrings.screen_notification_settings_enable_notifications),
isChecked = systemSettings.appNotificationsEnabled,
switchAlignment = Alignment.Top,
onCheckedChange = onNotificationsEnabledChanged
)
if (systemSettings.appNotificationsEnabled) {
PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_notification_section_title)) {
PreferenceText(
title = stringResource(id = CommonStrings.screen_notification_settings_group_chats),
subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode),
onClick = onGroupChatsClicked
)
PreferenceText(
title = stringResource(id = CommonStrings.screen_notification_settings_direct_chats),
subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode),
onClick = onDirectChatsClicked
)
}
PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_mode_mentions)) {
PreferenceSwitch(
modifier = Modifier,
title = stringResource(id = CommonStrings.screen_notification_settings_room_mention_label),
isChecked = matrixSettings.atRoomNotificationsEnabled,
switchAlignment = Alignment.Top,
onCheckedChange = onMentionNotificationsChanged
)
}
// We are removing the call notification toggle until call support has been added
// PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
// PreferenceSwitch(
// modifier = Modifier,
// title = stringResource(id = CommonStrings.screen_notification_settings_calls_label),
// isChecked = matrixSettings.callNotificationsEnabled,
// switchAlignment = Alignment.Top,
// onCheckedChange = onCallsNotificationsChanged
// )
// }
}
}
@Composable
private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) =
when(mode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = CommonStrings.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(CommonStrings.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(CommonStrings.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
@Composable
internal fun NotificationSettingsViewLightPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun NotificationSettingsViewDarkPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: NotificationSettingsState) {
NotificationSettingsView(
state = state,
onBackPressed = {},
onOpenEditDefault = {},
)
}
@Preview
@Composable
internal fun InvalidNotificationSettingsViewightPreview() =
ElementPreviewLight { InvalidNotificationSettingsContentToPreview() }
@Preview
@Composable
internal fun InvalidNotificationSettingsViewDarkPreview() =
ElementPreviewDark { InvalidNotificationSettingsContentToPreview() }
@Composable
private fun InvalidNotificationSettingsContentToPreview() {
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.CommonStrings
@Composable
fun DefaultNotificationSettingOption(
mode: RoomNotificationMode,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onOptionSelected: (RoomNotificationMode) -> Unit = {},
) {
val subtitle = when(mode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = CommonStrings.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 DefaultNotificationSettingOptionPreview() = 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.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 {
// On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
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,75 @@
/*
* 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.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
/**
* A view that allows a user to edit the default notification setting for rooms. This can be set separately
* for one-to-one and group rooms, indicated by [EditDefaultNotificationSettingState.isOneToOne].
*/
@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)
) {
// Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val categoryTitle = if(state.isOneToOne) {
CommonStrings.screen_notification_settings_edit_screen_direct_section_header
} else {
CommonStrings.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

@ -44,6 +44,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenAnalytics() fun onOpenAnalytics()
fun onOpenAbout() fun onOpenAbout()
fun onOpenDeveloperSettings() fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
} }
private fun onOpenBugReport() { private fun onOpenBugReport() {
@ -72,6 +73,10 @@ class PreferencesRootNode @AssistedInject constructor(
} }
} }
private fun onOpenNotificationSettings() {
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -87,6 +92,7 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenDeveloperSettings = this::onOpenDeveloperSettings, onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onSuccessLogout = { onSuccessLogout(activity, it) }, onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, state.accountManagementUrl) }, onManageAccountClicked = { onManageAccountClicked(activity, state.accountManagementUrl) },
onOpenNotificationSettings = this::onOpenNotificationSettings
) )
} }

View file

@ -29,6 +29,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
@ -46,6 +48,7 @@ class PreferencesRootPresenter @Inject constructor(
private val buildType: BuildType, private val buildType: BuildType,
private val versionFormatter: VersionFormatter, private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val featureFlagService: FeatureFlagService,
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@Composable @Composable
@ -60,6 +63,11 @@ class PreferencesRootPresenter @Inject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
val showNotificationSettings = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
showNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
}
// We should display the 'complete verification' option if the current session can be verified // We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false) val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
@ -81,6 +89,7 @@ class PreferencesRootPresenter @Inject constructor(
accountManagementUrl = accountManagementUrl.value, accountManagementUrl = accountManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders, showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings, showDeveloperSettings = showDeveloperSettings,
showNotificationSettings = showNotificationSettings.value,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
} }

View file

@ -28,5 +28,6 @@ data class PreferencesRootState(
val accountManagementUrl: String?, val accountManagementUrl: String?,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val showNotificationSettings: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
) )

View file

@ -28,5 +28,6 @@ fun aPreferencesRootState() = PreferencesRootState(
accountManagementUrl = "aUrl", accountManagementUrl = "aUrl",
showAnalyticsSettings = true, showAnalyticsSettings = true,
showDeveloperSettings = true, showDeveloperSettings = true,
showNotificationSettings = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
) )

View file

@ -24,6 +24,7 @@ import androidx.compose.material.icons.outlined.DeveloperMode
import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.InsertChart import androidx.compose.material.icons.outlined.InsertChart
import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -58,6 +59,7 @@ fun PreferencesRootView(
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit,
onOpenNotificationSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -92,6 +94,13 @@ fun PreferencesRootView(
onClick = onOpenAnalytics, onClick = onOpenAnalytics,
) )
} }
if(state.showNotificationSettings) {
PreferenceText(
title = stringResource(id = CommonStrings.screen_notification_settings_title),
icon = Icons.Outlined.Notifications,
onClick = onOpenNotificationSettings,
)
}
PreferenceText( PreferenceText(
title = stringResource(id = CommonStrings.action_report_bug), title = stringResource(id = CommonStrings.action_report_bug),
icon = Icons.Outlined.BugReport, icon = Icons.Outlined.BugReport,
@ -153,5 +162,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onVerifyClicked = {}, onVerifyClicked = {},
onSuccessLogout = {}, onSuccessLogout = {},
onManageAccountClicked = {}, onManageAccountClicked = {},
onOpenNotificationSettings = {},
) )
} }

View file

@ -18,8 +18,8 @@ package io.element.android.features.preferences.impl.tasks
import android.content.Context import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope

View file

@ -0,0 +1,64 @@
/*
* 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 app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Test
class EditDefaultNotificationSettingsPresenterTests {
@Test
fun `present - ensures initial state is correct`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.mode).isNull()
Truth.assertThat(initialState.isOneToOne).isFalse()
val loadedState = consumeItemsUntilPredicate {
it.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
}
}
@Test
fun `present - edit default notification setting`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
val loadedState = consumeItemsUntilPredicate {
it.mode == RoomNotificationMode.ALL_MESSAGES
}.last()
Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
}
}
}

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
class FakeSystemNotificationsEnabledProvider: SystemNotificationsEnabledProvider {
override fun notificationsEnabled(): Boolean {
return true
}
}

View file

@ -0,0 +1,201 @@
/*
* 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 app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class NotificationSettingsPresenterTests {
@Test
fun `present - ensures initial state is correct`() = runTest {
val presenter = aNotificationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.appSettings.appNotificationsEnabled).isFalse()
Truth.assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue()
Truth.assertThat(initialState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Uninitialized)
val loadedState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
Truth.assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue()
Truth.assertThat(loadedState.appSettings.systemNotificationsEnabled).isTrue()
val valid = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(valid?.atRoomNotificationsEnabled).isFalse()
Truth.assertThat(valid?.callNotificationsEnabled).isFalse()
Truth.assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
Truth.assertThat(valid?.defaultOneToOneNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - default group notification mode changed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = aNotificationPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
val updatedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)
?.defaultGroupNotificationMode == RoomNotificationMode.ALL_MESSAGES
}.last()
val valid = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
}
}
@Test
fun `present - notification settings mismatched`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = aNotificationPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setDefaultRoomNotificationMode(
isEncrypted = true,
isOneToOne = false,
mode = RoomNotificationMode.ALL_MESSAGES
)
notificationSettingsService.setDefaultRoomNotificationMode(
isEncrypted = false,
isOneToOne = false,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
)
val updatedState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Invalid
}.last()
Truth.assertThat(updatedState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false))
}
}
@Test
fun `present - fix notification settings mismatched`() = runTest {
// Start with a mismatched configuration
val notificationSettingsService = FakeNotificationSettingsService(
initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES,
initialGroupDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedOneToOneDefaultMode = RoomNotificationMode.ALL_MESSAGES,
initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
)
val presenter = aNotificationPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch)
val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
val fixedMatrixState = fixedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(fixedMatrixState?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
}
}
@Test
fun `present - set notifications enabled`() = runTest {
val presenter = aNotificationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last()
Truth.assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue()
loadedState.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(false))
val updatedState = consumeItemsUntilPredicate {
!it.appSettings.appNotificationsEnabled
}.last()
Truth.assertThat(updatedState.appSettings.appNotificationsEnabled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - set call notifications enabled`() = runTest {
val presenter = aNotificationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false
}.last()
val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(validMatrixState?.callNotificationsEnabled).isFalse()
loadedState.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(true))
val updatedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == true
}.last()
val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(updatedMatrixState?.callNotificationsEnabled).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - set atRoom notifications enabled`() = runTest {
val presenter = aNotificationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
}.last()
val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse()
loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true))
val updatedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == true
}.last()
val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(updatedMatrixState?.atRoomNotificationsEnabled).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
private fun aNotificationPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) : NotificationSettingsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
return NotificationSettingsPresenter(
notificationSettingsService = notificationSettingsService,
userPushStoreFactory = FakeUserPushStoreFactory(),
matrixClient = matrixClient,
systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(),
)
}
}

View file

@ -24,6 +24,7 @@ import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@ -52,6 +53,7 @@ class PreferencesRootPresenterTest {
BuildType.DEBUG, BuildType.DEBUG,
FakeVersionFormatter(), FakeVersionFormatter(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeFeatureFlagService()
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

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

@ -94,7 +94,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

@ -66,7 +66,6 @@ class RoomDetailsPresenterTests {
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
): RoomDetailsPresenter { ): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId) return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
@ -385,7 +384,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - mute room notifications`() = runTest { fun `present - mute room notifications`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake() val leaveRoomPresenter = LeaveRoomPresenterFake()
val notificationSettingsService = FakeNotificationSettingsService(initialMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService) val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -404,8 +403,8 @@ class RoomDetailsPresenterTests {
fun `present - unmute room notifications`() = runTest { fun `present - unmute room notifications`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake() val leaveRoomPresenter = LeaveRoomPresenterFake()
val notificationSettingsService = FakeNotificationSettingsService( val notificationSettingsService = FakeNotificationSettingsService(
initialMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialDefaultMode = RoomNotificationMode.ALL_MESSAGES initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES
) )
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService) val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)

View file

@ -24,7 +24,6 @@ import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -67,9 +66,9 @@ class RoomNotificationSettingsPresenterTests {
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate { val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == A_ROOM_NOTIFICATION_MODE it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last() }.last()
Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(A_ROOM_NOTIFICATION_MODE) Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
} }
} }

View file

@ -122,7 +122,7 @@ fun Context.copyToClipboard(
* Shows notification settings for the current app. * Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings * In android O will directly opens the notification settings, in lower version it will show the App settings
*/ */
fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher<Intent>) { fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher<Intent>? = null) {
val intent = Intent() val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
@ -132,7 +132,12 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.fromParts("package", packageName, null) intent.data = Uri.fromParts("package", packageName, null)
} }
activityResultLauncher.launch(intent)
if (activityResultLauncher != null) {
activityResultLauncher.launch(intent)
} else {
startActivity(intent)
}
} }
fun Context.openAppSettingsPage( fun Context.openAppSettingsPage(

View file

@ -41,8 +41,7 @@ enum class FeatureFlags(
NotificationSettings( NotificationSettings(
key = "feature.notificationsettings", key = "feature.notificationsettings",
title = "Show notification settings", title = "Show notification settings",
// Do not forget to edit StaticFeatureFlagProvider when enabling the feature. defaultValue = true,
defaultValue = false,
), ),
RichTextEditor( RichTextEditor(
key = "feature.richtexteditor", key = "feature.richtexteditor",

View file

@ -34,7 +34,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
when(feature) { when(feature) {
FeatureFlags.LocationSharing -> true FeatureFlags.LocationSharing -> true
FeatureFlags.Polls -> true FeatureFlags.Polls -> true
FeatureFlags.NotificationSettings -> false FeatureFlags.NotificationSettings -> true
FeatureFlags.RichTextEditor -> true FeatureFlags.RichTextEditor -> true
} }
} else { } else {

View file

@ -27,10 +27,15 @@ 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() = activeMemberCount == 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

@ -101,7 +101,6 @@ class RustMatrixClient constructor(
client = client, client = client,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService) private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService)
private val notificationClient = client.notificationClient(notificationProcessSetup) private val notificationClient = client.notificationClient(notificationProcessSetup)
.use { builder -> .use { builder ->

View file

@ -47,16 +47,26 @@ 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 +81,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

@ -212,7 +212,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

@ -25,32 +25,75 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
class FakeNotificationSettingsService( class FakeNotificationSettingsService(
initialMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE, initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
initialDefaultMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
) : NotificationSettingsService { ) : NotificationSettingsService {
private var _roomNotificationSettingsStateFlow = MutableStateFlow(Unit) private var _notificationSettingsStateFlow = MutableStateFlow(Unit)
private var defaultRoomNotificationMode: RoomNotificationMode = initialDefaultMode private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialMode private var defaultEncryptedGroupRoomNotificationMode: RoomNotificationMode = initialEncryptedGroupDefaultMode
private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode
private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialRoomMode
private var callNotificationsEnabled = false
private var atRoomNotificationsEnabled = false
override val notificationSettingsChangeFlow: SharedFlow<Unit> override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _roomNotificationSettingsStateFlow get() = _notificationSettingsStateFlow
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 Result.success(RoomNotificationSettings(mode = roomNotificationMode, isDefault = roomNotificationMode == defaultRoomNotificationMode)) return Result.success(
RoomNotificationSettings(
mode = roomNotificationMode,
isDefault = roomNotificationMode == defaultEncryptedGroupRoomNotificationMode
)
)
} }
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> { override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode> {
return Result.success(defaultRoomNotificationMode) return if (isOneToOne) {
if (isEncrypted) {
Result.success(defaultEncryptedOneToOneRoomNotificationMode)
} else {
Result.success(defaultOneToOneRoomNotificationMode)
}
} else {
if (isEncrypted) {
Result.success(defaultEncryptedGroupRoomNotificationMode)
} else {
Result.success(defaultGroupRoomNotificationMode)
}
}
}
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> {
if (isOneToOne) {
if (isEncrypted) {
defaultEncryptedOneToOneRoomNotificationMode = mode
} else {
defaultOneToOneRoomNotificationMode = mode
}
} else {
if (isEncrypted) {
defaultEncryptedGroupRoomNotificationMode = mode
} else {
defaultGroupRoomNotificationMode = mode
}
}
_notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
} }
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> { override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
roomNotificationMode = mode roomNotificationMode = mode
_roomNotificationSettingsStateFlow.emit(Unit) _notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> { override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> {
roomNotificationMode = defaultRoomNotificationMode roomNotificationMode = defaultEncryptedGroupRoomNotificationMode
_roomNotificationSettingsStateFlow.emit(Unit) _notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit) return Result.success(Unit)
} }
@ -58,7 +101,25 @@ class FakeNotificationSettingsService(
return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE) return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
} }
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 restoreDefaultRoomNotificationMode(roomId) return restoreDefaultRoomNotificationMode(roomId)
} }
override suspend fun isRoomMentionEnabled(): Result<Boolean> {
return Result.success(atRoomNotificationsEnabled)
}
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> {
atRoomNotificationsEnabled = enabled
return Result.success(Unit)
}
override suspend fun isCallEnabled(): Result<Boolean> {
return Result.success(callNotificationsEnabled)
}
override suspend fun setCallEnabled(enabled: Boolean): Result<Unit> {
callNotificationsEnabled = enabled
return Result.success(Unit)
}
} }

View file

@ -148,7 +148,7 @@ class FakeMatrixRoom(
} }
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask { override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, activeMemberCount).getOrThrow() val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings) roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
return Result.success(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) {

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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.pushstore.test"
}
dependencies {
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.tests.testutils)
implementation(projects.libraries.pushstore.api)
}

View file

@ -0,0 +1,59 @@
/*
* 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 com.element.android.libraries.pushstore.test.userpushstore
import io.element.android.libraries.pushstore.api.UserPushStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeUserPushStore: UserPushStore {
private var pushProviderName: String? = null
private var currentRegisteredPushKey: String? = null
private val notificationEnabledForDevice = MutableStateFlow(true)
override suspend fun getPushProviderName(): String? {
return pushProviderName
}
override suspend fun setPushProviderName(value: String) {
pushProviderName = value
}
override suspend fun getCurrentRegisteredPushKey(): String? {
return currentRegisteredPushKey
}
override suspend fun setCurrentRegisteredPushKey(value: String) {
currentRegisteredPushKey = value
}
override fun getNotificationEnabledForDevice(): Flow<Boolean> {
return notificationEnabledForDevice
}
override suspend fun setNotificationEnabledForDevice(enabled: Boolean) {
notificationEnabledForDevice.value = enabled
}
override fun useCompleteNotificationFormat(): Boolean {
return true
}
override suspend fun reset() {
}
}

View file

@ -0,0 +1,28 @@
/*
* 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 com.element.android.libraries.pushstore.test.userpushstore
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
class FakeUserPushStoreFactory: UserPushStoreFactory {
override fun create(userId: SessionId): UserPushStore {
return FakeUserPushStore()
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:954c763c082f8e4016247643408a61a038a221bb84643ba7e80f3389818528ba
size 15587

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30886fbb00695077a80e046cd6c6c134375be8ecb8c3962e72fdc0dff60d7069
size 14194

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbece1a3764a140e2e8218d34517461809acf67ea87715140fa22b81ffa9d7c0
size 42771

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6c2719db95641bd6c2b613c65008ebecb8f8989d74d76c8070da0d741be3e7d
size 43506

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c100e53723c6ed01ed1d82bba61e355bfc89ef45152f1490f664cf056beb95ce
size 49228

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d3ebc3196c825b08f7622ad370bb69f2989e57a0fc69058f41877163b43fd1a
size 52756

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:239a01988afb77f399b9488e3c314846a59d368ba2a7fd8bec1514ff60dbd8ab oid sha256:a99b724ff8e90a48557c38909fe62d3fb1b260c3b5f6a57ac9f3b25879aad1ef
size 39772 size 41532

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1b3670c1508e8548a32e51c8d1bd75d2dabce73a2bc710dc673a85f98901b5fb oid sha256:1b87a3743ebe1fc4c5df49180b94652543c58d250184787ae3f32c0c713ffad8
size 39100 size 40858

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ec1f4a968f58c9c7faf6d51ec5dc8e31f00d867eb506b35a466e6a2242945c74 oid sha256:d4dd79afac67e5d7ada5b32dc2d9d21d7b56ae53a19d34775058df77d4abce30
size 42131 size 44169

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4b0006728215e73456e78192ce7d774d8bc792d04fef9fcfa3b76c081dfc8deb oid sha256:53b465eddb29226425e68f50de870d85477fb847fa5bb83b6294da11f682e2d0
size 41996 size 44036