From a9d87da1ffebc8452224b6c6bd93a10489fe45b6 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 22 Sep 2023 16:16:52 +0100 Subject: [PATCH 01/41] Add roomsWithUserDefinedRules data and render list - get roomsWithUserDefinedRules from rust - add to state in the presenter - render in the edit defaults view as a list --- ...EditDefaultNotificationSettingPresenter.kt | 34 ++++++++++++ .../EditDefaultNotificationSettingState.kt | 2 + .../EditDefaultNotificationSettingView.kt | 54 +++++++++++++++++++ ...efaultNotificationSettingsStateProvider.kt | 49 +++++++++++++++++ .../components/avatar/AvatarSize.kt | 2 + .../NotificationSettingsService.kt | 1 + .../RustNotificationSettingsService.kt | 5 ++ 7 files changed, 147 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt index 764b37c52d..58140cb35f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt @@ -26,19 +26,25 @@ 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.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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 java.text.Collator import kotlin.time.Duration.Companion.seconds class EditDefaultNotificationSettingPresenter @AssistedInject constructor( private val notificationSettingsService: NotificationSettingsService, @Assisted private val isOneToOne: Boolean, + private val roomListService: RoomListService, + private val matrixClient: MatrixClient, ) : Presenter { @AssistedFactory interface Factory { @@ -50,10 +56,16 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( val mode: MutableState = remember { mutableStateOf(null) } + + val roomsWithUserDefinedMode: MutableState> = remember { + mutableStateOf(listOf()) + } + val localCoroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { fetchSettings(mode) observeNotificationSettings(mode) + observeRoomSummaries(roomsWithUserDefinedMode) } fun handleEvents(event: EditDefaultNotificationSettingStateEvents) { @@ -65,6 +77,7 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( return EditDefaultNotificationSettingState( isOneToOne = isOneToOne, mode = mode.value, + roomsWithUserDefinedMode = roomsWithUserDefinedMode.value, eventSink = ::handleEvents ) } @@ -83,6 +96,27 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( .launchIn(this) } + private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) { + roomListService.allRooms() + .summaries + .onEach { + updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode) + } + .launchIn(this) + } + + private fun CoroutineScope.updateRoomsWithUserDefinedMode(summaries: List, roomsWithUserDefinedMode: MutableState>) = launch { + val roomWithUserDefinedRules = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet() + roomsWithUserDefinedMode.value = summaries + .filterIsInstance() + .filter { + val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false + roomWithUserDefinedRules.contains(it.identifier()) && isOneToOne == room.isOneToOne + } + // locale sensitive sorting + .sortedWith(compareBy(Collator.getInstance()){ it.details.name }) + } + 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) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt index 62c708d988..e4d18239cd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt @@ -17,9 +17,11 @@ package io.element.android.features.preferences.impl.notifications.edit import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomSummary data class EditDefaultNotificationSettingState( val isOneToOne: Boolean, val mode: RoomNotificationMode?, + val roomsWithUserDefinedMode: List, val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 4cc95af71f..56cf3acf9a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -17,12 +17,26 @@ package io.element.android.features.preferences.impl.notifications.edit import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.preferences.impl.notifications.NotificationSettingsState +import io.element.android.features.preferences.impl.notifications.NotificationSettingsStateProvider +import io.element.android.features.preferences.impl.notifications.NotificationSettingsView +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings @@ -70,6 +84,46 @@ fun EditDefaultNotificationSettingView( } } } + if(state.roomsWithUserDefinedMode.isNotEmpty()) { + PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) { + LazyColumn { + items(state.roomsWithUserDefinedMode) { summary -> + val subtitle = when (summary.details.notificationMode) { + 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 -> "" + } + val avatarData = AvatarData( + id = summary.identifier(), + name = summary.details.name, + url = summary.details.avatarURLString, + size = AvatarSize.CustomRoomNotificationSetting, + ) + ListItem( + headlineContent = { + Text(text = summary.details.name) + }, + supportingContent = { + Text(text = subtitle) + }, + leadingContent = ListItemContent.Custom { + Avatar(avatarData = avatarData) + } + ) + } + } + } + } + } } +@DayNightPreviews +@Composable +internal fun EditDefaultNotificationSettingViewPreview(@PreviewParameter(EditDefaultNotificationSettingsStateProvider::class) state: EditDefaultNotificationSettingState) = ElementPreview { + EditDefaultNotificationSettingView( + state = state, + onBackPressed = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt new file mode 100644 index 0000000000..738074e9e6 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt @@ -0,0 +1,49 @@ +/* + * 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.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails + +open class EditDefaultNotificationSettingsStateProvider: PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEditDefaultNotificationSettingsState(), + ) +} + +fun anEditDefaultNotificationSettingsState() = EditDefaultNotificationSettingState( + isOneToOne = false, + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + roomsWithUserDefinedMode = listOf(aRoomSummary()), + eventSink = {} +) + +private fun aRoomSummary() = RoomSummary.Filled( + RoomSummaryDetails( + roomId = RoomId("!roomId:domain"), + name = "Room", + avatarURLString = null, + isDirect = false, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + ) +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 45d7780393..b2004ed204 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) { EditRoomDetails(70.dp), NotificationsOptIn(32.dp), + + CustomRoomNotificationSetting(36.dp) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt index 5a81edb052..71d46a2b8e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt @@ -38,4 +38,5 @@ interface NotificationSettingsService { suspend fun setRoomMentionEnabled(enabled: Boolean): Result suspend fun isCallEnabled(): Result suspend fun setCallEnabled(enabled: Boolean): Result + suspend fun getRoomsWithUserDefinedRules(): Result> } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt index a2fffdbdfb..df5c4d577f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt @@ -110,4 +110,9 @@ class RustNotificationSettingsService( notificationSettings.setCallEnabled(enabled) } } + + override suspend fun getRoomsWithUserDefinedRules(): Result> = + runCatching { + notificationSettings.getRoomsWithUserDefinedRules(enabled = true) + } } From b634db177237b63dccbf0fdee06b2a03e8d4148e Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 17 Oct 2023 16:08:08 +0100 Subject: [PATCH 02/41] List user define room notification settings - List user define room notification settings - Add new user defined style of the room notification settings view - Add navigation to expose room notification settings ui to the global settings - Add Progress indicators - Improve error handing --- .../android/appnav/LoggedInFlowNode.kt | 4 + .../android/appnav/room/RoomLoadedFlowNode.kt | 7 + .../preferences/api/PreferencesEntryPoint.kt | 2 + .../preferences/impl/PreferencesFlowNode.kt | 8 +- .../NotificationSettingsEvents.kt | 1 + .../NotificationSettingsPresenter.kt | 25 +++- .../NotificationSettingsState.kt | 2 + .../NotificationSettingsStateProvider.kt | 2 + .../notifications/NotificationSettingsView.kt | 15 ++ .../EditDefaultNotificationSettingNode.kt | 14 +- ...EditDefaultNotificationSettingPresenter.kt | 32 +++-- .../EditDefaultNotificationSettingState.kt | 2 + ...itDefaultNotificationSettingStateEvents.kt | 1 + .../EditDefaultNotificationSettingView.kt | 86 +++++++----- ...efaultNotificationSettingsStateProvider.kt | 2 + .../roomdetails/api/RoomDetailsEntryPoint.kt | 3 + .../impl/DefaultRoomDetailsEntryPoint.kt | 1 + .../roomdetails/impl/RoomDetailsFlowNode.kt | 15 +- .../RoomNotificationSettingsEvents.kt | 2 + .../RoomNotificationSettingsNode.kt | 26 +++- .../RoomNotificationSettingsPresenter.kt | 34 +++-- .../RoomNotificationSettingsState.kt | 4 + .../RoomNotificationSettingsStateProvider.kt | 4 + .../RoomNotificationSettingsView.kt | 25 +++- ...UserDefinedRoomNotificationSettingsView.kt | 128 ++++++++++++++++++ 25 files changed, 375 insertions(+), 70 deletions(-) create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 164c2ae2e4..065cfaafc2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -244,6 +244,10 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onVerifyClicked() { backstack.push(NavTarget.VerifySession) } + + override fun onOpenRoomNotificationSettings(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings)) + } } preferencesEntryPoint.nodeBuilder(this, buildContext) .callback(callback) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt index 34c459f979..2b063a2cbf 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt @@ -142,6 +142,10 @@ class RoomLoadedFlowNode @AssistedInject constructor( val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) } + NavTarget.RoomNotificationSettings -> { + val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) + roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + } } } @@ -154,6 +158,9 @@ class RoomLoadedFlowNode @AssistedInject constructor( @Parcelize data class RoomMemberDetails(val userId: UserId) : NavTarget + + @Parcelize + data object RoomNotificationSettings : NavTarget } @Composable diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 3d1a516593..50a605efe4 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId interface PreferencesEntryPoint : FeatureEntryPoint { @@ -33,5 +34,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenBugReport() fun onVerifyClicked() + fun onOpenRoomNotificationSettings(roomId: RoomId) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 6cf0390db2..8d77757527 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -43,6 +43,7 @@ import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.parcelize.Parcelize @@ -152,8 +153,13 @@ class PreferencesFlowNode @AssistedInject constructor( createNode(buildContext, listOf(notificationSettingsCallback)) } is NavTarget.EditDefaultNotificationSetting -> { + val callback = object : EditDefaultNotificationSettingNode.Callback { + override fun openRoomNotificationSettings(roomId: RoomId) { + plugins().forEach { it.onOpenRoomNotificationSettings(roomId) } + } + } val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) - createNode(buildContext, plugins = listOf(input)) + createNode(buildContext, plugins = listOf(input, callback)) } NavTarget.AdvancedSettings -> { createNode(buildContext) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt index 374b8078ca..9e87675b3a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents { data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents data object FixConfigurationMismatch : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents + data object ClearNotificationChangeError : NotificationSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 697d5887f0..689cca8f66 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -23,7 +23,9 @@ 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.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState 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 @@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor( val systemNotificationsEnabled: MutableState = remember { mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled()) } + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() val appNotificationsEnabled = userPushStore @@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor( fun handleEvents(event: NotificationSettingsEvents) { when (event) { - is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled) - is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled) + is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> { + localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } + is NotificationSettingsEvents.SetCallNotificationsEnabled -> { + localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) NotificationSettingsEvents.ClearConfigurationMismatchError -> { matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) @@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor( NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> { systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() } + NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized } } @@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor( systemNotificationsEnabled = systemNotificationsEnabled.value, appNotificationsEnabled = appNotificationsEnabled.value ), + changeNotificationSettingAction = changeNotificationSettingAction.value, eventSink = ::handleEvents ) } @@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor( ) } - private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch { - notificationSettingsService.setRoomMentionEnabled(enabled) + private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + suspend { + notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow() + }.runCatchingUpdatingState(action) } - private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch { - notificationSettingsService.setCallEnabled(enabled) + private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + suspend { + notificationSettingsService.setCallEnabled(enabled).getOrThrow() + }.runCatchingUpdatingState(action) } private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index cf3cf6e3d0..2b0faa110c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -17,12 +17,14 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Immutable data class NotificationSettingsState( val matrixSettings: MatrixSettings, val appSettings: AppSettings, + val changeNotificationSettingAction: Async, val eventSink: (NotificationSettingsEvents) -> Unit, ) { sealed interface MatrixSettings { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt index 1e653c47e0..cfff59e905 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode open class NotificationSettingsStateProvider : PreviewParameterProvider { @@ -37,5 +38,6 @@ fun aNotificationSettingsState() = NotificationSettingsState( systemNotificationsEnabled = false, appNotificationsEnabled = true, ), + changeNotificationSettingAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index dd3aba842d..c7bcbb573c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -36,6 +36,8 @@ 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.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog 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 @@ -91,6 +93,19 @@ fun NotificationSettingsView( // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, ) } + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) }, + ) + } + else -> Unit + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt index 6c4fd646f4..535203e35e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt @@ -21,12 +21,14 @@ 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.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId @ContributesNode(SessionScope::class) class EditDefaultNotificationSettingNode @AssistedInject constructor( @@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor( presenterFactory: EditDefaultNotificationSettingPresenter.Factory ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openRoomNotificationSettings(roomId: RoomId) + } + data class Inputs( val isOneToOne: Boolean ) : NodeInputs private val inputs = inputs() + private val callbacks = plugins() private val presenter = presenterFactory.create(inputs.isOneToOne) + private fun openRoomNotificationSettings(roomId: RoomId) { + callbacks.forEach { it.openRoomNotificationSettings(roomId) } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() EditDefaultNotificationSettingView( state = state, + openRoomNotificationSettings = { openRoomNotificationSettings(it) }, onBackPressed = ::navigateUp, - modifier = modifier + modifier = modifier, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt index 58140cb35f..79201e27d3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt @@ -25,7 +25,9 @@ import androidx.compose.runtime.rememberCoroutineScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState 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 @@ -57,6 +59,8 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( mutableStateOf(null) } + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val roomsWithUserDefinedMode: MutableState> = remember { mutableStateOf(listOf()) } @@ -70,7 +74,10 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( fun handleEvents(event: EditDefaultNotificationSettingStateEvents) { when (event) { - is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode) + is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> { + localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction) + } + EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = Async.Uninitialized } } @@ -78,6 +85,7 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( isOneToOne = isOneToOne, mode = mode.value, roomsWithUserDefinedMode = roomsWithUserDefinedMode.value, + changeNotificationSettingAction = changeNotificationSettingAction.value, eventSink = ::handleEvents ) } @@ -105,9 +113,13 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( .launchIn(this) } - private fun CoroutineScope.updateRoomsWithUserDefinedMode(summaries: List, roomsWithUserDefinedMode: MutableState>) = launch { - val roomWithUserDefinedRules = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet() - roomsWithUserDefinedMode.value = summaries + private fun CoroutineScope.updateRoomsWithUserDefinedMode( + summaries: List, + roomsWithUserDefinedMode: MutableState> + ) = launch { + val roomWithUserDefinedRules: Set = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet() + + val sortedSummaries = summaries .filterIsInstance() .filter { val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false @@ -115,12 +127,16 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( } // locale sensitive sorting .sortedWith(compareBy(Collator.getInstance()){ it.details.name }) + + roomsWithUserDefinedMode.value = sortedSummaries } - 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) + private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch { + suspend { + // 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).getOrThrow() + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne).getOrThrow() + }.runCatchingUpdatingState(action) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt index e4d18239cd..e8590ec27f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt @@ -16,6 +16,7 @@ package io.element.android.features.preferences.impl.notifications.edit +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomSummary @@ -23,5 +24,6 @@ data class EditDefaultNotificationSettingState( val isOneToOne: Boolean, val mode: RoomNotificationMode?, val roomsWithUserDefinedMode: List, + val changeNotificationSettingAction: Async, val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt index 75c9b6c1a4..f5774f1d78 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt @@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode sealed interface EditDefaultNotificationSettingStateEvents { data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents + data object ClearError: EditDefaultNotificationSettingStateEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 56cf3acf9a..cda19e15bb 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -17,19 +17,17 @@ package io.element.android.features.preferences.impl.notifications.edit import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.features.preferences.impl.notifications.NotificationSettingsState -import io.element.android.features.preferences.impl.notifications.NotificationSettingsStateProvider -import io.element.android.features.preferences.impl.notifications.NotificationSettingsView +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceView @@ -37,6 +35,7 @@ import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings @@ -47,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun EditDefaultNotificationSettingView( state: EditDefaultNotificationSettingState, + openRoomNotificationSettings:(roomId: RoomId) -> Unit, onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { - val title = if(state.isOneToOne) { + val title = if (state.isOneToOne) { CommonStrings.screen_notification_settings_direct_chats } else { CommonStrings.screen_notification_settings_group_chats @@ -65,7 +65,7 @@ fun EditDefaultNotificationSettingView( // 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) { + val categoryTitle = if (state.isOneToOne) { CommonStrings.screen_notification_settings_edit_screen_direct_section_header } else { CommonStrings.screen_notification_settings_edit_screen_group_section_header @@ -84,46 +84,64 @@ fun EditDefaultNotificationSettingView( } } } - if(state.roomsWithUserDefinedMode.isNotEmpty()) { + if (state.roomsWithUserDefinedMode.isNotEmpty()) { PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) { - LazyColumn { - items(state.roomsWithUserDefinedMode) { summary -> - val subtitle = when (summary.details.notificationMode) { - 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 -> "" + state.roomsWithUserDefinedMode.forEach { summary -> + val subtitle = when (summary.details.notificationMode) { + 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) } - val avatarData = AvatarData( - id = summary.identifier(), - name = summary.details.name, - url = summary.details.avatarURLString, - size = AvatarSize.CustomRoomNotificationSetting, - ) - ListItem( - headlineContent = { - Text(text = summary.details.name) - }, - supportingContent = { - Text(text = subtitle) - }, - leadingContent = ListItemContent.Custom { - Avatar(avatarData = avatarData) - } - ) + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + null -> "" } + val avatarData = AvatarData( + id = summary.identifier(), + name = summary.details.name, + url = summary.details.avatarURLString, + size = AvatarSize.CustomRoomNotificationSetting, + ) + ListItem( + headlineContent = { + Text(text = summary.details.name) + }, + supportingContent = { + Text(text = subtitle) + }, + leadingContent = ListItemContent.Custom { + Avatar(avatarData = avatarData) + }, + onClick = { + openRoomNotificationSettings(summary.details.roomId) + } + ) } } } - + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) }, + ) + } + else -> Unit + } } } @DayNightPreviews @Composable -internal fun EditDefaultNotificationSettingViewPreview(@PreviewParameter(EditDefaultNotificationSettingsStateProvider::class) state: EditDefaultNotificationSettingState) = ElementPreview { +internal fun EditDefaultNotificationSettingViewPreview( + @PreviewParameter(EditDefaultNotificationSettingsStateProvider::class) state: EditDefaultNotificationSettingState +) = ElementPreview { EditDefaultNotificationSettingView( state = state, + openRoomNotificationSettings = {}, onBackPressed = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt index 738074e9e6..6910b581bb 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.preferences.impl.notifications.edit import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomSummary @@ -33,6 +34,7 @@ fun anEditDefaultNotificationSettingsState() = EditDefaultNotificationSettingSta isOneToOne = false, mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, roomsWithUserDefinedMode = listOf(aRoomSummary()), + changeNotificationSettingAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 4fa5c18b2e..ed1e6f5c5a 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -33,6 +33,9 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { @Parcelize data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget + + @Parcelize + data object RoomNotificationSettings : InitialTarget } data class Inputs(val initialElement: InitialTarget) : NodeInputs diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index be6b915212..8cd6cb54d6 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -42,4 +42,5 @@ class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint internal fun InitialTarget.toNavTarget() = when (this) { is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) + is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 675ef7de60..5d7539e626 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -68,7 +68,13 @@ class RoomDetailsFlowNode @AssistedInject constructor( data object InviteMembers : NavTarget @Parcelize - object RoomNotificationSettings : NavTarget + data class RoomNotificationSettings( + /** + * When presented from oursite the context of the room, the rooms settings UI is different. + * Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0 + */ + val showUserDefinedSettingStyle: Boolean + ) : NavTarget @Parcelize data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget @@ -91,7 +97,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( } override fun openRoomNotificationSettings() { - backstack.push(NavTarget.RoomNotificationSettings) + backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false)) } } createNode(buildContext, listOf(roomDetailsCallback)) @@ -118,8 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( createNode(buildContext) } - NavTarget.RoomNotificationSettings -> { - createNode(buildContext) + is NavTarget.RoomNotificationSettings -> { + val plugins = listOf(RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle)) + createNode(buildContext, plugins) } is NavTarget.RoomMemberDetails -> { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt index bbe756b154..c69896a98b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt @@ -21,4 +21,6 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode sealed interface RoomNotificationSettingsEvents { data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents + data object DeleteCustomNotification: RoomNotificationSettingsEvents + data object ClearError: RoomNotificationSettingsEvents } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index 224f850e28..cb0168a42b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -26,6 +26,8 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen 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.RoomScope import io.element.android.services.analytics.api.AnalyticsService @@ -37,6 +39,12 @@ class RoomNotificationSettingsNode @AssistedInject constructor( private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { + data class RoomNotificationSettingInput( + val showUserDefinedSettingStyle: Boolean + ) : NodeInputs + + private val inputs = inputs() + init { lifecycle.subscribe( onResume = { @@ -48,10 +56,18 @@ class RoomNotificationSettingsNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { val state = presenter.present() - RoomNotificationSettingsView( - state = state, - modifier = modifier, - onBackPressed = this::navigateUp, - ) + if(inputs.showUserDefinedSettingStyle) { + UserDefinedRoomNotificationSettingsView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp, + ) + } else { + RoomNotificationSettingsView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp, + ) + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt index a6e2477bcc..9fb0c8b1c8 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt @@ -22,9 +22,12 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -42,16 +45,18 @@ class RoomNotificationSettingsPresenter @Inject constructor( private val room: MatrixRoom, private val notificationSettingsService: NotificationSettingsService, ) : Presenter { - @Composable override fun present(): RoomNotificationSettingsState { val defaultRoomNotificationMode: MutableState = rememberSaveable { mutableStateOf(null) } val localCoroutineScope = rememberCoroutineScope() + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val deleteCustomNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } LaunchedEffect(Unit) { getDefaultRoomNotificationMode(defaultRoomNotificationMode) + room.updateRoomNotificationSettings() observeNotificationSettings() } @@ -60,23 +65,32 @@ class RoomNotificationSettingsPresenter @Inject constructor( fun handleEvents(event: RoomNotificationSettingsEvents) { when (event) { is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> { - localCoroutineScope.setRoomNotificationMode(event.mode) + localCoroutineScope.setRoomNotificationMode(event.mode, changeNotificationSettingAction) } is RoomNotificationSettingsEvents.SetNotificationMode -> { if (event.isDefault) { - localCoroutineScope.restoreDefaultRoomNotificationMode() + localCoroutineScope.restoreDefaultRoomNotificationMode(changeNotificationSettingAction) } else { defaultRoomNotificationMode.value?.let { - localCoroutineScope.setRoomNotificationMode(it) + localCoroutineScope.setRoomNotificationMode(it, changeNotificationSettingAction) } } } + is RoomNotificationSettingsEvents.DeleteCustomNotification -> { + localCoroutineScope.restoreDefaultRoomNotificationMode(deleteCustomNotificationSettingAction) + } + RoomNotificationSettingsEvents.ClearError -> { + changeNotificationSettingAction.value = Async.Uninitialized + } } } return RoomNotificationSettingsState( + roomName = room.displayName, roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), defaultRoomNotificationMode = defaultRoomNotificationMode.value, + changeNotificationSettingAction = changeNotificationSettingAction.value, + deleteCustomNotificationSettingAction = deleteCustomNotificationSettingAction.value, eventSink = ::handleEvents, ) } @@ -98,11 +112,15 @@ class RoomNotificationSettingsPresenter @Inject constructor( ).getOrThrow() } - private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch { - notificationSettingsService.setRoomNotificationMode(room.roomId, mode) + private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch { + suspend { + notificationSettingsService.setRoomNotificationMode(room.roomId, mode).getOrThrow() + }.runCatchingUpdatingState(action) } - private fun CoroutineScope.restoreDefaultRoomNotificationMode() = launch { - notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) + private fun CoroutineScope.restoreDefaultRoomNotificationMode(action: MutableState>) = launch { + suspend { + notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId).getOrThrow() + }.runCatchingUpdatingState(action) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt index 04742781b5..a7c5c3b883 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt @@ -16,11 +16,15 @@ package io.element.android.features.roomdetails.impl.notificationsettings +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings data class RoomNotificationSettingsState( + val roomName: String, val roomNotificationSettings: RoomNotificationSettings?, val defaultRoomNotificationMode: RoomNotificationMode?, + val changeNotificationSettingAction: Async, + val deleteCustomNotificationSettingAction: Async, val eventSink: (RoomNotificationSettingsEvents) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt index df1dd7977b..220d82f6b5 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings @@ -24,10 +25,13 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider< override val values: Sequence get() = sequenceOf( RoomNotificationSettingsState( + roomName = "Room 1", RoomNotificationSettings( mode = RoomNotificationMode.MUTE, isDefault = true), RoomNotificationMode.ALL_MESSAGES, + changeNotificationSettingAction = Async.Uninitialized, + deleteCustomNotificationSettingAction = Async.Uninitialized, eventSink = { }, ), ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 5cf4adb84f..92bfe0c084 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -18,7 +18,6 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,8 +29,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton +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 @@ -45,7 +47,6 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalLayoutApi::class) @Composable fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, @@ -74,7 +75,6 @@ fun RoomNotificationSettingsView( null -> "" } - PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { PreferenceSwitch( isChecked = state.roomNotificationSettings?.isDefault.orTrue(), @@ -102,6 +102,16 @@ fun RoomNotificationSettingsView( ) } } + + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state) + } + else -> Unit + } } } } @@ -144,6 +154,15 @@ fun RoomNotificationSettingsOptions( } } +@Composable +fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState) { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearError) }, + ) +} + @DayNightPreviews @Composable internal fun RoomNotificationSettingsPreview( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt new file mode 100644 index 0000000000..6435e64497 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -0,0 +1,128 @@ +/* + * 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.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +@Composable +fun UserDefinedRoomNotificationSettingsView( + state: RoomNotificationSettingsState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = { + UserDefinedRoomNotificationSettingsTopBar( + roomName = state.roomName, + onBackPressed = { onBackPressed() } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.roomNotificationSettings != null) { + RoomNotificationSettingsOptions( + selected = state.roomNotificationSettings.mode, + enabled = !state.roomNotificationSettings.isDefault, + onOptionSelected = { + state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) + }, + ) + } + + PreferenceText( + title = stringResource(R.string.screen_room_notification_settings_edit_remove_setting), + icon = ImageVector.vectorResource(VectorIcons.Delete), + tintColor = MaterialTheme.colorScheme.error, + onClick = { + state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification) + } + ) + + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state) + } + else -> Unit + } + + when (state.deleteCustomNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state) + } + is Async.Success -> { + LaunchedEffect(state.deleteCustomNotificationSettingAction) { + onBackPressed() + } + } + else -> Unit + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserDefinedRoomNotificationSettingsTopBar( + roomName: String, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = roomName, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) +} From 72e7d92571336712512dea21cfe5c761ffe7fe82 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 17 Oct 2023 16:08:35 +0100 Subject: [PATCH 03/41] Add tests --- ...faultNotificationSettingsPresenterTests.kt | 48 +++++++++++++++-- .../RoomNotificationSettingsPresenterTests.kt | 51 ++++++++++++++++--- .../FakeNotificationSettingsService.kt | 13 ++++- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt index e8c9ff4fe5..8376aa95c3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt @@ -23,7 +23,13 @@ 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.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -32,7 +38,7 @@ class EditDefaultNotificationSettingsPresenterTests { @Test fun `present - ensures initial state is correct`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() - val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) + val presenter = createPresenter(notificationSettingsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -47,10 +53,32 @@ class EditDefaultNotificationSettingsPresenterTests { } } + @Test + fun `present - ensure list of rooms with user defined mode`() = runTest { + val room = FakeMatrixRoom() + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.ALL_MESSAGES, + initialRoomModeIsDefault = false + ) + val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService).apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val roomListService = FakeRoomListService() + val presenter = createPresenter(notificationSettingsService, roomListService, matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail(notificationMode = RoomNotificationMode.ALL_MESSAGES)))) + val loadedState = consumeItemsUntilPredicate { state -> + state.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES } + }.last() + Truth.assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue() + } + } + @Test fun `present - edit default notification setting`() = runTest { - val notificationSettingsService = FakeNotificationSettingsService() - val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) + val presenter = createPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -61,4 +89,18 @@ class EditDefaultNotificationSettingsPresenterTests { Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) } } + + private fun createPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + roomListService: FakeRoomListService = FakeRoomListService(), + matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) + ): EditDefaultNotificationSettingPresenter { + return EditDefaultNotificationSettingPresenter( + notificationSettingsService = notificationSettingsService, + isOneToOne = false, + roomListService = roomListService, + matrixClient = matrixClient + ) + } + } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt index a1b7831ce2..7d4c83d518 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt @@ -24,14 +24,17 @@ import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.A_ROOM_ID +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 RoomNotificationSettingsPresenterTests { @Test fun `present - initial state is created from room info`() = runTest { - val presenter = aNotificationPresenter + val presenter = aNotificationPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -44,7 +47,7 @@ class RoomNotificationSettingsPresenterTests { @Test fun `present - notification mode changed`() = runTest { - val presenter = aNotificationPresenter + val presenter = aNotificationPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -56,27 +59,61 @@ class RoomNotificationSettingsPresenterTests { } } + @Test + fun `present - observe notification mode changed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = aNotificationPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + val updatedState = consumeItemsUntilPredicate() { + it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + } + } + + + @Test + fun `present - notification settings set custom`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = aNotificationPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + val defaultState = consumeItemsUntilPredicate(timeout = 8000.milliseconds) { + it.roomNotificationSettings?.isDefault == false + }.last() + Truth.assertThat(defaultState.roomNotificationSettings?.isDefault).isFalse() + } + } + @Test fun `present - notification settings restore default`() = runTest { - val presenter = aNotificationPresenter + val presenter = aNotificationPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) - val defaultState = consumeItemsUntilPredicate { + val defaultState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) { it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) } } - private val aNotificationPresenter: RoomNotificationSettingsPresenter get() { - val room = aMatrixRoom() + private fun aNotificationPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() + ): RoomNotificationSettingsPresenter{ + val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) return RoomNotificationSettingsPresenter( room = room, - notificationSettingsService = room.notificationSettingsService + notificationSettingsService = notificationSettingsService ) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt index 77592d6d1f..2b65857053 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt @@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow class FakeNotificationSettingsService( initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE, + initialRoomModeIsDefault: Boolean = true, initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, @@ -37,6 +39,7 @@ class FakeNotificationSettingsService( private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode private var roomNotificationMode: RoomNotificationMode = initialRoomMode + private var roomNotificationModeIsDefault: Boolean = initialRoomModeIsDefault private var callNotificationsEnabled = false private var atRoomNotificationsEnabled = false override val notificationSettingsChangeFlow: SharedFlow @@ -45,8 +48,8 @@ class FakeNotificationSettingsService( override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result { return Result.success( RoomNotificationSettings( - mode = roomNotificationMode, - isDefault = roomNotificationMode == defaultEncryptedGroupRoomNotificationMode + mode = if(roomNotificationModeIsDefault) defaultEncryptedGroupRoomNotificationMode else roomNotificationMode, + isDefault = roomNotificationModeIsDefault ) ) } @@ -86,12 +89,14 @@ class FakeNotificationSettingsService( } override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result { + roomNotificationModeIsDefault = false roomNotificationMode = mode _notificationSettingsStateFlow.emit(Unit) return Result.success(Unit) } override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result { + roomNotificationModeIsDefault = true roomNotificationMode = defaultEncryptedGroupRoomNotificationMode _notificationSettingsStateFlow.emit(Unit) return Result.success(Unit) @@ -122,4 +127,8 @@ class FakeNotificationSettingsService( callNotificationsEnabled = enabled return Result.success(Unit) } + + override suspend fun getRoomsWithUserDefinedRules(): Result> { + return Result.success(if (roomNotificationModeIsDefault) listOf() else listOf(A_ROOM_ID.value)) + } } From aece3dcc7b612d2b0b6ae0d0d0397c361678f5d0 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 18 Oct 2023 21:44:37 +0100 Subject: [PATCH 04/41] Fix switch and radio buttons toggling to invalid intermediate states. --- .../RoomNotificationSettingsEvents.kt | 3 +- .../RoomNotificationSettingsPresenter.kt | 108 ++++++++++++++---- .../RoomNotificationSettingsState.kt | 16 ++- .../RoomNotificationSettingsStateProvider.kt | 12 +- .../RoomNotificationSettingsView.kt | 32 ++++-- ...UserDefinedRoomNotificationSettingsView.kt | 13 ++- 6 files changed, 134 insertions(+), 50 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt index c69896a98b..8b3c25d267 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt @@ -22,5 +22,6 @@ sealed interface RoomNotificationSettingsEvents { data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents data object DeleteCustomNotification: RoomNotificationSettingsEvents - data object ClearError: RoomNotificationSettingsEvents + data object ClearSetNotificationError: RoomNotificationSettingsEvents + data object ClearRestoreDefaultError: RoomNotificationSettingsEvents } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt index 9fb0c8b1c8..3086d878c4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt @@ -19,8 +19,6 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -31,7 +29,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomNotificationMode -import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce @@ -51,76 +49,136 @@ class RoomNotificationSettingsPresenter @Inject constructor( mutableStateOf(null) } val localCoroutineScope = rememberCoroutineScope() - val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } - val deleteCustomNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val setNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val restoreDefaultAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + + val roomNotificationSettings: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + // We store state of which mode the user has set via the notification service before the new push settings have been updated. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the radio buttons ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingRoomNotificationMode: MutableState = remember { + mutableStateOf(null) + } + + // We store state of whether the user has set the notifications settings to default or custom via the notification service. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the switch ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingSetDefault: MutableState = remember { + mutableStateOf(null) + } LaunchedEffect(Unit) { getDefaultRoomNotificationMode(defaultRoomNotificationMode) - room.updateRoomNotificationSettings() - observeNotificationSettings() + fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) + observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) } - val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() - fun handleEvents(event: RoomNotificationSettingsEvents) { when (event) { is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> { - localCoroutineScope.setRoomNotificationMode(event.mode, changeNotificationSettingAction) + localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) } is RoomNotificationSettingsEvents.SetNotificationMode -> { if (event.isDefault) { - localCoroutineScope.restoreDefaultRoomNotificationMode(changeNotificationSettingAction) + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) } else { defaultRoomNotificationMode.value?.let { - localCoroutineScope.setRoomNotificationMode(it, changeNotificationSettingAction) + localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) } } } is RoomNotificationSettingsEvents.DeleteCustomNotification -> { - localCoroutineScope.restoreDefaultRoomNotificationMode(deleteCustomNotificationSettingAction) + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) } - RoomNotificationSettingsEvents.ClearError -> { - changeNotificationSettingAction.value = Async.Uninitialized + RoomNotificationSettingsEvents.ClearSetNotificationError -> { + setNotificationSettingAction.value = Async.Uninitialized + } + RoomNotificationSettingsEvents.ClearRestoreDefaultError -> { + restoreDefaultAction.value = Async.Uninitialized } } } return RoomNotificationSettingsState( roomName = room.displayName, - roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), + roomNotificationSettings = roomNotificationSettings.value, + pendingRoomNotificationMode = pendingRoomNotificationMode.value, + pendingSetDefault = pendingSetDefault.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value, - changeNotificationSettingAction = changeNotificationSettingAction.value, - deleteCustomNotificationSettingAction = deleteCustomNotificationSettingAction.value, + setNotificationSettingAction = setNotificationSettingAction.value, + restoreDefaultAction = restoreDefaultAction.value, eventSink = ::handleEvents, ) } @OptIn(FlowPreview::class) - private fun CoroutineScope.observeNotificationSettings() { + private fun CoroutineScope.observeNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) { notificationSettingsService.notificationSettingsChangeFlow .debounce(0.5.seconds) .onEach { - room.updateRoomNotificationSettings() + fetchNotificationSettings(pendingModeState, roomNotificationSettings) } .launchIn(this) } - private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState) = launch { + private fun CoroutineScope.fetchNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) = launch { + suspend { + pendingModeState.value = null + notificationSettingsService.getRoomNotificationSettings(room.roomId, room.isEncrypted, room.isOneToOne).getOrThrow() + }.runCatchingUpdatingState(roomNotificationSettings) + } + + private fun CoroutineScope.getDefaultRoomNotificationMode( + defaultRoomNotificationMode: MutableState + ) = launch { defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( room.isEncrypted, room.isOneToOne ).getOrThrow() } - private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch { + private fun CoroutineScope.setRoomNotificationMode( + mode: RoomNotificationMode, + pendingModeState: MutableState, + pendingDefaultState: MutableState, + action: MutableState> + ) = launch { suspend { - notificationSettingsService.setRoomNotificationMode(room.roomId, mode).getOrThrow() + pendingModeState.value = mode + pendingDefaultState.value = false + val result = notificationSettingsService.setRoomNotificationMode(room.roomId, mode) + if (result.isFailure) { + pendingModeState.value = null + pendingDefaultState.value = null + } + result.getOrThrow() }.runCatchingUpdatingState(action) } - private fun CoroutineScope.restoreDefaultRoomNotificationMode(action: MutableState>) = launch { + private fun CoroutineScope.restoreDefaultRoomNotificationMode( + action: MutableState>, + pendingDefaultState: MutableState + ) = launch { suspend { - notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId).getOrThrow() + pendingDefaultState.value = true + val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) + if (result.isFailure) { + pendingDefaultState.value = null + } + result.getOrThrow() }.runCatchingUpdatingState(action) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt index a7c5c3b883..1f8c7e4ce8 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt @@ -22,9 +22,19 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings data class RoomNotificationSettingsState( val roomName: String, - val roomNotificationSettings: RoomNotificationSettings?, + val roomNotificationSettings: Async, + val pendingRoomNotificationMode: RoomNotificationMode?, + val pendingSetDefault: Boolean?, val defaultRoomNotificationMode: RoomNotificationMode?, - val changeNotificationSettingAction: Async, - val deleteCustomNotificationSettingAction: Async, + val setNotificationSettingAction: Async, + val restoreDefaultAction: Async, val eventSink: (RoomNotificationSettingsEvents) -> Unit ) + +val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() { + return pendingRoomNotificationMode ?: roomNotificationSettings.dataOrNull()?.mode +} + +val RoomNotificationSettingsState.displayIsDefault: Boolean? get() { + return pendingSetDefault ?: roomNotificationSettings.dataOrNull()?.isDefault +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt index 220d82f6b5..961909f933 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt @@ -26,12 +26,14 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider< get() = sequenceOf( RoomNotificationSettingsState( roomName = "Room 1", - RoomNotificationSettings( + Async.Success(RoomNotificationSettings( mode = RoomNotificationMode.MUTE, - isDefault = true), - RoomNotificationMode.ALL_MESSAGES, - changeNotificationSettingAction = Async.Uninitialized, - deleteCustomNotificationSettingAction = Async.Uninitialized, + isDefault = true)), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = Async.Uninitialized, + restoreDefaultAction = Async.Uninitialized, eventSink = { }, ), ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 92bfe0c084..6f440958b2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -75,27 +75,29 @@ fun RoomNotificationSettingsView( null -> "" } + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { PreferenceSwitch( - isChecked = state.roomNotificationSettings?.isDefault.orTrue(), + isChecked = state.displayIsDefault.orTrue(), onCheckedChange = { state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) }, title = "Match default setting", subtitle = subtitle, - enabled = state.roomNotificationSettings != null + enabled = roomNotificationSettings != null ) PreferenceText( title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), - enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault, + enabled = !state.displayIsDefault.orTrue(), ) - if (state.roomNotificationSettings != null) { + if (roomNotificationSettings != null && state.displayNotificationMode != null) { RoomNotificationSettingsOptions( - selected = state.roomNotificationSettings.mode, - enabled = !state.roomNotificationSettings.isDefault, + selected = state.displayNotificationMode, + enabled = !state.displayIsDefault.orTrue(), onOptionSelected = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, @@ -103,12 +105,22 @@ fun RoomNotificationSettingsView( } } - when (state.changeNotificationSettingAction) { + when (state.setNotificationSettingAction) { is Async.Loading -> { ProgressDialog() } is Async.Failure -> { - ShowChangeNotificationSettingError(state) + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError) + } + else -> Unit + } + + when (state.restoreDefaultAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError) } else -> Unit } @@ -155,11 +167,11 @@ fun RoomNotificationSettingsOptions( } @Composable -fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState) { +fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) { ErrorDialog( title = stringResource(CommonStrings.dialog_title_error), content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), - onDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearError) }, + onDismiss = { state.eventSink(event) }, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 6435e64497..aa8a2a739d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -62,10 +62,11 @@ fun UserDefinedRoomNotificationSettingsView( .consumeWindowInsets(padding), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (state.roomNotificationSettings != null) { + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + if (roomNotificationSettings != null && state.displayNotificationMode != null) { RoomNotificationSettingsOptions( - selected = state.roomNotificationSettings.mode, - enabled = !state.roomNotificationSettings.isDefault, + selected = state.displayNotificationMode, + enabled = roomNotificationSettings.isDefault, onOptionSelected = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, @@ -81,7 +82,7 @@ fun UserDefinedRoomNotificationSettingsView( } ) - when (state.changeNotificationSettingAction) { + when (state.setNotificationSettingAction) { is Async.Loading -> { ProgressDialog() } @@ -91,7 +92,7 @@ fun UserDefinedRoomNotificationSettingsView( else -> Unit } - when (state.deleteCustomNotificationSettingAction) { + when (state.restoreDefaultAction) { is Async.Loading -> { ProgressDialog() } @@ -99,7 +100,7 @@ fun UserDefinedRoomNotificationSettingsView( ShowChangeNotificationSettingError(state) } is Async.Success -> { - LaunchedEffect(state.deleteCustomNotificationSettingAction) { + LaunchedEffect(state.restoreDefaultAction) { onBackPressed() } } From 7e7e95c8e21d8f76a8b63259e6e92a7f29744a0a Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 18 Oct 2023 22:30:29 +0100 Subject: [PATCH 05/41] Fix enabled state and ClearError events. --- .../UserDefinedRoomNotificationSettingsView.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index aa8a2a739d..75642f4e15 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -32,13 +32,14 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.R import io.element.android.libraries.architecture.Async -import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.CommonDrawables @Composable fun UserDefinedRoomNotificationSettingsView( @@ -66,7 +67,7 @@ fun UserDefinedRoomNotificationSettingsView( if (roomNotificationSettings != null && state.displayNotificationMode != null) { RoomNotificationSettingsOptions( selected = state.displayNotificationMode, - enabled = roomNotificationSettings.isDefault, + enabled = !state.displayIsDefault.orTrue(), onOptionSelected = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, @@ -75,7 +76,7 @@ fun UserDefinedRoomNotificationSettingsView( PreferenceText( title = stringResource(R.string.screen_room_notification_settings_edit_remove_setting), - icon = ImageVector.vectorResource(VectorIcons.Delete), + icon = ImageVector.vectorResource(CommonDrawables.ic_compound_delete), tintColor = MaterialTheme.colorScheme.error, onClick = { state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification) @@ -87,7 +88,7 @@ fun UserDefinedRoomNotificationSettingsView( ProgressDialog() } is Async.Failure -> { - ShowChangeNotificationSettingError(state) + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError) } else -> Unit } @@ -97,7 +98,7 @@ fun UserDefinedRoomNotificationSettingsView( ProgressDialog() } is Async.Failure -> { - ShowChangeNotificationSettingError(state) + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError) } is Async.Success -> { LaunchedEffect(state.restoreDefaultAction) { From 4b43168913dd35462f1119f913fcd2a9a6db1e60 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 19 Oct 2023 16:17:57 +0100 Subject: [PATCH 06/41] Fix tests and lint --- ...efaultNotificationSettingStateProvider.kt} | 2 +- .../EditDefaultNotificationSettingView.kt | 2 +- ...faultNotificationSettingsPresenterTests.kt | 8 +-- .../RoomNotificationSettingsOptions.kt | 43 ++++++++++++++ .../RoomNotificationSettingsView.kt | 33 +---------- .../ShowChangeNotificationSettingError.kt | 31 ++++++++++ ...edRoomNotificationSettingsStateProvider.kt | 42 +++++++++++++ ...UserDefinedRoomNotificationSettingsView.kt | 13 +++- .../RoomNotificationSettingsPresenterTests.kt | 59 +++++++++++++------ .../FakeNotificationSettingsService.kt | 18 ++++-- 10 files changed, 190 insertions(+), 61 deletions(-) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/{EditDefaultNotificationSettingsStateProvider.kt => EditDefaultNotificationSettingStateProvider.kt} (93%) create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt create mode 100644 features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt similarity index 93% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt index 6910b581bb..3446c24efe 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails -open class EditDefaultNotificationSettingsStateProvider: PreviewParameterProvider { +open class EditDefaultNotificationSettingStateProvider: PreviewParameterProvider { override val values: Sequence get() = sequenceOf( anEditDefaultNotificationSettingsState(), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index bf82de7251..94f5a6b053 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -136,7 +136,7 @@ fun EditDefaultNotificationSettingView( @PreviewsDayNight @Composable internal fun EditDefaultNotificationSettingViewPreview( - @PreviewParameter(EditDefaultNotificationSettingsStateProvider::class) state: EditDefaultNotificationSettingState + @PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState ) = ElementPreview { EditDefaultNotificationSettingView( state = state, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt index 8376aa95c3..2269df46e1 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt @@ -38,7 +38,7 @@ class EditDefaultNotificationSettingsPresenterTests { @Test fun `present - ensures initial state is correct`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() - val presenter = createPresenter(notificationSettingsService) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -64,7 +64,7 @@ class EditDefaultNotificationSettingsPresenterTests { givenGetRoomResult(A_ROOM_ID, room) } val roomListService = FakeRoomListService() - val presenter = createPresenter(notificationSettingsService, roomListService, matrixClient) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService, matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -78,7 +78,7 @@ class EditDefaultNotificationSettingsPresenterTests { @Test fun `present - edit default notification setting`() = runTest { - val presenter = createPresenter() + val presenter = createEditDefaultNotificationSettingPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -90,7 +90,7 @@ class EditDefaultNotificationSettingsPresenterTests { } } - private fun createPresenter( + private fun createEditDefaultNotificationSettingPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), roomListService: FakeRoomListService = FakeRoomListService(), matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt new file mode 100644 index 0000000000..878632db9c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt @@ -0,0 +1,43 @@ +/* + * 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.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +@Composable +fun RoomNotificationSettingsOptions( + selected: RoomNotificationMode?, + enabled: Boolean, + modifier: Modifier = Modifier, + onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {}, +) { + val items = roomNotificationSettingsItems() + Column(modifier = modifier.selectableGroup()) { + items.forEach { item -> + RoomNotificationSettingsOption( + roomNotificationSettingsItem = item, + isSelected = selected == item.mode, + onOptionSelected = onOptionSelected, + enabled = enabled + ) + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index b0c82d291c..3d95e4ec20 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -33,12 +32,11 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton -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.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -146,35 +144,6 @@ private fun RoomNotificationSettingsTopBar( ) } -@Composable -fun RoomNotificationSettingsOptions( - selected: RoomNotificationMode?, - enabled: Boolean, - modifier: Modifier = Modifier, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {}, -) { - val items = roomNotificationSettingsItems() - Column(modifier = modifier.selectableGroup()) { - items.forEach { item -> - RoomNotificationSettingsOption( - roomNotificationSettingsItem = item, - isSelected = selected == item.mode, - onOptionSelected = onOptionSelected, - enabled = enabled - ) - } - } -} - -@Composable -fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) { - ErrorDialog( - title = stringResource(CommonStrings.dialog_title_error), - content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), - onDismiss = { state.eventSink(event) }, - ) -} - @PreviewsDayNight @Composable internal fun RoomNotificationSettingsPreview( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt new file mode 100644 index 0000000000..4b99976988 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(event) }, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt new file mode 100644 index 0000000000..76714a82d0 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.roomdetails.impl.notificationsettings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings + +internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RoomNotificationSettingsState( + roomName = "Room 1", + Async.Success( + RoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = false) + ), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = Async.Uninitialized, + restoreDefaultAction = Async.Uninitialized, + eventSink = { }, + ), + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 75642f4e15..6afde5bbf9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.R import io.element.android.libraries.architecture.Async @@ -36,6 +37,8 @@ import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -113,7 +116,7 @@ fun UserDefinedRoomNotificationSettingsView( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun UserDefinedRoomNotificationSettingsTopBar( +private fun UserDefinedRoomNotificationSettingsTopBar( roomName: String, modifier: Modifier = Modifier, onBackPressed: () -> Unit = {}, @@ -128,3 +131,11 @@ fun UserDefinedRoomNotificationSettingsTopBar( navigationIcon = { BackButton(onClick = onBackPressed) }, ) } + +@PreviewsDayNight +@Composable +internal fun UserDefinedRoomNotificationSettingsPreview( + @PreviewParameter(UserDefinedRoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState +) = ElementPreview { + UserDefinedRoomNotificationSettingsView(state) +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt index 7d4c83d518..7f4ea57cf1 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt @@ -25,6 +25,7 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest @@ -34,12 +35,12 @@ import kotlin.time.Duration.Companion.milliseconds class RoomNotificationSettingsPresenterTests { @Test fun `present - initial state is created from room info`() = runTest { - val presenter = aNotificationPresenter() + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - Truth.assertThat(initialState.roomNotificationSettings).isNull() + Truth.assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull() Truth.assertThat(initialState.defaultRoomNotificationMode).isNull() cancelAndIgnoreRemainingEvents() } @@ -47,53 +48,74 @@ class RoomNotificationSettingsPresenterTests { @Test fun `present - notification mode changed`() = runTest { - val presenter = aNotificationPresenter() + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) val updatedState = consumeItemsUntilPredicate { - it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() - Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() } } @Test fun `present - observe notification mode changed`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() - val presenter = aNotificationPresenter(notificationSettingsService) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) - val updatedState = consumeItemsUntilPredicate() { - it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() - Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) } } @Test - fun `present - notification settings set custom`() = runTest { + fun `present - notification settings set custom failed`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() - val presenter = aNotificationPresenter(notificationSettingsService) + notificationSettingsService.givenSetNotificationModeError(A_THROWABLE) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) - val defaultState = consumeItemsUntilPredicate(timeout = 8000.milliseconds) { - it.roomNotificationSettings?.isDefault == false + val states = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.isDefault == false + } + states.forEach { + Truth.assertThat(it.roomNotificationSettings.dataOrNull()?.isDefault).isTrue() + Truth.assertThat(it.pendingSetDefault).isNull() + } + } + } + + @Test + fun `present - notification settings set custom`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + val defaultState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.isDefault == false }.last() - Truth.assertThat(defaultState.roomNotificationSettings?.isDefault).isFalse() + Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse() } } @Test fun `present - notification settings restore default`() = runTest { - val presenter = aNotificationPresenter() + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -101,13 +123,14 @@ class RoomNotificationSettingsPresenterTests { initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) val defaultState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) { - it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() - Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() } } - private fun aNotificationPresenter( + private fun createRoomNotificationSettingsPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() ): RoomNotificationSettingsPresenter{ val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt index 2b65857053..7d7fb1a36a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt @@ -42,6 +42,7 @@ class FakeNotificationSettingsService( private var roomNotificationModeIsDefault: Boolean = initialRoomModeIsDefault private var callNotificationsEnabled = false private var atRoomNotificationsEnabled = false + private var setNotificationModeError: Throwable? = null override val notificationSettingsChangeFlow: SharedFlow get() = _notificationSettingsStateFlow @@ -89,10 +90,15 @@ class FakeNotificationSettingsService( } override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result { - roomNotificationModeIsDefault = false - roomNotificationMode = mode - _notificationSettingsStateFlow.emit(Unit) - return Result.success(Unit) + val error = setNotificationModeError + return if (error != null) { + Result.failure(error) + } else { + roomNotificationModeIsDefault = false + roomNotificationMode = mode + _notificationSettingsStateFlow.emit(Unit) + Result.success(Unit) + } } override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result { @@ -131,4 +137,8 @@ class FakeNotificationSettingsService( override suspend fun getRoomsWithUserDefinedRules(): Result> { return Result.success(if (roomNotificationModeIsDefault) listOf() else listOf(A_ROOM_ID.value)) } + + fun givenSetNotificationModeError(throwable: Throwable?) { + setNotificationModeError = throwable + } } From 919e0d730107be88bfb151c9f4de961f7239a0b1 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 19 Oct 2023 15:47:25 +0000 Subject: [PATCH 07/41] Update screenshots --- ...ultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png | 3 +++ ...ultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png | 3 +++ ...ull_EditUserProfileView-D-10_10_null_0,NEXUS_5,1.0,en].png} | 0 ...ull_EditUserProfileView-N-10_11_null_0,NEXUS_5,1.0,en].png} | 0 ...user_null_UserPreferences-D-9_9_null_0,NEXUS_5,1.0,en].png} | 0 ...user_null_UserPreferences-D-9_9_null_1,NEXUS_5,1.0,en].png} | 0 ...user_null_UserPreferences-D-9_9_null_2,NEXUS_5,1.0,en].png} | 0 ...ser_null_UserPreferences-N-9_10_null_0,NEXUS_5,1.0,en].png} | 0 ...ser_null_UserPreferences-N-9_10_null_1,NEXUS_5,1.0,en].png} | 0 ...ser_null_UserPreferences-N-9_10_null_2,NEXUS_5,1.0,en].png} | 0 ...edRoomNotificationSettings-D-5_5_null_0,NEXUS_5,1.0,en].png | 3 +++ ...edRoomNotificationSettings-N-5_6_null_0,NEXUS_5,1.0,en].png | 3 +++ ...ts.avatar_null_Avatars_Avatar_0_null_48,NEXUS_5,1.0,en].png | 3 +++ ...ts.avatar_null_Avatars_Avatar_0_null_49,NEXUS_5,1.0,en].png | 3 +++ ...ts.avatar_null_Avatars_Avatar_0_null_50,NEXUS_5,1.0,en].png | 3 +++ 15 files changed, 21 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-9_9_null_0,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-10_10_null_0,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-9_10_null_0,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-10_11_null_0,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_0,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_0,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_1,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_1,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_2,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_2,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_0,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_0,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_1,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_1,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_2,NEXUS_5,1.0,en].png => ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_2,NEXUS_5,1.0,en].png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-D-5_5_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-N-5_6_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_48,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_49,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_50,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1d4750eecb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1172d81259b7e7b694f428c5144d96c8a7748134f5b4f515a5c25c58ff8e5e1b +size 31318 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c423fe4456 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e835ab12c0a77c4a4dcaf8bac0d78ed5ec9a0ab8eef669c7d814317ab06abd27 +size 28443 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-9_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-10_10_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-9_9_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-10_10_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-9_10_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-10_11_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-9_10_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-10_11_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-8_8_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-D-9_9_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-8_9_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user_null_UserPreferences-N-9_10_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-D-5_5_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-D-5_5_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d26299f528 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-D-5_5_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:372ff5eca3c8c9de6bb15d5ae9a55f1d8ec124c7e454d411be6bd28f78d3aed0 +size 24324 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-N-5_6_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-N-5_6_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f8beb00ae9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_UserDefinedRoomNotificationSettings-N-5_6_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3795832f75ba48402f228dbd5be5dc05b10dab00242922fb861c0f77fcd391e4 +size 22853 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_48,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_48,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a3d0076294 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_48,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2619a9a70eca12d5931ebc21adce48a1aed8383f35908bb09546f44b40f04543 +size 23094 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_49,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_49,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..81b9668b0f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_49,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d7d71d58b250bdec2d9b6e1ed46c5e3ffd98ffeefcaf4267b9979970a89750b +size 22226 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_50,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_50,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5cb8c0a0bf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_50,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7bdea3caef3f1be9fac1fbb5511fe2d76b7985576c038f8c1d920615c3d49cd +size 25005 From ec63baa9cedc7eac2a092767b8c6869ced202be9 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 19 Oct 2023 16:57:35 +0100 Subject: [PATCH 08/41] Fix typo and preview --- .../edit/EditDefaultNotificationSettingStateProvider.kt | 1 + .../android/features/roomdetails/impl/RoomDetailsFlowNode.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt index 3446c24efe..2b1236f4af 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt @@ -47,5 +47,6 @@ private fun aRoomSummary() = RoomSummary.Filled( lastMessage = null, lastMessageTimestamp = null, unreadNotificationCount = 0, + notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, ) ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 5d7539e626..6945ebb0ca 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -70,7 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize data class RoomNotificationSettings( /** - * When presented from oursite the context of the room, the rooms settings UI is different. + * When presented from outsite the context of the room, the rooms settings UI is different. * Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0 */ val showUserDefinedSettingStyle: Boolean From 463ceba52c4a8a62675b9a3a760e3b53d9724b73 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 19 Oct 2023 16:08:50 +0000 Subject: [PATCH 09/41] Update screenshots --- ...ltNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...ltNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png index 1d4750eecb..7e1e8e88a0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1172d81259b7e7b694f428c5144d96c8a7748134f5b4f515a5c25c58ff8e5e1b -size 31318 +oid sha256:11aa649bb8e25975c79ebc8c20aa82153acac75b4087f99485c58bd604ba3f33 +size 35947 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png index c423fe4456..6e3219983b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e835ab12c0a77c4a4dcaf8bac0d78ed5ec9a0ab8eef669c7d814317ab06abd27 -size 28443 +oid sha256:1df0f8620db5a3f751e37543f34785517703e4731af9e624dc50b91de79f46e9 +size 33107 From b7ebff0e75f073683f3a51feda992e9e0ae42ebd Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 20 Oct 2023 21:08:44 +0100 Subject: [PATCH 10/41] Add new ui for room notifications screen - Add new ui for room notifications screen - Fix error when changing default notification setting. --- .../android/appnav/LoggedInFlowNode.kt | 16 +++- .../android/appnav/room/RoomLoadedFlowNode.kt | 22 +++-- .../android/appnav/RoomFlowNodeTest.kt | 25 ++++-- features/preferences/api/build.gradle.kts | 1 + .../preferences/api/PreferencesEntryPoint.kt | 13 +++ .../impl/DefaultPreferencesEntryPoint.kt | 10 +++ .../preferences/impl/PreferencesFlowNode.kt | 2 +- ...EditDefaultNotificationSettingPresenter.kt | 8 +- .../roomdetails/api/RoomDetailsEntryPoint.kt | 14 ++- features/roomdetails/impl/build.gradle.kts | 1 + .../impl/DefaultRoomDetailsEntryPoint.kt | 26 ++++-- .../roomdetails/impl/RoomDetailsFlowNode.kt | 12 ++- .../RoomNotificationSettingsNode.kt | 11 ++- .../RoomNotificationSettingsStateProvider.kt | 12 +++ .../RoomNotificationSettingsView.kt | 85 ++++++++++++------- .../RustNotificationSettingsService.kt | 10 ++- 16 files changed, 205 insertions(+), 63 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 891301532c..8d2edd0843 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -197,7 +197,9 @@ class LoggedInFlowNode @AssistedInject constructor( ) : NavTarget @Parcelize - data object Settings : NavTarget + data class Settings( + val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root + ) : NavTarget @Parcelize data object CreateRoom : NavTarget @@ -227,7 +229,7 @@ class LoggedInFlowNode @AssistedInject constructor( } override fun onSettingsClicked() { - backstack.push(NavTarget.Settings) + backstack.push(NavTarget.Settings()) } override fun onCreateRoomClicked() { @@ -260,11 +262,15 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onForwardedToSingleRoom(roomId: RoomId) { coroutineScope.launch { attachRoom(roomId) } } + + override fun onOpenGlobalNotificationSettings() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) + } } val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) createNode(buildContext, plugins = listOf(inputs, callback)) } - NavTarget.Settings -> { + is NavTarget.Settings -> { val callback = object : PreferencesEntryPoint.Callback { override fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } @@ -278,7 +284,9 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings)) } } - preferencesEntryPoint.nodeBuilder(this, buildContext) + val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) + return preferencesEntryPoint.nodeBuilder(this, buildContext) + .params(inputs) .callback(callback) .build() } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt index fa583645b2..613ed650c8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt @@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor( interface Callback : Plugin { fun onForwardedToSingleRoom(roomId: RoomId) + fun onOpenGlobalNotificationSettings() } data class Inputs( @@ -128,6 +129,18 @@ class RoomLoadedFlowNode @AssistedInject constructor( } } + private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node { + val callback = object : RoomDetailsEntryPoint.Callback { + override fun onOpenGlobalNotificationSettings() { + callbacks.forEach { it.onOpenGlobalNotificationSettings() } + } + } + return roomDetailsEntryPoint.nodeBuilder(this, buildContext) + .params(RoomDetailsEntryPoint.Params(initialTarget)) + .callback(callback) + .build() + } + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Messages -> { @@ -147,16 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor( messagesEntryPoint.createNode(this, buildContext, callback) } NavTarget.RoomDetails -> { - val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails) - roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) } is NavTarget.RoomMemberDetails -> { - val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) - roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) } NavTarget.RoomNotificationSettings -> { - val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) - roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) } } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index a25e4d8134..c05ddb6c73 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -31,6 +31,7 @@ import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.childNode +import io.element.android.libraries.architecture.createNode import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.test.room.FakeMatrixRoom @@ -71,14 +72,22 @@ class RoomFlowNodeTest { var nodeId: String? = null - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - inputs: RoomDetailsEntryPoint.Inputs, - plugins: List - ): Node { - return node(buildContext) {}.also { - nodeId = it.id + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder { + return object : RoomDetailsEntryPoint.NodeBuilder { + + override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder { + return this + } + + override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder { + return this + } + + override fun build(): Node { + return node(buildContext) {}.also { + nodeId = it.id + } + } } } } diff --git a/features/preferences/api/build.gradle.kts b/features/preferences/api/build.gradle.kts index c20fe9aabb..0278385ab3 100644 --- a/features/preferences/api/build.gradle.kts +++ b/features/preferences/api/build.gradle.kts @@ -15,6 +15,7 @@ */ plugins { id("io.element.android-library") + id("kotlin-parcelize") } android { diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 50a605efe4..a0d2b8e057 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,17 +16,30 @@ package io.element.android.features.preferences.api +import android.os.Parcelable import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object Root : InitialTarget + @Parcelize + data object NotificationSettings : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder interface NodeBuilder { + + fun params(params: Params): NodeBuilder fun callback(callback: Callback): NodeBuilder fun build(): Node } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt index aa286394dc..e551d9d8dc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint return object : PreferencesEntryPoint.NodeBuilder { val plugins = ArrayList() + override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder { + plugins += params + return this + } + override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder { plugins += callback return this @@ -42,3 +47,8 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint } } } + +internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { + is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root + is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 8d77757527..0fcf04d1df 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -53,7 +53,7 @@ class PreferencesFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BackstackNode( backstack = BackStack( - initialElement = NavTarget.Root, + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt index 79201e27d3..5fb34687d8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt @@ -135,7 +135,13 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( suspend { // 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).getOrThrow() - notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne).getOrThrow() + val result = notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne) + + if (result.isFailure) { + result.exceptionOrNull()?.printStackTrace() + } + + result.getOrThrow() }.runCatchingUpdatingState(action) } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index ed1e6f5c5a..0aaac324da 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -38,7 +38,17 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { data object RoomNotificationSettings : InitialTarget } - data class Inputs(val initialElement: InitialTarget) : NodeInputs + data class Params(val initialElement: InitialTarget) : NodeInputs - fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List): Node + interface Callback : Plugin { + fun onOpenGlobalNotificationSettings() + } + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder } diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 3bf38d4fa7..a2fdfa18c1 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) api(projects.features.roomdetails.api) api(projects.libraries.usersearch.api) api(projects.services.apperror.api) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index 8cd6cb54d6..108ef088f0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -29,13 +29,25 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - inputs: RoomDetailsEntryPoint.Inputs, - plugins: List - ): Node { - return parentNode.createNode(buildContext, plugins + inputs) + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder { + return object : RoomDetailsEntryPoint.NodeBuilder { + val plugins = ArrayList() + + override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder { + plugins += params + return this + } + + override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 6945ebb0ca..dba31ab223 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children 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 com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted @@ -47,7 +48,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BackstackNode( backstack = BackStack( - initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -125,8 +126,13 @@ class RoomDetailsFlowNode @AssistedInject constructor( } is NavTarget.RoomNotificationSettings -> { - val plugins = listOf(RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle)) - createNode(buildContext, plugins) + val input = RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle) + val callback = object : RoomNotificationSettingsNode.Callback { + override fun openGlobalNotificationSettings() { + plugins().forEach { it.onOpenGlobalNotificationSettings() } + } + } + createNode(buildContext, listOf(input, callback)) } is NavTarget.RoomMemberDetails -> { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index cb0168a42b..173e3b6af1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -22,6 +22,7 @@ import com.bumble.appyx.core.lifecycle.subscribe 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 im.vector.app.features.analytics.plan.MobileScreen @@ -42,8 +43,15 @@ class RoomNotificationSettingsNode @AssistedInject constructor( data class RoomNotificationSettingInput( val showUserDefinedSettingStyle: Boolean ) : NodeInputs - + interface Callback : Plugin { + fun openGlobalNotificationSettings() + } private val inputs = inputs() + private val callbacks = plugins() + + private fun openGlobalNotificationSettings() { + callbacks.forEach { it.openGlobalNotificationSettings() } + } init { lifecycle.subscribe( @@ -66,6 +74,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor( RoomNotificationSettingsView( state = state, modifier = modifier, + onShowGlobalNotifications = this::openGlobalNotificationSettings, onBackPressed = this::navigateUp, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt index 961909f933..1a95530d29 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt @@ -36,5 +36,17 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider< restoreDefaultAction = Async.Uninitialized, eventSink = { }, ), + RoomNotificationSettingsState( + roomName = "Room 1", + Async.Success(RoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = false)), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = Async.Uninitialized, + restoreDefaultAction = Async.Uninitialized, + eventSink = { }, + ), ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 3d95e4ec20..21bc11781d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -21,10 +21,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.R @@ -34,9 +38,9 @@ import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton 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.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -49,6 +53,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, modifier: Modifier = Modifier, + onShowGlobalNotifications: () -> Unit = {}, onBackPressed: () -> Unit = {}, ) { Scaffold( @@ -66,40 +71,62 @@ fun RoomNotificationSettingsView( .consumeWindowInsets(padding), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val subtitle = when (state.defaultRoomNotificationMode) { - RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) - RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) - RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) - null -> "" - } - val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() - - PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { - PreferenceSwitch( - isChecked = state.displayIsDefault.orTrue(), - onCheckedChange = { - state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) - }, - title = "Match default setting", - subtitle = subtitle, - enabled = roomNotificationSettings != null - ) - - PreferenceText( - title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), - subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), - enabled = !state.displayIsDefault.orTrue(), - ) - - if (roomNotificationSettings != null && state.displayNotificationMode != null) { + PreferenceSwitch( + isChecked = !state.displayIsDefault.orTrue(), + onCheckedChange = { + state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it)) + }, + title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), + subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), + enabled = roomNotificationSettings != null + ) + if (state.displayIsDefault.orTrue()) { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) { + val text = buildAnnotatedStringWithStyledPart( + R.string.screen_room_notification_settings_default_setting_footnote, + R.string.screen_room_notification_settings_default_setting_footnote_content_link, + color = Color.Unspecified, + underline = false, + bold = true, + ) + ClickableText( + text = text, + onClick = { + onShowGlobalNotifications() + }, + modifier = Modifier + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular + .copy( + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + ) + ) + if(state.defaultRoomNotificationMode != null){ + val defaultModeTitle = when (state.defaultRoomNotificationMode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { + stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) + } + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + } + RoomNotificationSettingsOption( + roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle), + isSelected = true, + onOptionSelected = { }, + enabled = true + ) + } + } + } else { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { RoomNotificationSettingsOptions( selected = state.displayNotificationMode, enabled = !state.displayIsDefault.orTrue(), onOptionSelected = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) - }, - ) + },) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt index df5c4d577f..42b2662a6d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate +import org.matrix.rustcomponents.sdk.NotificationSettingsException +import timber.log.Timber class RustNotificationSettingsService( private val notificationSettings: NotificationSettings, @@ -63,7 +65,13 @@ class RustNotificationSettingsService( isOneToOne: Boolean ): Result = withContext(dispatchers.io) { runCatching { - notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode)) + try { + notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode)) + } catch (exception: NotificationSettingsException.RuleNotFound) { + // `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930) + // since production home servers may not have these rules yet, we drop the RuleNotFound error + Timber.w("Unable to find the rule: ${exception.ruleId}") + } } } From e718c452c5f7ae0a451e17c82e93c59969cb4f0b Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 20 Oct 2023 20:30:14 +0000 Subject: [PATCH 11/41] Update screenshots --- ...ltNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...ltNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png | 4 ++-- ..._RoomNotificationSettings-D-4_4_null_0,NEXUS_5,1.0,en].png | 4 ++-- ..._RoomNotificationSettings-D-4_4_null_1,NEXUS_5,1.0,en].png | 3 +++ ..._RoomNotificationSettings-N-4_5_null_0,NEXUS_5,1.0,en].png | 4 ++-- ..._RoomNotificationSettings-N-4_5_null_1,NEXUS_5,1.0,en].png | 3 +++ 6 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png index 1d4750eecb..7e1e8e88a0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-D-8_8_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1172d81259b7e7b694f428c5144d96c8a7748134f5b4f515a5c25c58ff8e5e1b -size 31318 +oid sha256:11aa649bb8e25975c79ebc8c20aa82153acac75b4087f99485c58bd604ba3f33 +size 35947 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png index c423fe4456..6e3219983b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.notifications.edit_null_EditDefaultNotificationSettingView-N-8_9_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e835ab12c0a77c4a4dcaf8bac0d78ed5ec9a0ab8eef669c7d814317ab06abd27 -size 28443 +oid sha256:1df0f8620db5a3f751e37543f34785517703e4731af9e624dc50b91de79f46e9 +size 33107 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_0,NEXUS_5,1.0,en].png index 88554d0d72..7a08009659 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f34dde086bbd14cc5cfeb0003d6bfd0d3868ecbc497956dc17794e69f6773279 -size 40070 +oid sha256:3f2fc33febab98860da9b6591c4ae33dc9ba4fa28365520e4fcb08f8bfaf6ff8 +size 33828 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..383adaff52 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-D-4_4_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d99786d84445bc42f7e7f7e414f973a04ba6684780f69eb1cfe60a09ff3ec6e +size 38017 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_0,NEXUS_5,1.0,en].png index 9adff83203..d59805dfbd 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef0545d55a8a78af5d8286fc590998c973cee986a3f6aff91850cf277d47a5e5 -size 36415 +oid sha256:0cf8d93229fd5d8034dc7e2f68f9868fc42515c538098c89d4dddca814827bc3 +size 31448 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b7a1f3917 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.notificationsettings_null_RoomNotificationSettings-N-4_5_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13c5007d3a97734dca40a88f2312e59d09a8906deb71d16c0611df7dd25a6391 +size 35124 From 7681cfdb2011b0b8e9f13992c2d01667428126b2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 23 Oct 2023 10:28:04 +0100 Subject: [PATCH 12/41] fix unused import --- .../test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index c05ddb6c73..fd5de85b1d 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -31,7 +31,6 @@ import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.childNode -import io.element.android.libraries.architecture.createNode import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.test.room.FakeMatrixRoom From 3763593a668c766856626c48e3c7ecd321bdafe6 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 23 Oct 2023 12:06:52 +0100 Subject: [PATCH 13/41] Fix emoji shortcut values --- .../android/features/messages/impl/actionlist/ActionListView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 05db97d3be..34d87201a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -348,7 +348,7 @@ private fun EmojiReactionsRow( ) { // TODO use most recently used emojis here when available from the Rust SDK val defaultEmojis = sequenceOf( - "👍", "👎", "🔥", "❤️", "👏" + "👍️", "👎️", "🔥", "❤️", "👏" ) for (emoji in defaultEmojis) { val isHighlighted = highlightedEmojis.contains(emoji) From 6de6b6a8be911acf65cd9f3e1f4137a07d884c60 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 23 Oct 2023 12:11:23 +0000 Subject: [PATCH 14/41] Update screenshots --- ...ionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png index ac13aa8b52..385f874f07 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e7684929f93f437c50549b309e1ba75b95cddb506ad9c8ef97373d0552bddfb -size 39017 +oid sha256:1997fc442ccd0abe76d0fdcde639e8224e339f8b93cc3703543b39ec641edcf8 +size 37486 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png index 18aaab9fd1..44fb2cdf99 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56f71535fd676a86e79b0e987701d34c85c96276e5338f77117b13e1b3a05144 -size 45715 +oid sha256:72976810fdc5da4e2182db9ddcd1972a3d5f58510a2083a58513441cce02579e +size 43960 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png index 9098eef49b..cbf0e90093 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5554661aeb29ec0d95464942da03df5e83dc78762bc08e2f57851a8f7e811a5a -size 39438 +oid sha256:fc9f27f9d0068e1010eccbc776af4106bdb95811f6c3bdd3d13de26eea254b22 +size 37902 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png index 0c6cb3114f..483ae0043a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d398070330d5c66358b7ac17d4e1b93795fb9001ffc6eabe533d3bae86de7d58 -size 39810 +oid sha256:0e271ef60b51973bdb283f656f6c2bbfbb8bbfc723f15128e04e0ff1123dc763 +size 38256 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png index 74448d6967..d65f7aaa8a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:979138753000ada021a67f4bc14a89b912430c4331ac69aa702b2e50c050d7cb -size 41200 +oid sha256:207ee896047bed185f6110dd032924c9ed3ffa40a58ca6c2e829797e3074d460 +size 39600 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png index 2e051be73c..fd436c9971 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb4be22a56036d53f7c394c3829a9f8f09820276d7529ace0d3731965a8a92f9 -size 37556 +oid sha256:9a82276d1fa57bbed2850244886071df37b6441d7842acef4ecefd6a161404d7 +size 36106 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png index ea837fc372..125bcd6f79 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:369e8b4e3f741476cfa5ff7e3f9d5d1e1399fcf3494fadb95540129436ef4d68 -size 43744 +oid sha256:440aa47c7c00d1957a800b245de79b9b9b9a570de7024a0da211df681effc5f3 +size 42340 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png index 1c028e2b60..8aecf8543a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cd8fe02340aeb0edb116c0f3d8f07efc1d331849688718782db25e17bb6e1ab -size 37766 +oid sha256:6c595a14f273e1ffbda9ecbe499acccb49d4ee5ff32aaa3f8d5cfee0f7cf86d2 +size 36277 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png index cb01f9579d..23ebba41cf 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b2aaa54bf065e6496957c3a60d61336f65e6b76f97e5dbc773284ee49fac91d -size 37988 +oid sha256:babf4bbf8494b67b000177fca06fb8817f35ba5b5851cd85e729ad1d70a9bd95 +size 36515 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png index 8d45efac8c..ac459d9d58 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:752abe37a810cc8e4b36ae5f4c4aa38c9f22757b6458f272ba991050c69aee53 -size 39409 +oid sha256:1845e027e45d15aadc4be9c579568a791623adf25ff166ffb1595333b4535833 +size 37953 From a0c192266f39e1ccbf04a63c83080b3928dcd51b Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 23 Oct 2023 15:25:28 +0100 Subject: [PATCH 15/41] Update thumbs up emoji in the state provider. --- .../features/messages/impl/timeline/TimelineStateProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 1374f4aef4..2955c6783f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -139,7 +139,7 @@ fun aTimelineItemReactions( count: Int = 1, isHighlighted: Boolean = false, ): TimelineItemReactions { - val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") + val emojis = arrayOf("👍️", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") return TimelineItemReactions( reactions = buildList { repeat(count) { index -> From f721c988eeafa20be5a04cf9f2535e218b24b16f Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 23 Oct 2023 14:39:14 +0000 Subject: [PATCH 16/41] Update screenshots --- ...ionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...ionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png index 385f874f07..ac13aa8b52 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1997fc442ccd0abe76d0fdcde639e8224e339f8b93cc3703543b39ec641edcf8 -size 37486 +oid sha256:2e7684929f93f437c50549b309e1ba75b95cddb506ad9c8ef97373d0552bddfb +size 39017 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png index 44fb2cdf99..18aaab9fd1 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72976810fdc5da4e2182db9ddcd1972a3d5f58510a2083a58513441cce02579e -size 43960 +oid sha256:56f71535fd676a86e79b0e987701d34c85c96276e5338f77117b13e1b3a05144 +size 45715 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png index cbf0e90093..9098eef49b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc9f27f9d0068e1010eccbc776af4106bdb95811f6c3bdd3d13de26eea254b22 -size 37902 +oid sha256:5554661aeb29ec0d95464942da03df5e83dc78762bc08e2f57851a8f7e811a5a +size 39438 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png index 483ae0043a..0c6cb3114f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e271ef60b51973bdb283f656f6c2bbfbb8bbfc723f15128e04e0ff1123dc763 -size 38256 +oid sha256:d398070330d5c66358b7ac17d4e1b93795fb9001ffc6eabe533d3bae86de7d58 +size 39810 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png index d65f7aaa8a..74448d6967 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-D-1_1_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:207ee896047bed185f6110dd032924c9ed3ffa40a58ca6c2e829797e3074d460 -size 39600 +oid sha256:979138753000ada021a67f4bc14a89b912430c4331ac69aa702b2e50c050d7cb +size 41200 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png index fd436c9971..2e051be73c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a82276d1fa57bbed2850244886071df37b6441d7842acef4ecefd6a161404d7 -size 36106 +oid sha256:cb4be22a56036d53f7c394c3829a9f8f09820276d7529ace0d3731965a8a92f9 +size 37556 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png index 125bcd6f79..ea837fc372 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:440aa47c7c00d1957a800b245de79b9b9b9a570de7024a0da211df681effc5f3 -size 42340 +oid sha256:369e8b4e3f741476cfa5ff7e3f9d5d1e1399fcf3494fadb95540129436ef4d68 +size 43744 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png index 8aecf8543a..1c028e2b60 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c595a14f273e1ffbda9ecbe499acccb49d4ee5ff32aaa3f8d5cfee0f7cf86d2 -size 36277 +oid sha256:5cd8fe02340aeb0edb116c0f3d8f07efc1d331849688718782db25e17bb6e1ab +size 37766 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png index 23ebba41cf..cb01f9579d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:babf4bbf8494b67b000177fca06fb8817f35ba5b5851cd85e729ad1d70a9bd95 -size 36515 +oid sha256:9b2aaa54bf065e6496957c3a60d61336f65e6b76f97e5dbc773284ee49fac91d +size 37988 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png index ac459d9d58..8d45efac8c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.actionlist_null_SheetContent-N-1_2_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1845e027e45d15aadc4be9c579568a791623adf25ff166ffb1595333b4535833 -size 37953 +oid sha256:752abe37a810cc8e4b36ae5f4c4aa38c9f22757b6458f272ba991050c69aee53 +size 39409 From 5c582bba1b684987c1dec14fd6f988e2171e4569 Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Mon, 23 Oct 2023 18:28:00 +0100 Subject: [PATCH 17/41] Record and send voice messages (#1596) --------- Co-authored-by: ElementBot --- changelog.d/1596.feature | 1 + features/messages/impl/build.gradle.kts | 2 + .../impl/src/main/AndroidManifest.xml | 20 ++ .../messages/impl/MessagesPresenter.kt | 3 + .../features/messages/impl/MessagesState.kt | 1 + .../messages/impl/MessagesStateProvider.kt | 1 + .../features/messages/impl/MessagesView.kt | 19 + .../messagecomposer/MessageComposerView.kt | 9 +- .../VoiceMessageComposerEvents.kt | 5 + .../VoiceMessageComposerPresenter.kt | 156 ++++++++- .../VoiceMessageComposerState.kt | 1 + .../VoiceMessageComposerStateProvider.kt | 3 +- .../voicemessages/VoiceMessageException.kt | 26 ++ .../VoiceMessagePermissionRationaleDialog.kt | 37 ++ .../messages/MessagesPresenterTest.kt | 17 +- .../VoiceMessageComposerPresenterTest.kt | 327 +++++++++++++++++- gradle/libs.versions.toml | 2 + .../android/libraries/core/hash/Hash.kt | 35 ++ .../libraries/mediaupload/api/MediaSender.kt | 54 ++- .../mediaupload/api/MediaUploadInfo.kt | 1 + .../mediaupload/AndroidMediaPreProcessor.kt | 1 + .../mediaupload/test/FakeMediaPreProcessor.kt | 18 + .../api/PermissionsStateProvider.kt | 5 +- .../libraries/textcomposer/TextComposer.kt | 43 ++- ...dingProgress.kt => VoiceMessagePreview.kt} | 21 +- .../components/VoiceMessageRecording.kt | 102 ++++++ .../textcomposer/model/VoiceMessageState.kt | 6 +- libraries/voicerecorder/api/build.gradle.kts | 32 ++ .../voicerecorder/api/VoiceRecorder.kt | 55 +++ .../voicerecorder/api/VoiceRecorderState.kt | 44 +++ libraries/voicerecorder/impl/build.gradle.kts | 48 +++ .../voicerecorder/impl/VoiceRecorderImpl.kt | 132 +++++++ .../impl/audio/AndroidAudioReader.kt | 139 ++++++++ .../voicerecorder/impl/audio/Audio.kt | 28 ++ .../voicerecorder/impl/audio/AudioConfig.kt | 35 ++ .../impl/audio/AudioLevelCalculator.kt | 28 ++ .../voicerecorder/impl/audio/AudioReader.kt | 37 ++ .../impl/audio/DecibelAudioLevelCalculator.kt | 49 +++ .../impl/audio/DefaultEncoder.kt | 63 ++++ .../voicerecorder/impl/audio/Encoder.kt | 28 ++ .../voicerecorder/impl/audio/SampleRate.kt | 24 ++ .../impl/di/VoiceRecorderModule.kt | 58 ++++ .../impl/file/DefaultVoiceFileManager.kt | 49 +++ .../impl/file/VoiceFileConfig.kt | 30 ++ .../impl/file/VoiceFileManager.kt | 25 ++ .../impl/VoiceRecorderImplTest.kt | 134 +++++++ .../audio/DecibelAudioLevelCalculatorTest.kt | 46 +++ .../test/FakeAudioLevelCalculator.kt | 26 ++ .../voicerecorder/test/FakeAudioReader.kt | 49 +++ .../test/FakeAudioRecorderFactory.kt | 30 ++ .../voicerecorder/test/FakeEncoder.kt | 40 +++ .../voicerecorder/test/FakeFileSystem.kt | 43 +++ .../test/FakeVoiceFileManager.kt | 37 ++ libraries/voicerecorder/test/build.gradle.kts | 30 ++ .../voicerecorder/test/FakeVoiceRecorder.kt | 74 ++++ .../kotlin/extension/DependencyHandleScope.kt | 1 + ...ViewVoice-D-5_5_null_0,NEXUS_5,1.0,en].png | 4 +- ...ViewVoice-N-5_6_null_0,NEXUS_5,1.0,en].png | 4 +- ...gProgress-D-11_11_null,NEXUS_5,1.0,en].png | 3 - ...gProgress-N-11_12_null,NEXUS_5,1.0,en].png | 3 - ...ndButton-D-11_11_null,NEXUS_5,1.0,en].png} | 0 ...ndButton-N-11_12_null,NEXUS_5,1.0,en].png} | 0 ...rmatting-D-12_12_null,NEXUS_5,1.0,en].png} | 0 ...rmatting-N-12_13_null,NEXUS_5,1.0,en].png} | 0 ...gePreview-D-13_13_null,NEXUS_5,1.0,en].png | 3 + ...gePreview-N-13_14_null,NEXUS_5,1.0,en].png | 3 + ...Recording-D-14_14_null,NEXUS_5,1.0,en].png | 3 + ...Recording-N-14_15_null,NEXUS_5,1.0,en].png | 3 + 68 files changed, 2274 insertions(+), 82 deletions(-) create mode 100644 changelog.d/1596.feature create mode 100644 features/messages/impl/src/main/AndroidManifest.xml create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessagePermissionRationaleDialog.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt rename libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/{RecordingProgress.kt => VoiceMessagePreview.kt} (75%) create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt create mode 100644 libraries/voicerecorder/api/build.gradle.kts create mode 100644 libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt create mode 100644 libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt create mode 100644 libraries/voicerecorder/impl/build.gradle.kts create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt create mode 100644 libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeEncoder.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeFileSystem.kt create mode 100644 libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceFileManager.kt create mode 100644 libraries/voicerecorder/test/build.gradle.kts create mode 100644 libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_RecordingProgress-D-11_11_null,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_RecordingProgress-N-11_12_null,NEXUS_5,1.0,en].png rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_null_SendButton-D-12_12_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_null_SendButton-D-11_11_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_null_SendButton-N-12_13_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_null_SendButton-N-11_12_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_null_TextFormatting-D-13_13_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_null_TextFormatting-D-12_12_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_null_TextFormatting-N-13_14_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_null_TextFormatting-N-12_13_null,NEXUS_5,1.0,en].png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-13_13_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-13_14_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessageRecording-D-14_14_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessageRecording-N-14_15_null,NEXUS_5,1.0,en].png diff --git a/changelog.d/1596.feature b/changelog.d/1596.feature new file mode 100644 index 0000000000..5108d6008b --- /dev/null +++ b/changelog.d/1596.feature @@ -0,0 +1 @@ +Record and send voice messages \ No newline at end of file diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 956b80949a..8ee3adb13c 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.voicerecorder.api) implementation(projects.features.networkmonitor.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) @@ -80,6 +81,7 @@ dependencies { testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.textcomposer.test) + testImplementation(projects.libraries.voicerecorder.test) testImplementation(libs.test.mockk) ksp(libs.showkase.processor) diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/features/messages/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a00e8e1873 --- /dev/null +++ b/features/messages/impl/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6125e920b8..cf15db5bd9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -63,6 +63,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor( private val preferencesStore: PreferencesStore, private val featureFlagsService: FeatureFlagService, @Assisted private val navigator: MessagesNavigator, + private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory @@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor( enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, enableInRoomCalls = enableInRoomCalls, + appName = buildMeta.applicationName, eventSink = { handleEvents(it) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 3a0585f390..81feec4b63 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -50,5 +50,6 @@ data class MessagesState( val enableTextFormatting: Boolean, val enableVoiceMessages: Boolean, val enableInRoomCalls: Boolean, + val appName: String, val eventSink: (MessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 249c4b487e..ae279f6d49 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState( enableTextFormatting = true, enableVoiceMessages = true, enableInRoomCalls = true, + appName = "Element", eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5a7168e7ce..6dd91aa01c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents +import io.element.android.features.messages.impl.voicemessages.VoiceMessagePermissionRationaleDialog import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule @@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.UserId @@ -107,6 +110,10 @@ fun MessagesView( ) { LogCompositions(tag = "MessagesScreen", msg = "Root") + OnLifecycleEvent { _, event -> + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) + } + AttachmentStateView( state = state.composerState.attachmentsState, onPreviewAttachments = onPreviewAttachments, @@ -306,6 +313,18 @@ private fun MessagesViewContent( enableTextFormatting = state.enableTextFormatting, ) + if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) { + VoiceMessagePermissionRationaleDialog( + onContinue = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) + }, + onDismiss = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) + }, + appName = state.appName + ) + } + ExpandableBottomSheetScaffold( sheetDragHandle = if (state.composerState.showTextFormatting) { @Composable { BottomSheetDragHandle() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 150ae23f9b..8f3899f139 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -71,10 +71,14 @@ internal fun MessageComposerView( } } - fun onVoiceRecordButtonEvent(press: PressEvent) { + val onVoiceRecordButtonEvent = { press: PressEvent -> voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press)) } + fun onSendVoiceMessage() { + voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + TextComposer( modifier = modifier, state = state.richTextEditorState, @@ -89,7 +93,8 @@ internal fun MessageComposerView( onDismissTextFormatting = ::onDismissTextFormatting, enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, - onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent, + onVoiceRecordButtonEvent = onVoiceRecordButtonEvent, + onSendVoiceMessage = ::onSendVoiceMessage, onError = ::onError, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt index 7d6803fc41..42ba0d1d07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt @@ -16,10 +16,15 @@ package io.element.android.features.messages.impl.voicemessages +import androidx.lifecycle.Lifecycle import io.element.android.libraries.textcomposer.model.PressEvent sealed interface VoiceMessageComposerEvents { data class RecordButtonEvent( val pressEvent: PressEvent ): VoiceMessageComposerEvents + data object SendVoiceMessage: VoiceMessageComposerEvents + data object AcceptPermissionRationale: VoiceMessageComposerEvents + data object DismissPermissionsRationale: VoiceMessageComposerEvents + data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt index 106125934b..78e7d0ceb8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt @@ -16,49 +16,171 @@ package io.element.android.features.messages.impl.voicemessages +import android.Manifest import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.textcomposer.model.PressEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File import javax.inject.Inject @SingleIn(RoomScope::class) -class VoiceMessageComposerPresenter @Inject constructor() : Presenter { +class VoiceMessageComposerPresenter @Inject constructor( + private val appCoroutineScope: CoroutineScope, + private val voiceRecorder: VoiceRecorder, + private val analyticsService: AnalyticsService, + private val mediaSender: MediaSender, + permissionsPresenterFactory: PermissionsPresenter.Factory +) : Presenter { + private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) + @Composable override fun present(): VoiceMessageComposerState { - var voiceMessageState by remember { mutableStateOf(VoiceMessageState.Idle) } + val localCoroutineScope = rememberCoroutineScope() + val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) - fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) { - PressEvent.PressStart -> { - // TODO start the recording - voiceMessageState = VoiceMessageState.Recording - } - PressEvent.LongPressEnd -> { - // TODO finish the recording - voiceMessageState = VoiceMessageState.Idle - } - PressEvent.Tapped -> { - // TODO discard the recording and show the 'hold to record' tooltip - voiceMessageState = VoiceMessageState.Idle + val permissionState = permissionsPresenter.present() + var isSending by remember { mutableStateOf(false) } + + val onLifecycleEvent = { event: Lifecycle.Event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + appCoroutineScope.finishRecording() + } + Lifecycle.Event.ON_DESTROY -> { + appCoroutineScope.cancelRecording() + } + else -> {} } } + val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent -> + val permissionGranted = permissionState.permissionGranted + when (event.pressEvent) { + PressEvent.PressStart -> { + Timber.v("Voice message record button pressed") + when { + permissionGranted -> { + localCoroutineScope.startRecording() + } + else -> { + Timber.i("Voice message permission needed") + permissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + PressEvent.LongPressEnd -> { + Timber.v("Voice message record button released") + localCoroutineScope.finishRecording() + } + PressEvent.Tapped -> { + Timber.v("Voice message record button tapped") + localCoroutineScope.cancelRecording() + } + } + } - fun handleEvents(event: VoiceMessageComposerEvents) { + val onAcceptPermissionsRationale = { + permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) + } + + val onDismissPermissionsRationale = { + permissionState.eventSink(PermissionsEvents.CloseDialog) + } + + val onSendButtonPress = lambda@{ + val finishedState = recorderState as? VoiceRecorderState.Finished + if (finishedState == null) { + val exception = VoiceMessageException.FileException("No file to send") + analyticsService.trackError(exception) + Timber.e(exception) + return@lambda + } + if (isSending) { + return@lambda + } + isSending = true + appCoroutineScope.sendMessage( + file = finishedState.file, + mimeType = finishedState.mimeType, + ).invokeOnCompletion { + isSending = false + } + } + + val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event -> when (event) { is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event) + is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch { + onSendButtonPress() + } + VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale() + VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale() + is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event) } } return VoiceMessageComposerState( - voiceMessageState = voiceMessageState, - eventSink = { handleEvents(it) } + voiceMessageState = when (val state = recorderState) { + is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level) + is VoiceRecorderState.Finished -> VoiceMessageState.Preview + else -> VoiceMessageState.Idle + }, + showPermissionRationaleDialog = permissionState.showDialog, + eventSink = handleEvents, ) } + + private fun CoroutineScope.startRecording() = launch { + try { + voiceRecorder.startRecord() + } catch (e: SecurityException) { + Timber.e(e, "Voice message error") + analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) + } + } + + private fun CoroutineScope.finishRecording() = launch { + voiceRecorder.stopRecord() + } + + private fun CoroutineScope.cancelRecording() = launch { + voiceRecorder.stopRecord(cancelled = true) + } + + private fun CoroutineScope.sendMessage( + file: File, mimeType: String, + ) = launch { + val result = mediaSender.sendVoiceMessage( + uri = file.toUri(), + mimeType = mimeType, + waveForm = emptyList(), // TODO generate waveform + ) + + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "Voice message error") + return@launch + } + + voiceRecorder.deleteRecording() + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt index bacbe76324..8f0ab827b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState @Stable data class VoiceMessageComposerState( val voiceMessageState: VoiceMessageState, + val showPermissionRationaleDialog: Boolean, val eventSink: (VoiceMessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt index 63b59596c0..1a904beee3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording), + aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)), ) } @@ -30,5 +30,6 @@ internal fun aVoiceMessageComposerState( voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, ) = VoiceMessageComposerState( voiceMessageState = voiceMessageState, + showPermissionRationaleDialog = false, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt new file mode 100644 index 0000000000..2020b687ae --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt @@ -0,0 +1,26 @@ +/* + * 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.messages.impl.voicemessages + +internal sealed class VoiceMessageException : Exception() { + data class FileException( + override val message: String?, override val cause: Throwable? = null + ) : VoiceMessageException() + data class PermissionMissing( + override val message: String?, override val cause: Throwable? + ) : VoiceMessageException() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessagePermissionRationaleDialog.kt new file mode 100644 index 0000000000..19b7f7cb46 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessagePermissionRationaleDialog.kt @@ -0,0 +1,37 @@ +/* + * 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.messages.impl.voicemessages + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun VoiceMessagePermissionRationaleDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), + onSubmitClicked = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index b02afd90fb..b0958a5d2d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.mediapickers.test.FakePickerProvider @@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate @@ -607,20 +609,28 @@ class MessagesPresenterTest { analyticsService: FakeAnalyticsService = FakeAnalyticsService(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), ): MessagesPresenter { + val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, room = matrixRoom, mediaPickerProvider = FakePickerProvider(), featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), - mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), + mediaSender = mediaSender, snackbarDispatcher = SnackbarDispatcher(), analyticsService = analyticsService, messageComposerContext = MessageComposerContextImpl(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), - permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + permissionsPresenterFactory = permissionsPresenterFactory, + ) + val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( + this, + FakeVoiceRecorder(), + analyticsService, + mediaSender, + permissionsPresenterFactory, ) - val voiceMessageComposerPresenter = VoiceMessageComposerPresenter() val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, @@ -649,6 +659,7 @@ class MessagesPresenterTest { clipboardHelper = clipboardHelper, preferencesStore = preferencesStore, featureFlagsService = FakeFeatureFlagService(), + buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt index 008226bf05..d1ee074e46 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt @@ -18,16 +18,31 @@ package io.element.android.features.messages.voicemessages +import android.Manifest +import androidx.lifecycle.Lifecycle import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.VoiceMessageException +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.aPermissionsState +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.model.PressEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -37,53 +52,349 @@ class VoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val voiceRecorder = FakeVoiceRecorder() + private val analyticsService = FakeAnalyticsService() + private val matrixRoom = FakeMatrixRoom() + private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } + private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom) + @Test fun `present - initial state`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + testPauseAndDestroy(initialState) } } @Test fun `present - recording state`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) + + testPauseAndDestroy(finalState) } } @Test fun `present - abort recording`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + testPauseAndDestroy(finalState) } } @Test fun `present - finish recording`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + + testPauseAndDestroy(finalState) } } - private fun createPresenter() = VoiceMessageComposerPresenter() + + @Test + fun `present - send recording`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send recording before previous completed, waits`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().run { + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures aren't tracked`() = runTest { + // Let sending fail due to media preprocessing error + mediaPreProcessor.givenResult(Result.failure(Exception())) + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + + val finalState = awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview) + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).hasSize(0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures can be retried`() = runTest { + // Let sending fail due to media preprocessing error + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + mediaPreProcessor.givenResult(Result.failure(Exception())) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + val previewState = awaitItem() + + previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + + ensureAllEventsConsumed() + assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + + mediaPreProcessor.givenAudioResult() + previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send error - missing recording is tracked`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Send the message before recording anything + initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + + assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).hasSize(1) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - record error - security exceptions are tracked`() = runTest { + val exception = SecurityException("") + voiceRecorder.givenThrowsSecurityException(exception) + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).containsExactly( + VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) + ) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - permission accepted first time`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission denied previously`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) + } + + // Dialog is hidden, user accepts permissions + assertThat(awaitItem().showPermissionRationaleDialog).isFalse() + + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission rationale dismissed`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) + } + + // Dialog is hidden, user tries to record again + awaitItem().also { + assertThat(it.showPermissionRationaleDialog).isFalse() + it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + } + + // Dialog is shown once again + val finalState = awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + } + + testPauseAndDestroy(finalState) + } + } + + private suspend fun TurbineTestContext.testPauseAndDestroy( + mostRecentState: VoiceMessageComposerState, + ) { + mostRecentState.eventSink( + VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE) + ) + + val onPauseState = when (mostRecentState.voiceMessageState) { + VoiceMessageState.Idle, + VoiceMessageState.Preview -> { + mostRecentState + } + is VoiceMessageState.Recording -> { + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + } + } + } + + onPauseState.eventSink( + VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY) + ) + + when (onPauseState.voiceMessageState) { + VoiceMessageState.Idle -> + ensureAllEventsConsumed() + is VoiceMessageState.Recording, + VoiceMessageState.Preview -> + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + } + } + + private fun TestScope.createVoiceMessageComposerPresenter( + permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), + ): VoiceMessageComposerPresenter { + return VoiceMessageComposerPresenter( + this, + voiceRecorder, + analyticsService, + mediaSender, + FakePermissionsPresenterFactory(permissionsPresenter), + ) + } + + private fun createFakePermissionsPresenter( + recordPermissionGranted: Boolean = true, + recordPermissionShowDialog: Boolean = false, + ): FakePermissionsPresenter { + val initialPermissionState = aPermissionsState( + showDialog = recordPermissionShowDialog, + permission = Manifest.permission.RECORD_AUDIO, + permissionGranted = recordPermissionGranted, + ) + return FakePermissionsPresenter( + initialState = initialPermissionState + ) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c86738fa0..7c3afb48dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx_annotationjvm = { module = "androidx.annotation:annotation-jvm", version = "1.7.0" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6" @@ -164,6 +165,7 @@ statemachine = "com.freeletics.flowredux:compose:1.2.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1" +opusencoder = "io.element.android:opusencoder:1.1.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt new file mode 100644 index 0000000000..760431a7be --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.core.hash + +import java.security.MessageDigest +import java.util.Locale + +/** + * Compute a Hash of a String, using md5 algorithm. + */ +fun String.md5() = try { + val digest = MessageDigest.getInstance("md5") + val locale = Locale.ROOT + digest.update(toByteArray()) + digest.digest() + .joinToString("") { String.format(locale, "%02X", it) } + .lowercase(locale) +} catch (exc: Exception) { + // Should not happen, but just in case + hashCode().toString() +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 899e92efc5..dde62e7513 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -50,16 +50,43 @@ class MediaSender @Inject constructor( .flatMapCatching { info -> room.sendMedia(info, progressCallback) } - .onFailure { error -> - val job = ongoingUploadJobs.remove(Job) - if (error !is CancellationException) { - job?.cancel() - } - } - .onSuccess { - ongoingUploadJobs.remove(Job) - } + .handleSendResult() } + suspend fun sendVoiceMessage( + uri: Uri, + mimeType: String, + waveForm: List, + progressCallback: ProgressCallback? = null + ): Result { + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = true, + compressIfPossible = false + ) + .flatMapCatching { info -> + val audioInfo = (info as MediaUploadInfo.Audio).audioInfo + val newInfo = MediaUploadInfo.VoiceMessage( + file = info.file, + audioInfo = audioInfo, + waveform = waveForm, + ) + room.sendMedia(newInfo, progressCallback) + } + .handleSendResult() + } + + private fun Result.handleSendResult() = this + .onFailure { error -> + val job = ongoingUploadJobs.remove(Job) + if (error !is CancellationException) { + job?.cancel() + } + } + .onSuccess { + ongoingUploadJobs.remove(Job) + } private suspend fun MatrixRoom.sendMedia( uploadInfo: MediaUploadInfo, @@ -90,7 +117,14 @@ class MediaSender @Inject constructor( progressCallback = progressCallback ) } - + is MediaUploadInfo.VoiceMessage -> { + sendVoiceMessage( + file = uploadInfo.file, + audioInfo = uploadInfo.audioInfo, + waveform = uploadInfo.waveform, + progressCallback = progressCallback + ) + } is MediaUploadInfo.AnyFile -> { sendFile( file = uploadInfo.file, diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 51f6372b23..e1debf6bda 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -29,5 +29,6 @@ sealed interface MediaUploadInfo { data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo + data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List) : MediaUploadInfo data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt index cd968530f8..205be3b241 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -118,6 +118,7 @@ class AndroidMediaPreProcessor @Inject constructor( is MediaUploadInfo.Audio -> copy(file = renamedFile) is MediaUploadInfo.Image -> copy(file = renamedFile) is MediaUploadInfo.Video -> copy(file = renamedFile) + is MediaUploadInfo.VoiceMessage -> copy(file = renamedFile) } } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index d94414d2d7..8e7e71e8fb 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -17,11 +17,14 @@ package io.element.android.libraries.mediaupload.test import android.net.Uri +import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.tests.testutils.simulateLongTask import java.io.File +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration class FakeMediaPreProcessor : MediaPreProcessor { @@ -53,4 +56,19 @@ class FakeMediaPreProcessor : MediaPreProcessor { fun givenResult(value: Result) { this.result = value } + + fun givenAudioResult() { + givenResult( + Result.success( + MediaUploadInfo.Audio( + file = File("audio.ogg"), + audioInfo = AudioInfo( + duration = 1000.seconds.toJavaDuration(), + size = 1000, + mimetype = "audio/ogg", + ), + ) + ) + ) + } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt index cc59d96b44..19797b9075 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt @@ -31,10 +31,11 @@ open class PermissionsStateProvider : PreviewParameterProvider fun aPermissionsState( showDialog: Boolean, - permission: String = Manifest.permission.POST_NOTIFICATIONS + permission: String = Manifest.permission.POST_NOTIFICATIONS, + permissionGranted: Boolean = false, ) = PermissionsState( permission = permission, - permissionGranted = false, + permissionGranted = permissionGranted, shouldShowRationale = false, showDialog = showDialog, permissionAlreadyAsked = false, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 911cebb142..7924077394 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -64,7 +64,8 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.textcomposer.components.ComposerOptionsButton import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton import io.element.android.libraries.textcomposer.components.RecordButton -import io.element.android.libraries.textcomposer.components.RecordingProgress +import io.element.android.libraries.textcomposer.components.VoiceMessagePreview +import io.element.android.libraries.textcomposer.components.VoiceMessageRecording import io.element.android.libraries.textcomposer.components.SendButton import io.element.android.libraries.textcomposer.components.TextFormatting import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape @@ -95,6 +96,7 @@ fun TextComposer( onAddAttachment: () -> Unit = {}, onDismissTextFormatting: () -> Unit = {}, onVoiceRecordButtonEvent: (PressEvent) -> Unit = {}, + onSendVoiceMessage: () -> Unit = {}, onError: (Throwable) -> Unit = {}, ) { val onSendClicked = { @@ -137,24 +139,39 @@ fun TextComposer( composerMode = composerMode, ) } - val recordButton = @Composable { + val recordVoiceButton = @Composable { RecordButton( onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) }, onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) }, onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) }, ) } + val sendVoiceButton = @Composable { + SendButton( + canSendMessage = voiceMessageState is VoiceMessageState.Preview, + onClick = { onSendVoiceMessage() }, + composerMode = composerMode, + ) + } val textFormattingOptions = @Composable { TextFormatting(state = state) } - val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) { - sendButton - } else { - recordButton + val sendOrRecordButton = when { + enableVoiceMessages && !canSendMessage -> + when (voiceMessageState) { + is VoiceMessageState.Preview -> sendVoiceButton + else -> recordVoiceButton + } + else -> + sendButton } - val recordingProgress = @Composable { - RecordingProgress() + val voiceRecording = @Composable { + if (voiceMessageState is VoiceMessageState.Recording) { + VoiceMessageRecording(voiceMessageState.level) + } else if (voiceMessageState is VoiceMessageState.Preview) { + VoiceMessagePreview() + } } if (showTextFormatting) { @@ -170,11 +187,12 @@ fun TextComposer( } else { StandardLayout( voiceMessageState = voiceMessageState, + enableVoiceMessages = enableVoiceMessages, modifier = layoutModifier, composerOptionsButton = composerOptionsButton, textInput = textInput, endButton = sendOrRecordButton, - recordingProgress = recordingProgress, + voiceRecording = voiceRecording, ) } @@ -190,9 +208,10 @@ fun TextComposer( @Composable private fun StandardLayout( voiceMessageState: VoiceMessageState, + enableVoiceMessages: Boolean, textInput: @Composable () -> Unit, composerOptionsButton: @Composable () -> Unit, - recordingProgress: @Composable () -> Unit, + voiceRecording: @Composable () -> Unit, endButton: @Composable () -> Unit, modifier: Modifier = Modifier, ) { @@ -200,13 +219,13 @@ private fun StandardLayout( modifier = modifier, verticalAlignment = Alignment.Bottom, ) { - if (voiceMessageState is VoiceMessageState.Recording) { + if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) { Box( modifier = Modifier .padding(start = 16.dp, bottom = 8.dp, top = 8.dp) .weight(1f) ) { - recordingProgress() + voiceRecording() } } else { Box( diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt similarity index 75% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 2fc0420e05..351293a329 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -17,14 +17,10 @@ package io.element.android.libraries.textcomposer.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -36,7 +32,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme @Composable -internal fun RecordingProgress( +internal fun VoiceMessagePreview( modifier: Modifier = Modifier, ) { Row( @@ -50,16 +46,9 @@ internal fun RecordingProgress( .heightIn(26.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(8.dp) - .background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape) - ) - Spacer(Modifier.size(8.dp)) - - // TODO Replace with timer UI + // TODO Replace with recording preview UI Text( - text = "Recording...", // Not localized because it is a placeholder + text = "Finished recording", // Not localized because it is a placeholder color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodySmMedium ) @@ -68,6 +57,6 @@ internal fun RecordingProgress( @PreviewsDayNight @Composable -internal fun RecordingProgressPreview() = ElementPreview { - RecordingProgress() +internal fun VoiceMessagePreviewPreview() = ElementPreview { + VoiceMessagePreview() } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt new file mode 100644 index 0000000000..24703a579c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt @@ -0,0 +1,102 @@ +/* + * 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.libraries.textcomposer.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +internal fun VoiceMessageRecording( + level: Double, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = MaterialTheme.shapes.medium, + ) + .padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp) + .heightIn(26.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape) + ) + Spacer(Modifier.size(8.dp)) + + // TODO Replace with timer UI + Text( + text = "Recording...", // Not localized because it is a placeholder + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium + ) + + Spacer(Modifier.size(20.dp)) + + // TODO Replace with waveform UI + DebugAudioLevel( + modifier = Modifier.weight(1f), level = level + ) + } +} + +@Composable +private fun DebugAudioLevel( + level: Double, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .height(26.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxWidth(level.toFloat()) + .background(ElementTheme.colors.iconQuaternary, shape = MaterialTheme.shapes.small) + .fillMaxHeight() + ) + } +} + +@PreviewsDayNight +@Composable +internal fun VoiceMessageRecordingPreview() = ElementPreview { + VoiceMessageRecording(0.5) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index d376c4ee70..835000478a 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -18,5 +18,9 @@ package io.element.android.libraries.textcomposer.model sealed class VoiceMessageState { data object Idle: VoiceMessageState() - data object Recording: VoiceMessageState() + + data object Preview: VoiceMessageState() + data class Recording( + val level: Double, + ): VoiceMessageState() } diff --git a/libraries/voicerecorder/api/build.gradle.kts b/libraries/voicerecorder/api/build.gradle.kts new file mode 100644 index 0000000000..bed69b7d28 --- /dev/null +++ b/libraries/voicerecorder/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * 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") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.voicerecorder.api" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt new file mode 100644 index 0000000000..77465ddeea --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt @@ -0,0 +1,55 @@ +/* + * 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.libraries.voicerecorder.api + +import android.Manifest +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.flow.StateFlow + +/** + * Audio recorder which records audio to opus/ogg files. + */ +interface VoiceRecorder { + /** + * Start a recording. + * + * Call [stopRecord] to stop the recording and release resources. + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + suspend fun startRecord() + + /** + * Stop the current recording. + * + * Call [deleteRecording] to delete any recorded audio. + * + * @param cancelled If true, the recording is deleted. + */ + suspend fun stopRecord( + cancelled: Boolean = false + ) + + /** + * Stop the current recording and delete the output file. + */ + suspend fun deleteRecording() + + /** + * The current state of the recorder. + */ + val state: StateFlow +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt new file mode 100644 index 0000000000..8d531c3565 --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt @@ -0,0 +1,44 @@ +/* + * 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.libraries.voicerecorder.api + +import java.io.File + +sealed class VoiceRecorderState { + /** + * The recorder is idle and not recording. + */ + data object Idle : VoiceRecorderState() + + /** + * The recorder is currently recording. + * + * @property level The current audio level of the recording as a fraction of 1. + */ + data class Recording(val level: Double) : VoiceRecorderState() + + /** + * The recorder has finished recording. + * + * @property file The recorded file. + * @property mimeType The mime type of the file. + */ + data class Finished( + val file: File, + val mimeType: String, + ) : VoiceRecorderState() +} diff --git a/libraries/voicerecorder/impl/build.gradle.kts b/libraries/voicerecorder/impl/build.gradle.kts new file mode 100644 index 0000000000..6ebfb28997 --- /dev/null +++ b/libraries/voicerecorder/impl/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * 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") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.voicerecorder.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + api(projects.libraries.voicerecorder.api) + api(libs.opusencoder) + + implementation(libs.dagger) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + + testImplementation(projects.tests.testutils) + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.mockk) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.core) + testImplementation(libs.coroutines.test) +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt new file mode 100644 index 0000000000..ef91118371 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt @@ -0,0 +1,132 @@ +/* + * 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.libraries.voicerecorder.impl + +import android.Manifest +import androidx.annotation.RequiresPermission +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator +import io.element.android.libraries.voicerecorder.impl.audio.AudioReader +import io.element.android.libraries.voicerecorder.impl.audio.Encoder +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class VoiceRecorderImpl @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val audioReaderFactory: AudioReader.Factory, + private val encoder: Encoder, + private val fileManager: VoiceFileManager, + private val config: AudioConfig, + private val fileConfig: VoiceFileConfig, + private val audioLevelCalculator: AudioLevelCalculator, + appCoroutineScope: CoroutineScope, +) : VoiceRecorder { + private val voiceCoroutineScope by lazy { + appCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}") + } + + private var outputFile: File? = null + private var audioReader: AudioReader? = null + private var recordingJob: Job? = null + + private val _state = MutableStateFlow(VoiceRecorderState.Idle) + override val state: StateFlow = _state + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override suspend fun startRecord() { + Timber.i("Voice recorder started recording") + outputFile = fileManager.createFile() + .also(encoder::init) + + val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it } + + recordingJob = voiceCoroutineScope.launch { + audioRecorder.record { audio -> + when (audio) { + is Audio.Data -> { + val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer) + _state.emit(VoiceRecorderState.Recording(audioLevel)) + encoder.encode(audio.buffer, audio.readSize) + } + is Audio.Error -> { + Timber.e("Voice message error: code=${audio.audioRecordErrorCode}") + _state.emit(VoiceRecorderState.Recording(0.0)) + } + } + } + } + } + + /** + * Stop the current recording. + * + * Call [deleteRecording] to delete any recorded audio. + */ + override suspend fun stopRecord( + cancelled: Boolean + ) { + recordingJob?.cancel()?.also { + Timber.i("Voice recorder stopped recording") + } + recordingJob = null + + audioReader?.stop() + audioReader = null + encoder.release() + + if (cancelled) { + deleteRecording() + } + + _state.emit( + when (val file = outputFile) { + null -> VoiceRecorderState.Idle + else -> VoiceRecorderState.Finished(file, fileConfig.mimeType) + } + ) + } + + /** + * Stop the current recording and delete the output file. + */ + override suspend fun deleteRecording() { + outputFile?.let(fileManager::deleteFile)?.also { + Timber.i("Voice recorder deleted recording") + } + outputFile = null + _state.emit(VoiceRecorderState.Idle) + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt new file mode 100644 index 0000000000..a2342f3c2f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt @@ -0,0 +1,139 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import android.Manifest +import android.media.AudioRecord +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import androidx.annotation.RequiresPermission +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.di.RoomScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext + +class AndroidAudioReader +@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor( + private val config: AudioConfig, + private val dispatchers: CoroutineDispatchers, +) : AudioReader { + private val audioRecord: AudioRecord + private var noiseSuppressor: NoiseSuppressor? = null + private var automaticGainControl: AutomaticGainControl? = null + private val outputBuffer: ShortArray + + init { + outputBuffer = createOutputBuffer(config.sampleRate) + audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build() + noiseSuppressor = requestNoiseSuppressor(audioRecord) + automaticGainControl = requestAutomaticGainControl(audioRecord) + } + + /** + * Record audio data continuously. + * + * @param onAudio callback when audio is read. + */ + override suspend fun record( + onAudio: suspend (Audio) -> Unit, + ) { + audioRecord.startRecording() + withContext(dispatchers.io) { + while (isActive) { + if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + break + } + onAudio(read()) + } + } + } + + private fun read(): Audio { + val result = audioRecord.read(outputBuffer, 0, outputBuffer.size) + + if (isAudioRecordErrorResult(result)) { + return Audio.Error(result) + } + + return Audio.Data( + result, + outputBuffer, + ) + } + + override fun stop() { + if (audioRecord.state == AudioRecord.STATE_INITIALIZED) { + audioRecord.stop() + } + audioRecord.release() + + noiseSuppressor?.release() + noiseSuppressor = null + + automaticGainControl?.release() + automaticGainControl = null + } + + private fun createOutputBuffer(sampleRate: SampleRate): ShortArray { + val bufferSizeInShorts = AudioRecord.getMinBufferSize( + sampleRate.hz, + config.format.channelMask, + config.format.encoding + ) + return ShortArray(bufferSizeInShorts) + } + + private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? { + if (!NoiseSuppressor.isAvailable()) { + return null + } + + return tryOrNull { + NoiseSuppressor.create(audioRecord.audioSessionId).apply { + enabled = true + } + } + } + + private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? { + if (!AutomaticGainControl.isAvailable()) { + return null + } + + return tryOrNull { + AutomaticGainControl.create(audioRecord.audioSessionId).apply { + enabled = true + } + } + } + + @ContributesBinding(RoomScope::class) + companion object Factory : AudioReader.Factory { + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader { + return AndroidAudioReader(config, dispatchers) + } + } +} + +private fun isAudioRecordErrorResult(result: Int): Boolean { + return result < 0 +} + +private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt new file mode 100644 index 0000000000..3e51d615f4 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt @@ -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 io.element.android.libraries.voicerecorder.impl.audio + +sealed class Audio { + class Data( + val readSize: Int, + val buffer: ShortArray, + ) : Audio() + + data class Error( + val audioRecordErrorCode: Int + ) : Audio() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt new file mode 100644 index 0000000000..6ff912c2ae --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import android.media.AudioFormat +import android.media.MediaRecorder.AudioSource + +/** + * Audio configuration for voice recording. + * + * @property source the audio source to use, see constants in [AudioSource] + * @property format the audio format to use, see [AudioFormat] + * @property sampleRate the sample rate to use. Ensure this matches the value set in [format]. + * @property bitRate the bitrate in bps + */ +data class AudioConfig( + val source: Int, + val format: AudioFormat, + val sampleRate: SampleRate, + val bitRate: Int, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt new file mode 100644 index 0000000000..554b6ba4b1 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt @@ -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 io.element.android.libraries.voicerecorder.impl.audio + +interface AudioLevelCalculator { + /** + * Calculate the audio level of the audio buffer. + * + * @param buffer The audio buffer containing raw audio data. + * + * @return A value between 0 and 1. + */ + fun calculateAudioLevel(buffer: ShortArray): Double +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt new file mode 100644 index 0000000000..230c9533fd --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt @@ -0,0 +1,37 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers + +interface AudioReader { + /** + * Record audio data continuously. + * + * @param onAudio callback when audio is read. + */ + suspend fun record( + onAudio: suspend (Audio) -> Unit, + ) + + fun stop() + + interface Factory { + fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader + } + +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt new file mode 100644 index 0000000000..8a16acf83b --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt @@ -0,0 +1,49 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import javax.inject.Inject +import kotlin.math.log10 +import kotlin.math.min +import kotlin.math.sqrt + +@ContributesBinding(RoomScope::class) +class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator { + companion object { + private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation + } + + override fun calculateAudioLevel(buffer: ShortArray): Double { + val rms = buffer.rootMeanSquare() + + // Convert to decibels and clip + val db = 20 * log10(rms / REFERENCE_DB) + val clipped = min(db, REFERENCE_DB) + + // Scale to the range [0.0, 1.0] + return clipped / REFERENCE_DB + } + + private fun ShortArray.rootMeanSquare(): Double { + // Use Double to avoid overflow + val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() } + val avgSquare = sumOfSquares / size.toDouble() + return sqrt(avgSquare) + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt new file mode 100644 index 0000000000..a888824fe5 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt @@ -0,0 +1,63 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.opusencoder.OggOpusEncoder +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Provider + +/** + * Safe wrapper for OggOpusEncoder. + */ +@ContributesBinding(RoomScope::class) +class DefaultEncoder @Inject constructor( + private val encoderProvider: Provider, + config: AudioConfig, +) : Encoder { + private val bitRate = config.bitRate +private val sampleRate = config.sampleRate.asEncoderModel() + + private var encoder: OggOpusEncoder? = null + override fun init( + file: File, + ) { + encoder?.release() + encoder = encoderProvider.get().apply { + init(file.absolutePath, sampleRate) + setBitrate(bitRate) + // TODO check encoder application: 2048 (voice, default is typically 2049 as audio) + } + } + + override fun encode( + buffer: ShortArray, + readSize: Int, + ) { + encoder?.encode(buffer, readSize) + ?: Timber.w("Can't encode when encoder not initialized") + } + + override fun release() { + encoder?.release() + ?: Timber.w("Can't release encoder that is not initialized") + encoder = null + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt new file mode 100644 index 0000000000..67685635aa --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt @@ -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 io.element.android.libraries.voicerecorder.impl.audio + +import java.io.File + +interface Encoder { + + fun init(file: File) + + fun encode(buffer: ShortArray, readSize: Int) + + fun release() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt new file mode 100644 index 0000000000..b392b6e19f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt @@ -0,0 +1,24 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate + +data object SampleRate { + const val hz = 48_000 + fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt new file mode 100644 index 0000000000..b21ab48ac3 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt @@ -0,0 +1,58 @@ +/* + * 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.libraries.voicerecorder.impl.di + +import android.media.AudioFormat +import android.media.MediaRecorder +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.SampleRate +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig +import io.element.android.opusencoder.OggOpusEncoder + +@Module +@ContributesTo(RoomScope::class) +object VoiceRecorderModule { + @Provides + fun provideAudioConfig(): AudioConfig { + val sampleRate = SampleRate + return AudioConfig( + format = AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate.hz) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .build(), + bitRate = 24_000, // 24 kbps + sampleRate = sampleRate, + source = MediaRecorder.AudioSource.MIC, + ) + } + + @Provides + fun provideVoiceFileConfig(): VoiceFileConfig = + VoiceFileConfig( + cacheSubdir = "voice_recordings", + fileExt = "ogg", + mimeType = "audio/ogg", + ) + + @Provides + fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt new file mode 100644 index 0000000000..07ef54991f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt @@ -0,0 +1,49 @@ +/* + * 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.libraries.voicerecorder.impl.file + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.hash.md5 +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import java.io.File +import java.util.UUID +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class DefaultVoiceFileManager @Inject constructor( + @CacheDirectory private val cacheDir: File, + private val config: VoiceFileConfig, + room: MatrixRoom, +) : VoiceFileManager { + + private val roomId: RoomId = room.roomId + + override fun createFile(): File { + val fileName = "${UUID.randomUUID()}.${config.fileExt}" + val outputDirectory = File(cacheDir, config.cacheSubdir) + val roomDir = File(outputDirectory, roomId.value.md5()) + .apply(File::mkdirs) + return File(roomDir, fileName) + } + + override fun deleteFile(file: File) { + file.delete() + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt new file mode 100644 index 0000000000..a7b1f4607d --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt @@ -0,0 +1,30 @@ +/* + * 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.libraries.voicerecorder.impl.file + +/** + * File configuration for voice recording. + * + * @property cacheSubdir the subdirectory in the cache dir to use. + * @property fileExt the file extension for audio files. + * @property mimeType the mime type of audio files. + */ +data class VoiceFileConfig( + val cacheSubdir: String, + val fileExt: String, + val mimeType: String, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt new file mode 100644 index 0000000000..77e85b910e --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt @@ -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.libraries.voicerecorder.impl.file + +import java.io.File + +interface VoiceFileManager { + fun createFile(): File + + fun deleteFile(file: File) +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt new file mode 100644 index 0000000000..847e1c514f --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt @@ -0,0 +1,134 @@ +/* + * 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.libraries.voicerecorder.impl + +import android.media.AudioFormat +import android.media.MediaRecorder +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.SampleRate +import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule +import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator +import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory +import io.element.android.libraries.voicerecorder.test.FakeEncoder +import io.element.android.libraries.voicerecorder.test.FakeFileSystem +import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass +import org.junit.Test +import java.io.File + +class VoiceRecorderImplTest { + private val fakeFileSystem = FakeFileSystem() + + @Test + fun `it emits the initial state`() = runTest { + val voiceRecorder = createVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + } + } + + @Test + fun `when recording, it emits the recording state`() = runTest { + val voiceRecorder = createVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0)) + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0)) + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0)) + } + } + + @Test + fun `when stopped, it provides a file`() = runTest { + val voiceRecorder = createVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + skipItems(3) + voiceRecorder.stopRecord() + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg")) + assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA) + } + } + + @Test + fun `when cancelled, it deletes the file`() = runTest { + val voiceRecorder = createVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + skipItems(3) + voiceRecorder.stopRecord(cancelled = true) + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull() + } + } + + private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl { + val fileConfig = VoiceRecorderModule.provideVoiceFileConfig() + return VoiceRecorderImpl( + dispatchers = testCoroutineDispatchers(), + audioReaderFactory = FakeAudioRecorderFactory( + audio = AUDIO, + ), + encoder = FakeEncoder(fakeFileSystem), + config = AudioConfig( + format = AUDIO_FORMAT, + bitRate = 24_000, // 24 kbps + sampleRate = SampleRate, + source = MediaRecorder.AudioSource.MIC, + ), + fileConfig = fileConfig, + fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID), + audioLevelCalculator = FakeAudioLevelCalculator(), + appCoroutineScope = backgroundScope, + ) + } + + companion object { + const val FILE_ID: String = "recording" + const val FILE_PATH = "voice_recordings/${FILE_ID}.ogg" + private lateinit var AUDIO_FORMAT: AudioFormat + + // FakeEncoder doesn't actually encode, it just writes the data to the file + private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]" + private const val MAX_AMP = Short.MAX_VALUE + private val AUDIO = listOf( + Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)), + Audio.Error(-1), + Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)), + ) + + @BeforeClass + @JvmStatic + fun initAudioFormat() { + AUDIO_FORMAT = mockk() + } + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt new file mode 100644 index 0000000000..8ffbf1ef8e --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt @@ -0,0 +1,46 @@ +/* + * 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.libraries.voicerecorder.impl.audio + +import org.junit.Test + +class DecibelAudioLevelCalculatorTest { + + @Test + fun `given max values, it returns values within range`() { + val calculator = DecibelAudioLevelCalculator() + val buffer = ShortArray(100) { Short.MAX_VALUE } + val level = calculator.calculateAudioLevel(buffer) + assert(level in 0.0..1.0) + } + + @Test + fun `given mixed values, it returns values within range`() { + val calculator = DecibelAudioLevelCalculator() + val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1) + val level = calculator.calculateAudioLevel(buffer) + assert(level in 0.0..1.0) + } + + @Test + fun `given min values, it returns values within range`() { + val calculator = DecibelAudioLevelCalculator() + val buffer = ShortArray(100) { Short.MIN_VALUE } + val level = calculator.calculateAudioLevel(buffer) + assert(level in 0.0..1.0) + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt new file mode 100644 index 0000000000..1615067f6c --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt @@ -0,0 +1,26 @@ +/* + * 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.libraries.voicerecorder.test + +import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator +import kotlin.math.abs + +class FakeAudioLevelCalculator: AudioLevelCalculator { + override fun calculateAudioLevel(buffer: ShortArray): Double { + return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt new file mode 100644 index 0000000000..71fd2df041 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt @@ -0,0 +1,49 @@ +/* + * 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.libraries.voicerecorder.test + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioReader +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +class FakeAudioReader( + private val dispatchers: CoroutineDispatchers, + private val audio: List