From 5bf4e4c8ce851ce077ed73a3e46b10c1ffa7436b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Aug 2025 16:29:50 +0200 Subject: [PATCH 01/31] [a11y] Ensure external keyboard `Esc` key closes any bottom sheet. --- .../theme/components/ModalBottomSheet.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt index 51a6cd9ee1..b362c729dc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -23,6 +23,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -54,7 +59,17 @@ fun ModalBottomSheet( val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState androidx.compose.material3.ModalBottomSheet( onDismissRequest = onDismissRequest, - modifier = modifier, + modifier = modifier.onKeyEvent { keyEvent -> + // It seems that on some devices, we have to handle the Escape key manually to close the bottom sheet. + // This is not the case using an emulator, but is necessary on some physical devices. + if (keyEvent.type == KeyEventType.KeyUp && + keyEvent.key == Key.Escape) { + onDismissRequest() + true + } else { + false + } + }, sheetState = safeSheetState, shape = shape, containerColor = containerColor, From c2949b4753d0b45b9df73739d22985ad0186fac6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Aug 2025 16:36:21 +0200 Subject: [PATCH 02/31] Cleanup: `rememberModalBottomSheetState` is the default value for `sheetState` --- .../components/reactionsummary/ReactionSummaryView.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt index 081d93465f..d3dc63c031 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -83,8 +82,6 @@ fun ReactionSummaryView( state: ReactionSummaryState, modifier: Modifier = Modifier, ) { - val sheetState = rememberModalBottomSheetState() - fun onDismiss() { state.eventSink(ReactionSummaryEvents.Clear) } @@ -92,7 +89,6 @@ fun ReactionSummaryView( if (state.target != null) { ModalBottomSheet( onDismissRequest = ::onDismiss, - sheetState = sheetState, modifier = modifier ) { ReactionSummaryViewContent(summary = state.target) From 3a97bff95a1a37bf70aeb5dbd4aa6407dffbf626 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Aug 2025 16:37:43 +0200 Subject: [PATCH 03/31] Cleanup: The local inspection mode is handled in `ModalBottomSheet`. --- .../impl/rolesandpermissions/RolesAndPermissionsView.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt index 536b0f8b18..b581554a92 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.preview.sheetStateForPreview import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem @@ -145,11 +143,7 @@ private fun ChangeOwnRoleBottomSheet( eventSink: (RolesAndPermissionsEvents) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - val sheetState = if (LocalInspectionMode.current) { - sheetStateForPreview() - } else { - rememberModalBottomSheetState(skipPartiallyExpanded = true) - } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) fun dismiss() { sheetState.hide(coroutineScope) { eventSink(RolesAndPermissionsEvents.CancelPendingAction) From c4e654ac424b1e97893dce9f937306e8f49a09fc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 09:17:06 +0200 Subject: [PATCH 04/31] Cleanup: Remove unused class --- .../configureroom/ConfigureRoomPresenterArgs.kt | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt deleted file mode 100644 index b41b98b8cc..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.createroom.impl.configureroom - -import io.element.android.libraries.matrix.api.user.MatrixUser - -data class ConfigureRoomPresenterArgs( - val selectedUsers: List, -) From eb33c8d864a96b04e8db5e88afc1d1846b7459b3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 09:41:08 +0200 Subject: [PATCH 05/31] [a11y] Let keyboard shortcut Shift + F10 trigger the same action than a long click --- .../home/impl/components/RoomSummaryRow.kt | 18 ++++---- .../timeline/components/MessageEventBubble.kt | 15 ++++--- .../components/MessageStateEventContainer.kt | 4 +- .../components/MessagesReactionButton.kt | 2 + .../components/TimelineItemCallNotifyView.kt | 2 + .../timeline/components/TimelineItemRow.kt | 13 +++--- .../components/event/TimelineItemImageView.kt | 11 +++-- .../event/TimelineItemStickerView.kt | 2 + .../components/event/TimelineItemVideoView.kt | 11 +++-- .../designsystem/modifiers/Keyboard.kt | 42 +++++++++++++++++++ .../impl/gallery/ui/AudioItemView.kt | 2 + .../impl/gallery/ui/FileItemView.kt | 2 + .../impl/gallery/ui/ImageItemView.kt | 4 +- .../impl/gallery/ui/VideoItemView.kt | 4 +- .../impl/gallery/ui/VoiceItemView.kt | 2 + 15 files changed, 104 insertions(+), 30 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index b37e9ea223..1786c1139e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -170,14 +171,15 @@ private fun RoomSummaryScaffoldRow( hideAvatarImage: Boolean = false, content: @Composable ColumnScope.() -> Unit ) { - val clickModifier = Modifier.combinedClickable( - onClick = { onClick(room) }, - onLongClick = { onLongClick(room) }, - onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - indication = ripple(), - interactionSource = remember { MutableInteractionSource() } - ) - + val clickModifier = Modifier + .combinedClickable( + onClick = { onClick(room) }, + onLongClick = { onLongClick(room) }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + indication = ripple(), + interactionSource = remember { MutableInteractionSource() } + ) + .onShiftF10 { onLongClick(room) } Row( modifier = modifier .fillMaxWidth() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 00069e9f82..710428bfab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -96,12 +97,14 @@ fun MessageEventBubble( val clickableModifier = if (isTalkbackActive()) { Modifier } else { - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - indication = ripple(), - interactionSource = interactionSource - ) + Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = ripple(), + interactionSource = interactionSource + ) + .onShiftF10(onLongClick) } // Ignore state.isHighlighted for now, we need a design decision on it. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt index c988679626..70abb82278 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Surface @@ -46,7 +47,8 @@ fun MessageStateEventContainer( onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), indication = ripple(), interactionSource = interactionSource - ), + ) + .onShiftF10(onLongClick), color = backgroundColor, shape = shape, content = content diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 8c2a1d9ff6..86005640c9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.AggregatedReacti import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -107,6 +108,7 @@ fun MessagesReactionButton( onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClick = onLongClick ) + .onShiftF10(onLongClick) // Inner border, to highlight when selected .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt index ca34184491..579b825796 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt @@ -34,6 +34,7 @@ import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallStateProvider import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -57,6 +58,7 @@ internal fun TimelineItemCallNotifyView( onLongClick = { onLongClick(event) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onShiftF10 { onLongClick(event) } .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 59ec63d842..8753a90c42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.modifiers.subtleColorStops import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -148,11 +149,13 @@ internal fun TimelineItemRow( // Custom clickable that applies over the whole item for accessibility .then( if (isTalkbackActive()) { - Modifier.combinedClickable( - onClick = { onContentClick(timelineItem) }, - onLongClick = { onLongClick(timelineItem) }, - onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ) + Modifier + .combinedClickable( + onClick = { onContentClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onShiftF10 { onLongClick(timelineItem) } } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 41d3873a65..94e71afeb2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle @@ -91,10 +92,12 @@ fun TimelineItemImageView( .then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then( if (!isTalkbackActive() && onContentClick != null) { - Modifier.combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick - ) + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onShiftF10(onLongClick) } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index dc7cfb2d52..79e4583ba4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.MediaRequestData @@ -74,6 +75,7 @@ fun TimelineItemStickerView( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onShiftF10(onLongClick) } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 18a59325d5..433d75dbc9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -105,10 +106,12 @@ fun TimelineItemVideoView( .then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then( if (!isTalkbackActive && onContentClick != null) { - Modifier.combinedClickable( - onClick = onContentClick, - onLongClick = onLongClick - ) + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onShiftF10(onLongClick) } else { Modifier } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt new file mode 100644 index 0000000000..c1a6a57428 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type + +/** + * Modifier to handle Shift + F10 key events. + * This is typically used to trigger context menus in desktop applications. + * + * @param onShiftF10Press The callback to invoke when Shift + F10 is pressed. + */ +fun Modifier.onShiftF10( + onShiftF10Press: (() -> Unit)?, +): Modifier = then( + if (onShiftF10Press == null) { + Modifier + } else { + Modifier.onKeyEvent { keyEvent -> + // invoke the callback when the user presses Shift + F10 + if (keyEvent.type == KeyEventType.KeyUp && + keyEvent.isShiftPressed && + keyEvent.key == Key.F10) { + onShiftF10Press() + true + } else { + false + } + } + } +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt index c8d735d485..f0410981b8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.core.extensions.withBrackets +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -84,6 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onShiftF10(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt index 1144fc3eaf..bcc19212f4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.core.extensions.withBrackets +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -84,6 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onShiftF10(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt index 917c60df9e..f03f5e0955 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.mediaviewer.impl.model.MediaItem @@ -44,7 +45,8 @@ fun ImageItemView( onClick = onClick, onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ), + ) + .onShiftF10(onLongClick), ) { var isLoaded by remember { mutableStateOf(false) } AsyncImage( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt index cdc66c4eae..6a47567d76 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -33,6 +33,7 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -54,7 +55,8 @@ fun VideoItemView( onClick = onClick, onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ), + ) + .onShiftF10(onLongClick), ) { var isLoaded by remember { mutableStateOf(false) } AsyncImage( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 5d84b79f8f..032f486cf7 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView +import io.element.android.libraries.designsystem.modifiers.onShiftF10 import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator @@ -105,6 +106,7 @@ private fun VoiceInfoRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onShiftF10(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, From a7ea4d01be3c8248a20ad90f3e36a27653b04623 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 12:00:12 +0200 Subject: [PATCH 06/31] Ensure that navigation using keyboard is not broken on the room list. Workaround https://issuetracker.google.com/issues/436432313 --- .../io/element/android/features/home/impl/HomeView.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 30a5c86243..cf1bede289 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -240,14 +240,19 @@ private fun HomeScaffold( contentPadding = PaddingValues( // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80, // and include provided bottom padding - bottom = 80.dp + padding.calculateBottomPadding(), - top = padding.calculateTopPadding() + // Disable contentPadding due to navigation issue using the keyboard + // See https://issuetracker.google.com/issues/436432313 + bottom = 80.dp, // + padding.calculateBottomPadding(), + //top = padding.calculateTopPadding() ), modifier = Modifier .padding( PaddingValues( start = padding.calculateStartPadding(LocalLayoutDirection.current), end = padding.calculateEndPadding(LocalLayoutDirection.current), + // Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed + bottom = padding.calculateBottomPadding(), + top = padding.calculateTopPadding() ) ) .consumeWindowInsets(padding) From 81ed07a641e4099cee03f016742cb875a8fafda9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 5 Aug 2025 10:11:31 +0000 Subject: [PATCH 07/31] Update screenshots --- .../snapshots/images/features.home.impl_HomeView_Day_3_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_3_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index 654b904236..3a0271ad4f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb923fffd20dc775f31b998e23c39acf79013a831a7c292c4cdba5b9142b4094 -size 68227 +oid sha256:6fd52151e94328a68ef5b65bdc0e5e0d730bd617ed2b58ad92c350fb409c75d2 +size 63096 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index 6324eecb45..4da8004df3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9066615bfa2f78b77784302ce0272e3a486c79e267629c50c35ec1221c58046 -size 64443 +oid sha256:a67035a18b067b07f52090ee0ca9e3defa34a158e7dcefc55062131ac1599823 +size 59193 From 21473efdf29618f6c92dbf0717225ae03de70a04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:56:13 +0000 Subject: [PATCH 08/31] Update dependency net.zetetic:sqlcipher-android to v4.10.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33834ed2f5..21bd25937a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -182,7 +182,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.9.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.10.0" sqlite = "androidx.sqlite:sqlite-ktx:2.5.2" unifiedpush = "org.unifiedpush.android:connector:3.0.10" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" From 683628e352f98162daa81ade596a536fd3c2627e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 21:07:10 +0200 Subject: [PATCH 09/31] Rename extension. --- .../features/home/impl/components/RoomSummaryRow.kt | 4 ++-- .../impl/timeline/components/MessageEventBubble.kt | 4 ++-- .../timeline/components/MessageStateEventContainer.kt | 4 ++-- .../impl/timeline/components/MessagesReactionButton.kt | 4 ++-- .../timeline/components/TimelineItemCallNotifyView.kt | 4 ++-- .../impl/timeline/components/TimelineItemRow.kt | 4 ++-- .../timeline/components/event/TimelineItemImageView.kt | 4 ++-- .../components/event/TimelineItemStickerView.kt | 4 ++-- .../timeline/components/event/TimelineItemVideoView.kt | 4 ++-- .../libraries/designsystem/modifiers/Keyboard.kt | 10 +++++----- .../mediaviewer/impl/gallery/ui/AudioItemView.kt | 4 ++-- .../mediaviewer/impl/gallery/ui/FileItemView.kt | 4 ++-- .../mediaviewer/impl/gallery/ui/ImageItemView.kt | 4 ++-- .../mediaviewer/impl/gallery/ui/VideoItemView.kt | 4 ++-- .../mediaviewer/impl/gallery/ui/VoiceItemView.kt | 4 ++-- 15 files changed, 33 insertions(+), 33 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 1786c1139e..a065da16e5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -179,7 +179,7 @@ private fun RoomSummaryScaffoldRow( indication = ripple(), interactionSource = remember { MutableInteractionSource() } ) - .onShiftF10 { onLongClick(room) } + .onKeyboardContextMenuAction { onLongClick(room) } Row( modifier = modifier .fillMaxWidth() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 710428bfab..04fe0cb481 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -38,7 +38,7 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -104,7 +104,7 @@ fun MessageEventBubble( indication = ripple(), interactionSource = interactionSource ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) } // Ignore state.isHighlighted for now, we need a design decision on it. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt index 70abb82278..82eeb77177 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Surface @@ -48,7 +48,7 @@ fun MessageStateEventContainer( indication = ripple(), interactionSource = interactionSource ) - .onShiftF10(onLongClick), + .onKeyboardContextMenuAction(onLongClick), color = backgroundColor, shape = shape, content = content diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 86005640c9..c2673c0d84 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -43,7 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.AggregatedReacti import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.libraries.designsystem.icons.CompoundDrawables -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -108,7 +108,7 @@ fun MessagesReactionButton( onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClick = onLongClick ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) // Inner border, to highlight when selected .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt index 579b825796..6cbd22c3fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt @@ -34,7 +34,7 @@ import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallStateProvider import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -58,7 +58,7 @@ internal fun TimelineItemCallNotifyView( onLongClick = { onLongClick(event) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10 { onLongClick(event) } + .onKeyboardContextMenuAction { onLongClick(event) } .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 8753a90c42..ff318b3f7e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -37,7 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.subtleColorStops import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -155,7 +155,7 @@ internal fun TimelineItemRow( onLongClick = { onLongClick(timelineItem) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10 { onLongClick(timelineItem) } + .onKeyboardContextMenuAction { onLongClick(timelineItem) } } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 94e71afeb2..df5b3e0cbb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -48,7 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle @@ -97,7 +97,7 @@ fun TimelineItemImageView( onClick = onContentClick, onLongClick = onLongClick, ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index 79e4583ba4..1ad75fb933 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -31,7 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.MediaRequestData @@ -75,7 +75,7 @@ fun TimelineItemStickerView( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) } else { Modifier } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 433d75dbc9..87e895f76c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -54,7 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.protection.ProtectedView import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -111,7 +111,7 @@ fun TimelineItemVideoView( onClick = onContentClick, onLongClick = onLongClick, ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) } else { Modifier } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt index c1a6a57428..c8c8b8769e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.input.key.type * Modifier to handle Shift + F10 key events. * This is typically used to trigger context menus in desktop applications. * - * @param onShiftF10Press The callback to invoke when Shift + F10 is pressed. + * @param action The callback to invoke when Shift + F10 is pressed. */ -fun Modifier.onShiftF10( - onShiftF10Press: (() -> Unit)?, +fun Modifier.onKeyboardContextMenuAction( + action: (() -> Unit)?, ): Modifier = then( - if (onShiftF10Press == null) { + if (action == null) { Modifier } else { Modifier.onKeyEvent { keyEvent -> @@ -32,7 +32,7 @@ fun Modifier.onShiftF10( if (keyEvent.type == KeyEventType.KeyUp && keyEvent.isShiftPressed && keyEvent.key == Key.F10) { - onShiftF10Press() + action() true } else { false diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt index f0410981b8..7229bdb9c1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.core.extensions.withBrackets -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -85,7 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt index bcc19212f4..2b01b734e8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.core.extensions.withBrackets -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -85,7 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt index f03f5e0955..05e6ccf86a 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.mediaviewer.impl.model.MediaItem @@ -46,7 +46,7 @@ fun ImageItemView( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick), + .onKeyboardContextMenuAction(onLongClick), ) { var isLoaded by remember { mutableStateOf(false) } AsyncImage( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt index 6a47567d76..4bfe58badd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -33,7 +33,7 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -56,7 +56,7 @@ fun VideoItemView( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick), + .onKeyboardContextMenuAction(onLongClick), ) { var isLoaded by remember { mutableStateOf(false) } AsyncImage( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 032f486cf7..23ccb9b68b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView -import io.element.android.libraries.designsystem.modifiers.onShiftF10 +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator @@ -106,7 +106,7 @@ private fun VoiceInfoRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) - .onShiftF10(onLongClick) + .onKeyboardContextMenuAction(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, From f2580374c03cb0aefabf444eab966e1251ef138c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 21:10:26 +0200 Subject: [PATCH 10/31] Fix comment quality --- .../kotlin/io/element/android/features/home/impl/HomeView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index cf1bede289..525eaf88fd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -242,8 +242,9 @@ private fun HomeScaffold( // and include provided bottom padding // Disable contentPadding due to navigation issue using the keyboard // See https://issuetracker.google.com/issues/436432313 - bottom = 80.dp, // + padding.calculateBottomPadding(), - //top = padding.calculateTopPadding() + bottom = 80.dp, + // bottom = 80.dp + padding.calculateBottomPadding(), + // top = padding.calculateTopPadding() ), modifier = Modifier .padding( From 2dec34374eab524be54a9e1460ee336c3e069308 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 5 Aug 2025 16:55:39 +0200 Subject: [PATCH 11/31] Prevent users from using Element FOSS on homeservers that enforce the usage of Element Pro. --- appnav/build.gradle.kts | 1 + .../io/element/android/appnav/RootFlowNode.kt | 6 +- .../AccountProviderAccessControl.kt | 12 + .../DefaultAccountProviderAccessControl.kt | 61 +++++ .../ElementWellknownRetriever.kt | 42 ++++ .../changeserver/ChangeServerPresenter.kt | 14 +- .../changeserver/ChangeServerStateProvider.kt | 8 + .../impl/changeserver/ChangeServerView.kt | 22 ++ .../UnauthorizedAccountProviderException.kt | 15 +- .../login/impl/error/ChangeServerError.kt | 15 +- .../login/impl/login/LoginModeView.kt | 19 ++ .../impl/resolver/network/ElementWellKnown.kt | 3 + .../screens/onboarding/OnBoardingPresenter.kt | 9 +- .../qrcode/scan/QrCodeScanPresenter.kt | 13 +- .../qrcode/scan/QrCodeScanStateProvider.kt | 13 +- .../screens/qrcode/scan/QrCodeScanView.kt | 15 +- .../impl/src/main/res/values/localazy.xml | 1 + ...DefaultAccountProviderAccessControlTest.kt | 214 ++++++++++++++++++ .../FakeElementWellknownRetriever.kt | 19 ++ .../changeserver/ChangeServerPresenterTest.kt | 42 +++- .../onboarding/OnBoardingPresenterTest.kt | 8 + .../qrcode/scan/QrCodeScanPresenterTest.kt | 21 +- .../androidutils/system/SystemUtils.kt | 18 ++ .../android/libraries/matrix/test/TestData.kt | 1 + 24 files changed, 556 insertions(+), 36 deletions(-) create mode 100644 features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/ElementWellknownRetriever.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/FakeElementWellknownRetriever.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 09b0a634a4..fd676b50c1 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) + implementation(projects.features.login.api) implementation(libs.coil) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index ae9f935061..5498f7a2a0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -33,8 +33,8 @@ import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView -import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.viewfolder.api.ViewFolderEntryPoint @@ -64,7 +64,7 @@ class RootFlowNode @AssistedInject constructor( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val authenticationService: MatrixAuthenticationService, - private val enterpriseService: EnterpriseService, + private val accountProviderAccessControl: AccountProviderAccessControl, private val navStateFlowFactory: RootNavStateFlowFactory, private val matrixSessionCache: MatrixSessionCache, private val presenter: RootPresenter, @@ -293,7 +293,7 @@ class RootFlowNode @AssistedInject constructor( val latestSessionId = authenticationService.getLatestSessionId() if (latestSessionId == null) { // No session, open login - if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) { + if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { switchToNotLoggedInFlow(params) } else { Timber.w("Login link ignored, we are not allowed to connect to the homeserver") diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt new file mode 100644 index 0000000000..3b182fd4df --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.api.accesscontrol + +interface AccountProviderAccessControl { + suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt new file mode 100644 index 0000000000..d591e97f6f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAccountProviderAccessControl @Inject constructor( + private val enterpriseService: EnterpriseService, + private val elementWellknownRetriever: ElementWellknownRetriever, +) : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { + assertIsAllowedToConnectToAccountProvider( + title = accountProviderUrl, + accountProviderUrl = accountProviderUrl, + ) + true + } catch (_: AccountProviderAccessException) { + false + } + + @Throws(AccountProviderAccessException::class) + suspend fun assertIsAllowedToConnectToAccountProvider( + title: String, + accountProviderUrl: String, + ) { + if (enterpriseService.isEnterpriseBuild.not()) { + // Ensure that Element Pro is not required for this account provider + val wellKnown = elementWellknownRetriever.retrieve( + accountProviderUrl = accountProviderUrl.ensureProtocol(), + ) + if (wellKnown?.enforceElementPro == true) { + throw AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = title, + applicationId = ELEMENT_PRO_APPLICATION_ID, + ) + } + } + if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { + throw AccountProviderAccessException.UnauthorizedAccountProviderException( + unauthorisedAccountProviderTitle = title, + authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), + ) + } + } + + companion object { + const val ELEMENT_PRO_APPLICATION_ID = "io.element.enterprise" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/ElementWellknownRetriever.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/ElementWellknownRetriever.kt new file mode 100644 index 0000000000..e68809df07 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/ElementWellknownRetriever.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.impl.resolver.network.ElementWellKnown +import io.element.android.features.login.impl.resolver.network.WellknownAPI +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import timber.log.Timber +import javax.inject.Inject + +interface ElementWellknownRetriever { + suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? +} + +@ContributesBinding(AppScope::class) +class DefaultElementWellknownRetriever @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : ElementWellknownRetriever { + override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? { + val wellknownApi = try { + retrofitFactory.create(accountProviderUrl) + .create(WellknownAPI::class.java) + } catch (e: Exception) { + // If the base URL is not valid, we cannot retrieve the well-known data + Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl") + return null + } + return try { + wellknownApi.getElementWellKnown() + } catch (e: Exception) { + Timber.e(e, "Failed to retrieve Element well-known data for $accountProviderUrl") + null + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index c0cdf7c06d..3b75ee2578 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError @@ -27,7 +27,7 @@ import javax.inject.Inject class ChangeServerPresenter @Inject constructor( private val authenticationService: MatrixAuthenticationService, private val accountProviderDataSource: AccountProviderDataSource, - private val enterpriseService: EnterpriseService, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, ) : Presenter { @Composable override fun present(): ChangeServerState { @@ -55,12 +55,10 @@ class ChangeServerPresenter @Inject constructor( changeServerAction: MutableState>, ) = launch { suspend { - if (enterpriseService.isAllowedToConnectToHomeserver(data.url).not()) { - throw UnauthorizedAccountProviderException( - unauthorisedAccountProviderTitle = data.title, - authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), - ) - } + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( + title = data.title, + accountProviderUrl = data.url, + ) authenticationService.setHomeserver(data.url).map { authenticationService.getHomeserverDetails().value!! // Valid, remember user choice diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt index 2549109488..a97ff2dda1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -26,6 +26,14 @@ open class ChangeServerStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val eventSink = state.eventSink when (state.changeServerAction) { is AsyncData.Failure -> { @@ -56,6 +60,24 @@ fun ChangeServerView( } ) } + is ChangeServerError.NeedElementPro -> { + ConfirmationDialog( + modifier = modifier, + title = stringResource(R.string.screen_change_server_error_element_pro_required_title), + content = stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ), + submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android), + onSubmitClick = { + context.openGooglePlay(error.applicationId) + eventSink.invoke(ChangeServerEvents.ClearError) + }, + onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + }, + ) + } is ChangeServerError.UnauthorizedAccountProvider -> { ErrorDialog( modifier = modifier, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt index 1ceb2ab343..5c48f346fe 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt @@ -7,7 +7,14 @@ package io.element.android.features.login.impl.changeserver -class UnauthorizedAccountProviderException( - val unauthorisedAccountProviderTitle: String, - val authorisedAccountProviderTitles: List, -) : Exception() +sealed class AccountProviderAccessException : Exception() { + data class NeedElementProException( + val unauthorisedAccountProviderTitle: String, + val applicationId: String, + ) : AccountProviderAccessException() + + data class UnauthorizedAccountProviderException( + val unauthorisedAccountProviderTitle: String, + val authorisedAccountProviderTitles: List, + ) : AccountProviderAccessException() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt index 6d678854c1..5079e2a715 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt @@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.ui.strings.CommonStrings -sealed class ChangeServerError : Throwable() { +sealed class ChangeServerError : Exception() { data class Error( @StringRes val messageId: Int? = null, val messageStr: String? = null, @@ -26,6 +26,11 @@ sealed class ChangeServerError : Throwable() { fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown) } + data class NeedElementPro( + val unauthorisedAccountProviderTitle: String, + val applicationId: String, + ) : ChangeServerError() + data class UnauthorizedAccountProvider( val unauthorisedAccountProviderTitle: String, val authorisedAccountProviderTitles: List, @@ -37,7 +42,11 @@ sealed class ChangeServerError : Throwable() { fun from(error: Throwable): ChangeServerError = when (error) { is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert is AuthenticationException.Oidc -> Error(messageStr = error.message) - is UnauthorizedAccountProviderException -> UnauthorizedAccountProvider( + is AccountProviderAccessException.NeedElementProException -> NeedElementPro( + unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle, + applicationId = error.applicationId, + ) + is AccountProviderAccessException.UnauthorizedAccountProviderException -> UnauthorizedAccountProvider( unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle, authorisedAccountProviderTitles = error.authorisedAccountProviderTitles, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index abf1327913..9a2183b1f7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -8,12 +8,15 @@ package io.element.android.features.login.impl.login import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import io.element.android.features.login.impl.R import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.libraries.androidutils.system.openGooglePlay import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.matrix.api.auth.OidcDetails @@ -28,6 +31,7 @@ fun LoginModeView( onNeedLoginPassword: () -> Unit, onCreateAccountContinue: (url: String) -> Unit ) { + val context = LocalContext.current when (loginMode) { is AsyncData.Failure -> { when (val error = loginMode.error) { @@ -48,6 +52,21 @@ fun LoginModeView( onDismiss = onClearError, ) } + is ChangeServerError.NeedElementPro -> { + ConfirmationDialog( + title = stringResource(R.string.screen_change_server_error_element_pro_required_title), + content = stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ), + submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android), + onSubmitClick = { + context.openGooglePlay(error.applicationId) + onClearError() + }, + onDismiss = onClearError, + ) + } is ChangeServerError.UnauthorizedAccountProvider -> { ErrorDialog( content = stringResource( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt index 2ed54bc4e2..72c4b2e19b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt @@ -23,4 +23,7 @@ import kotlinx.serialization.Serializable data class ElementWellKnown( @SerialName("registration_helper_url") val registrationHelperUrl: String? = null, + + @SerialName("enforce_element_pro") + val enforceElementPro: Boolean? = null, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 37dce8e4b5..90e4e99c37 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -21,6 +21,7 @@ import dagger.assisted.AssistedInject import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter @@ -34,6 +35,7 @@ class OnBoardingPresenter @AssistedInject constructor( private val buildMeta: BuildMeta, private val featureFlagService: FeatureFlagService, private val enterpriseService: EnterpriseService, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val loginHelper: LoginHelper, ) : Presenter { @@ -63,7 +65,12 @@ class OnBoardingPresenter @AssistedInject constructor( val linkAccountProvider by produceState(initialValue = null) { // Account provider from the link, if allowed by the enterprise service value = params.accountProvider?.takeIf { - enterpriseService.isAllowedToConnectToHomeserver(it) + try { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it) + true + } catch (_: Exception) { + false + } } } val defaultAccountProvider = remember(linkAccountProvider) { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt index 49b94ac504..9be601f775 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt @@ -15,8 +15,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.qrcode.QrCodeLoginManager import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter @@ -38,7 +37,7 @@ class QrCodeScanPresenter @Inject constructor( private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, private val qrCodeLoginManager: QrCodeLoginManager, private val coroutineDispatchers: CoroutineDispatchers, - private val enterpriseService: EnterpriseService, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, ) : Presenter { private var isScanning by mutableStateOf(true) @@ -97,10 +96,10 @@ class QrCodeScanPresenter @Inject constructor( Timber.e(it, "Error parsing QR code data") }.getOrThrow() val serverName = data.serverName() - if (serverName != null && enterpriseService.isAllowedToConnectToHomeserver(serverName).not()) { - throw UnauthorizedAccountProviderException( - unauthorisedAccountProviderTitle = serverName, - authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), + if (serverName != null) { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( + title = serverName, + accountProviderUrl = serverName, ) } data diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt index cdea9f8b41..dd0c09344e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt @@ -8,7 +8,7 @@ package io.element.android.features.login.impl.screens.qrcode.scan import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException @@ -23,12 +23,21 @@ open class QrCodeScanStateProvider : PreviewParameterProvider { aQrCodeScanState( isScanning = false, authenticationAction = AsyncAction.Failure( - UnauthorizedAccountProviderException( + AccountProviderAccessException.UnauthorizedAccountProviderException( unauthorisedAccountProviderTitle = "example.com", authorisedAccountProviderTitles = listOf("element.io", "element.org"), ) ) ), + aQrCodeScanState( + isScanning = false, + authenticationAction = AsyncAction.Failure( + AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = "example.com", + applicationId = "applicationId" + ) + ) + ), // Add other state here ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt index 0c5598c528..c4892e9c3e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.components.BigIcon @@ -145,7 +145,10 @@ private fun ColumnScope.Buttons( Spacer(modifier = Modifier.width(4.dp)) Text( text = when (error) { - is UnauthorizedAccountProviderException -> { + is AccountProviderAccessException.NeedElementProException -> { + stringResource(R.string.screen_change_server_error_element_pro_required_title) + } + is AccountProviderAccessException.UnauthorizedAccountProviderException -> { stringResource( id = R.string.screen_change_server_error_unauthorized_homeserver_title, error.unauthorisedAccountProviderTitle, @@ -163,7 +166,13 @@ private fun ColumnScope.Buttons( } Text( text = when (error) { - is UnauthorizedAccountProviderException -> { + is AccountProviderAccessException.NeedElementProException -> { + stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ) + } + is AccountProviderAccessException.UnauthorizedAccountProviderException -> { stringResource( id = R.string.screen_change_server_error_unauthorized_homeserver_content, error.authorisedAccountProviderTitles.joinToString(), diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 8a45096617..9b235558c8 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -13,6 +13,7 @@ "Other" "Use a different account provider, such as your own private server or a work account." "Change account provider" + "Google Play" "The Element Pro app is required on %1$s. Please download it from the store." "Element Pro required" "We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help." diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt new file mode 100644 index 0000000000..87ec750b36 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.features.login.impl.resolver.network.ElementWellKnown +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultAccountProviderAccessControlTest { + @Test + fun `foss build should not allow using account provider that enforce enterprise build`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = ElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectNeedElementProException() + } + + @Test + fun `foss build should not allow using account provider that enforce enterprise build taking precedence over authorization`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + // false here. + isAllowedToConnectToHomeserver = false, + elementWellKnown = ElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectNeedElementProException() + } + + @Test + fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = ElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should allow using account provider twith missing key in wellknown`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = ElementWellKnown( + enforceElementPro = null, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should allow using account provider twith missing wellknown`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = null, + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = ElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + @Test + fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = true, + elementWellKnown = ElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = true, + elementWellKnown = ElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = ElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + @Test + fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = ElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + private fun createDefaultAccountProviderAccessControl( + isEnterpriseBuild: Boolean = false, + isAllowedToConnectToHomeserver: Boolean = false, + allowedAccountProviders: List = emptyList(), + elementWellKnown: ElementWellKnown? = null, + ) = DefaultAccountProviderAccessControl( + enterpriseService = FakeEnterpriseService( + isEnterpriseBuild = isEnterpriseBuild, + isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, + defaultHomeserverListResult = { allowedAccountProviders }, + ), + elementWellknownRetriever = FakeElementWellknownRetriever( + retrieveResult = { elementWellKnown } + ), + ) + + private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { + val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) { + runTest { + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + } + } + assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) + assertThat(exception.applicationId).isEqualTo("io.element.enterprise") + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isFalse() + } + } + + private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() { + val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) { + runTest { + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + } + } + assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) + assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2) + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isFalse() + } + } + + private suspend fun DefaultAccountProviderAccessControl.expectAllowed() { + // If no exception is thrown, the test passes + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isTrue() + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/FakeElementWellknownRetriever.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/FakeElementWellknownRetriever.kt new file mode 100644 index 0000000000..70854302c8 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/FakeElementWellknownRetriever.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import io.element.android.features.login.impl.resolver.network.ElementWellKnown +import io.element.android.tests.testutils.simulateLongTask + +class FakeElementWellknownRetriever( + private val retrieveResult: (String) -> ElementWellKnown? = { null }, +) : ElementWellknownRetriever { + override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? = simulateLongTask { + retrieveResult(accountProviderUrl) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 17c4cc0ae7..a096024141 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -10,10 +10,15 @@ package io.element.android.features.login.impl.changeserver import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever +import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.features.login.impl.resolver.network.ElementWellKnown import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -106,13 +111,48 @@ class ChangeServerPresenterTest { } } + @Test + fun `present - change server element pro required error`() = runTest { + val retrieveResult = lambdaRecorder { + ElementWellKnown( + enforceElementPro = true, + ) + } + createPresenter( + elementWellknownRetriever = FakeElementWellknownRetriever( + retrieveResult = retrieveResult, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val failureState = awaitItem() + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle + ).isEqualTo(anAccountProvider.title) + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId + ).isEqualTo("io.element.enterprise") + retrieveResult.assertions() + .isCalledOnce() + .with(value(A_HOMESERVER_URL.ensureProtocol())) + } + } + private fun createPresenter( authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), enterpriseService: EnterpriseService = FakeEnterpriseService(), + elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), ) = ChangeServerPresenter( authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, - enterpriseService = enterpriseService, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + elementWellknownRetriever = elementWellknownRetriever, + ), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 3e59528427..ae00099687 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -12,6 +12,9 @@ import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever +import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever @@ -235,6 +238,7 @@ private fun createPresenter( buildMeta: BuildMeta = aBuildMeta(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), enterpriseService: EnterpriseService = FakeEnterpriseService(), + elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), rageshakeFeatureAvailability: () -> Boolean = { true }, loginHelper: LoginHelper = createLoginHelper(), ) = OnBoardingPresenter( @@ -242,6 +246,10 @@ private fun createPresenter( buildMeta = buildMeta, featureFlagService = featureFlagService, enterpriseService = enterpriseService, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + elementWellknownRetriever = elementWellknownRetriever, + ), rageshakeFeatureAvailability = rageshakeFeatureAvailability, loginHelper = loginHelper, ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt index ccf2fe4988..2d6cdf71dc 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt @@ -13,7 +13,10 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever +import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep @@ -91,9 +94,15 @@ class QrCodeScanPresenterTest { assertThat(awaitItem().isScanning).isFalse() assertThat(awaitItem().authenticationAction.isLoading()).isTrue() awaitItem().also { state -> - assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle) + assertThat( + (state.authenticationAction + .errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle + ) .isEqualTo("example.com") - assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).authorisedAccountProviderTitles) + assertThat( + (state.authenticationAction + .errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles + ) .containsExactly("element.io") } } @@ -153,10 +162,14 @@ class QrCodeScanPresenterTest { coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), enterpriseService: EnterpriseService = FakeEnterpriseService(), + elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), ) = QrCodeScanPresenter( qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginManager = qrCodeLoginManager, coroutineDispatchers = coroutineDispatchers, - enterpriseService = enterpriseService, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + elementWellknownRetriever = elementWellknownRetriever, + ), ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 8e45262da1..49895e47ec 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -165,6 +165,7 @@ fun Context.startSharePlainTextIntent( fun Context.openUrlInExternalApp( url: String, errorMessage: String = getString(R.string.error_no_compatible_app_found), + throwInCaseOfError: Boolean = false, ) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) if (this !is Activity) { @@ -173,10 +174,27 @@ fun Context.openUrlInExternalApp( try { startActivity(intent) } catch (activityNotFoundException: ActivityNotFoundException) { + if (throwInCaseOfError) throw activityNotFoundException toast(errorMessage) } } +/** + * Open Google Play on the provided application Id. + */ +fun Context.openGooglePlay( + appId: String, +) { + try { + openUrlInExternalApp( + url = "market://details?id=$appId", + throwInCaseOfError = true, + ) + } catch (_: ActivityNotFoundException) { + openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId") + } +} + // Not in KTX anymore fun Context.toast(resId: Int) { Toast.makeText(this, resId, Toast.LENGTH_SHORT).show() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 87b8a348dc..a63c301ce0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -69,6 +69,7 @@ const val A_REDACTION_REASON = "A redaction reason" const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL_2 = "matrix-client.org" +const val AN_ACCOUNT_PROVIDER_URL = "https://account.provider.org" const val AN_ACCOUNT_PROVIDER = "matrix.org" const val AN_ACCOUNT_PROVIDER_2 = "element.io" const val AN_ACCOUNT_PROVIDER_3 = "other.io" From 0ce65f5feedfe2986895bb0dc977d1e1576c4950 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 6 Aug 2025 08:37:58 +0000 Subject: [PATCH 12/31] Update screenshots --- ...tures.login.impl.changeserver_ChangeServerView_Day_4_en.png | 3 +++ ...res.login.impl.changeserver_ChangeServerView_Night_4_en.png | 3 +++ ....login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png | 3 +++ ...ogin.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Day_4_en.png new file mode 100644 index 0000000000..dc70ab1442 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f682f46ad20ea337ed76ca04e9a8f09f2b214e3f900a69463676d97675579e32 +size 26776 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Night_4_en.png new file mode 100644 index 0000000000..1b4092d421 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.changeserver_ChangeServerView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f63e4f092e4bd7e6d0eeec2f4176963b2460d1d434a8a7ecc89a72c34ec3b3a9 +size 25068 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png new file mode 100644 index 0000000000..6870a05265 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83ba365f6653a558c2e85f981477fb9f044ad57006c8c44f51435130ff773216 +size 30457 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png new file mode 100644 index 0000000000..ffd600c8a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bb3cc28228eb14cb0552a5ab3e42f3188e70076a6697c4199acab1ca7ac4142 +size 29240 From 3ac746247462ebb0f2f46ca05653aedb663535c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 6 Aug 2025 11:12:22 +0200 Subject: [PATCH 13/31] Rename file to match class name. --- ...ountProviderException.kt => AccountProviderAccessException.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/{UnauthorizedAccountProviderException.kt => AccountProviderAccessException.kt} (100%) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt similarity index 100% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt From 4f831547f896245ce7036425f2127d9609797c54 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 6 Aug 2025 11:13:11 +0200 Subject: [PATCH 14/31] Remove blank line --- .../features/login/impl/resolver/network/ElementWellKnown.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt index 72c4b2e19b..4f0073455a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/ElementWellKnown.kt @@ -23,7 +23,6 @@ import kotlinx.serialization.Serializable data class ElementWellKnown( @SerialName("registration_helper_url") val registrationHelperUrl: String? = null, - @SerialName("enforce_element_pro") val enforceElementPro: Boolean? = null, ) From c963a22adf60675162affbe747e009cf514eaf3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 6 Aug 2025 14:14:18 +0200 Subject: [PATCH 15/31] Changelog for version 25.08.1 --- CHANGES.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index e0fd96e8e9..3563713bf5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,22 @@ +Changes in Element X v25.08.1 +============================= + + + +## What's Changed +### 🙌 Improvements +* Force last owner of a room to pass ownership when leaving by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5094 +### 🐛 Bugfixes +* Reload room member list when active members count changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5129 +* Delegate call notifications to Element Call, upgrade SDK and EC embedded by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5119 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5112 +### Dependency upgrades +* Update media3 to v1.8.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5101 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.0...v25.08.1 + Changes in Element X v25.08.0 ============================= From c9c6652c7636eb7038315842c665adeb94fd49c1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 6 Aug 2025 14:40:37 +0200 Subject: [PATCH 16/31] Add missing preview on LoginModeView --- .../impl/error/ChangeServerErrorProvider.kt | 32 +++++++++++++++++++ .../login/impl/login/LoginModeView.kt | 19 +++++++++++ 2 files changed, 51 insertions(+) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt new file mode 100644 index 0000000000..333347851a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.error + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.R + +class ChangeServerErrorProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ChangeServerError.Error( + messageId = R.string.screen_change_server_error_invalid_homeserver, + ), + ChangeServerError.Error( + messageStr = "An error description", + ), + ChangeServerError.NeedElementPro( + unauthorisedAccountProviderTitle = "element.io", + applicationId = "io.element.enterprise", + ), + ChangeServerError.UnauthorizedAccountProvider( + unauthorisedAccountProviderTitle = "element.io", + authorisedAccountProviderTitles = listOf("provider.org", "provider.io"), + ), + ChangeServerError.SlidingSyncAlert, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index 9a2183b1f7..73127281bc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -10,14 +10,18 @@ package io.element.android.features.login.impl.login import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.login.impl.R import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.features.login.impl.error.ChangeServerErrorProvider import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.libraries.androidutils.system.openGooglePlay import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.ui.strings.CommonStrings @@ -106,3 +110,18 @@ fun LoginModeView( AsyncData.Uninitialized -> Unit } } + +@PreviewsDayNight +@Composable +internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) { + ElementPreview { + LoginModeView( + loginMode = AsyncData.Failure(error), + onClearError = {}, + onLearnMoreClick = {}, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onCreateAccountContinue = {} + ) + } +} From cbc776ecbbfed599a77a18fd019ce699337d85f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:41:29 +0200 Subject: [PATCH 17/31] Update dependency io.nlopez.compose.rules:detekt to v0.4.27 (#5123) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9242f1c2d0..fd8403c66e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,7 @@ allprojects { config.from(files("$rootDir/tools/detekt/detekt.yml")) } dependencies { - detektPlugins("io.nlopez.compose.rules:detekt:0.4.26") + detektPlugins("io.nlopez.compose.rules:detekt:0.4.27") detektPlugins(project(":tests:detekt-rules")) } From c60f14c20a0c6691abc8170f28f338d20efb5700 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:56:26 +0200 Subject: [PATCH 18/31] Update actions/download-artifact action to v5 (#5122) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/maestro-local.yml | 2 +- .github/workflows/quality.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index cfb743934f..9deb7c61b4 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -69,7 +69,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.ref }} - name: Download APK artifact from previous job - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: elementx-apk-maestro - name: Enable KVM group perms diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index bb6bbe87d1..f66086ad06 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -284,7 +284,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - name: Download reports from previous jobs - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 - name: Prepare Danger if: always() run: | From d48ace62448f866f275bf2faaf0cd8cefa67a3cd Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 6 Aug 2025 12:57:33 +0000 Subject: [PATCH 19/31] Update screenshots --- .../features.login.impl.login_LoginModeView_Day_0_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Day_1_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Day_2_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Day_3_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Day_4_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_0_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_1_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_2_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_3_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_4_en.png | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_0_en.png new file mode 100644 index 0000000000..856a552949 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baa26e4da095cdd3f17d90bfd17a410d4d04c2c255287afdd2f6212764e2417d +size 32142 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_1_en.png new file mode 100644 index 0000000000..6b516cfb64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77f1ad1ffa6b0f8d8e23a276464c5b9930ec7a694b1646e80b135c1de6d99893 +size 11352 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_2_en.png new file mode 100644 index 0000000000..7058503c79 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:182d98e4af86fa766fc1fd51fa1b295875cdd7b8523716eac6875cda8d78205b +size 26213 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_3_en.png new file mode 100644 index 0000000000..e590807856 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1042d3c9994ff687684f47f3e4c64d1d0e7fdb4c35f4b898024b1fdc187fe503 +size 15322 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_4_en.png new file mode 100644 index 0000000000..2d6df8b039 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcaa6f6abb01095e62ece79207536c035e70335403a4c6d73c7e18bdc7d0aa5d +size 27024 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_0_en.png new file mode 100644 index 0000000000..cec5c96996 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e84510ac41d16fd20099a2e8d3bedb0b062796d6d8e709b24ce327d0d17d071e +size 30022 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_1_en.png new file mode 100644 index 0000000000..cf5a65305f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebb8817e231bfae265698c45d030aa055f43848976993de600ae375cb5831f52 +size 9946 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_2_en.png new file mode 100644 index 0000000000..579e8477ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:628864c47de8fa14e821cf9e4f141888eb70bd4df629c523bd39c0674c6d4fae +size 24426 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_3_en.png new file mode 100644 index 0000000000..2fd9b546c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:248864ef59f926d634045de482019dd9f9927d96cde2a34efeadae0b005e95fb +size 13580 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_4_en.png new file mode 100644 index 0000000000..ab7ccfe7fd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:066175e7a3408f163dc0b1126b3644ab83d08f2627bd0429e3fa4a14e1444c03 +size 25335 From 6079e5a4d69f3f5964fc4f508987c37675f49a15 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 6 Aug 2025 16:13:07 +0200 Subject: [PATCH 20/31] Avoid code duplication --- .../android/appnav/di/MatrixSessionCache.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index 302a12d99b..0941c7c8cc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -42,12 +42,7 @@ class MatrixSessionCache @Inject constructor( init { authenticationService.listenToNewMatrixClients { matrixClient -> - val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) - sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( - matrixClient = matrixClient, - syncOrchestrator = syncOrchestrator, - ) - syncOrchestrator.start() + onMatrixClient(matrixClient) } } @@ -105,17 +100,21 @@ class MatrixSessionCache @Inject constructor( Timber.d("Restore matrix session: $sessionId") return authenticationService.restoreSession(sessionId) .onSuccess { matrixClient -> - val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) - sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( - matrixClient = matrixClient, - syncOrchestrator = syncOrchestrator, - ) - syncOrchestrator.start() + onMatrixClient(matrixClient) } .onFailure { Timber.e(it, "Fail to restore session") } } + + private fun onMatrixClient(matrixClient: MatrixClient) { + val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) + sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( + matrixClient = matrixClient, + syncOrchestrator = syncOrchestrator, + ) + syncOrchestrator.start() + } } private data class InMemoryMatrixSession( From 5577d2ca9beec52dc9a2f126a9e5e444a1cc463d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 11:31:05 +0200 Subject: [PATCH 21/31] Store log files in subfolder based on the homeserver domain. --- .../x/initializer/PlatformInitializer.kt | 14 +----- .../io/element/android/appnav/RootFlowNode.kt | 3 ++ features/rageshake/api/build.gradle.kts | 1 + .../logs/WriteToFilesConfigurationFactory.kt | 20 +++++++++ .../rageshake/api/reporter/BugReporter.kt | 8 ++++ .../impl/reporter/DefaultBugReporter.kt | 44 ++++++++++++++++--- .../matrix/api/tracing/TracingService.kt | 2 + .../auth/RustMatrixAuthenticationService.kt | 13 +++--- .../matrix/impl/tracing/RustTracingService.kt | 7 +++ 9 files changed, 90 insertions(+), 22 deletions(-) create mode 100644 features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt index 6dd0d69dc0..40016922ae 100644 --- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt +++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt @@ -10,11 +10,10 @@ package io.element.android.x.initializer import android.content.Context import android.system.Os import androidx.startup.Initializer -import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.libraries.architecture.bindings import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.tracing.TracingConfiguration -import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration import io.element.android.x.di.AppBindings import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -34,7 +33,7 @@ class PlatformInitializer : Initializer { val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() } val tracingConfiguration = TracingConfiguration( writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) }, - writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter), + writesToFilesConfiguration = bugReporter.createWriteToFilesConfiguration(), logLevel = logLevel, extraTargets = listOf(ELEMENT_X_TARGET), traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() }, @@ -45,14 +44,5 @@ class PlatformInitializer : Initializer { Os.setenv("RUST_BACKTRACE", "1", true) } - private fun defaultWriteToDiskConfiguration(bugReporter: BugReporter): WriteToFilesConfiguration.Enabled { - return WriteToFilesConfiguration.Enabled( - directory = bugReporter.logDirectory().absolutePath, - filenamePrefix = "logs", - // Keep a maximum of 1 week of log files. - numberOfFiles = 7 * 24, - ) - } - override fun dependencies(): List>> = mutableListOf() } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index ae9f935061..db512ff2ea 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -36,6 +36,7 @@ import io.element.android.appnav.root.RootView import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.login.api.LoginParams import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.viewfolder.api.ViewFolderEntryPoint import io.element.android.libraries.architecture.BackstackView @@ -73,6 +74,7 @@ class RootFlowNode @AssistedInject constructor( private val signedOutEntryPoint: SignedOutEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, + private val bugReporter: BugReporter, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -123,6 +125,7 @@ class RootFlowNode @AssistedInject constructor( private fun switchToNotLoggedInFlow(params: LoginParams?) { matrixSessionCache.removeAll() + bugReporter.setLogDirectorySubfolder(null) backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) } diff --git a/features/rageshake/api/build.gradle.kts b/features/rageshake/api/build.gradle.kts index f47b748c3e..7fe1620613 100644 --- a/features/rageshake/api/build.gradle.kts +++ b/features/rageshake/api/build.gradle.kts @@ -16,5 +16,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) implementation(projects.libraries.uiStrings) } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt new file mode 100644 index 0000000000..52298b5414 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.logs + +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration + +fun BugReporter.createWriteToFilesConfiguration(): WriteToFilesConfiguration { + return WriteToFilesConfiguration.Enabled( + directory = logDirectory().absolutePath, + filenamePrefix = "logs", + // Keep a maximum of 1 week of log files. + numberOfFiles = 7 * 24, + ) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt index 1cc3cc8908..bb625370cf 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -34,6 +34,14 @@ interface BugReporter { */ fun logDirectory(): File + /** + * Set the subfolder name for the log directory. + * This will create a subfolder in the log directory with the given name. + * It will also configure the Rust SDK to use this subfolder for its logs. + * If the name is null, the log files will be stored in the base folder for the logs. + */ + fun setLogDirectorySubfolder(subfolderName: String?) + /** * Set the current tracing log level. */ diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index b9ed1c7778..5f3195c32f 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -13,6 +13,7 @@ import androidx.core.net.toFile import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding import io.element.android.appconfig.RageshakeConfig +import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.impl.crash.CrashDataStore @@ -28,11 +29,14 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.tracing.TracingService import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient @@ -71,6 +75,8 @@ class DefaultBugReporter @Inject constructor( private val bugReporterUrlProvider: BugReporterUrlProvider, private val sdkMetadata: SdkMetadata, private val matrixClientProvider: MatrixClientProvider, + private val tracingService: TracingService, + matrixAuthenticationService: MatrixAuthenticationService, ) : BugReporter { companion object { // filenames @@ -81,7 +87,21 @@ class DefaultBugReporter @Inject constructor( private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") private var currentTracingLogLevel: String? = null - private val logCatErrFile = File(logDirectory().absolutePath, LOG_CAT_FILENAME) + private val logCatErrFile: File + get() = File(logDirectory(), LOG_CAT_FILENAME) + private val baseLogDirectory = File(context.cacheDir, LOG_DIRECTORY_NAME) + private var currentLogDirectory: File = baseLogDirectory + + init { + val logSubfolder = runBlocking { + sessionStore.getLatestSession() + }?.userId?.substringAfter(":") + setCurrentLogDirectory(logSubfolder) + matrixAuthenticationService.listenToNewMatrixClients { + // When a new Matrix client is created, we update the tracing configuration to write to files + setLogDirectorySubfolder(it.userIdServerName()) + } + } override suspend fun sendBugReport( withDevicesLogs: Boolean, @@ -286,11 +306,24 @@ class DefaultBugReporter @Inject constructor( } override fun logDirectory(): File { - return File(context.cacheDir, LOG_DIRECTORY_NAME).apply { + return currentLogDirectory.apply { mkdirs() } } + override fun setLogDirectorySubfolder(subfolderName: String?) { + setCurrentLogDirectory(subfolderName) + tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration()) + } + + private fun setCurrentLogDirectory(subfolderName: String?) { + currentLogDirectory = if (subfolderName == null) { + baseLogDirectory + } else { + File(baseLogDirectory, subfolderName) + } + } + suspend fun deleteAllFiles(predicate: (File) -> Boolean) { withContext(coroutineDispatchers.io) { getLogFiles() @@ -325,11 +358,12 @@ class DefaultBugReporter @Inject constructor( * @return the file if the operation succeeds */ override fun saveLogCat() { - if (logCatErrFile.exists()) { - logCatErrFile.safeDelete() + val file = logCatErrFile + if (file.exists()) { + file.safeDelete() } try { - logCatErrFile.writer().use { + file.writer().use { getLogCatError(it) } } catch (error: OutOfMemoryError) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt index 5cef6cde02..8c183c80b4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt @@ -11,4 +11,6 @@ import timber.log.Timber interface TracingService { fun createTimberTree(target: String): Timber.Tree + + fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index a1ffbc5da9..1a044a9f4a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -71,9 +71,9 @@ class RustMatrixAuthenticationService @Inject constructor( private var currentClient: Client? = null private var currentHomeserver = MutableStateFlow(null) - private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null + private val newMatrixClientObservers = mutableListOf<(MatrixClient) -> Unit>() override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) { - newMatrixClientObserver = lambda + newMatrixClientObservers.add(lambda) } private fun rotateSessionPath(): SessionPaths { @@ -155,7 +155,8 @@ class RustMatrixAuthenticationService @Inject constructor( passphrase = pendingPassphrase, sessionPaths = currentSessionPaths, ) - newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.storeData(sessionData) // Clean up the strong reference held here since it's no longer necessary @@ -246,7 +247,8 @@ class RustMatrixAuthenticationService @Inject constructor( pendingOAuthAuthorizationData?.close() pendingOAuthAuthorizationData = null - newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.storeData(sessionData) // Clean up the strong reference held here since it's no longer necessary @@ -290,7 +292,8 @@ class RustMatrixAuthenticationService @Inject constructor( passphrase = pendingPassphrase, sessionPaths = emptySessionPaths, ) - newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.storeData(sessionData) // Clean up the strong reference held here since it's no longer necessary diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt index 912561d843..728bf136b8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import io.element.android.libraries.matrix.api.tracing.TracingService import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration import org.matrix.rustcomponents.sdk.TracingFileConfiguration +import org.matrix.rustcomponents.sdk.reloadTracingFileWriter import timber.log.Timber import javax.inject.Inject @@ -23,6 +24,12 @@ class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) : override fun createTimberTree(target: String): Timber.Tree { return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable) } + + override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) { + config.toTracingFileConfiguration()?.let { + reloadTracingFileWriter(it) + } + } } private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel { From d71b639ada063aa3b67be978ae0d687dc7320a42 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 11:40:38 +0200 Subject: [PATCH 22/31] Ensure that deleteAllFiles will check all the log files. --- .../impl/reporter/DefaultBugReporter.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 5f3195c32f..60b4274689 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -326,9 +326,22 @@ class DefaultBugReporter @Inject constructor( suspend fun deleteAllFiles(predicate: (File) -> Boolean) { withContext(coroutineDispatchers.io) { - getLogFiles() - .filter(predicate) - .forEach { it.safeDelete() } + deleteAllFilesRecursive(baseLogDirectory, predicate) + } + } + + private fun deleteAllFilesRecursive( + directory: File, + predicate: (File) -> Boolean, + ) { + directory.listFiles()?.forEach { file -> + if (file.isDirectory) { + deleteAllFilesRecursive(file, predicate) + } else { + if (predicate(file)) { + file.safeDelete() + } + } } } From 0c215f2d1e553ec53c7b6f1b3111dc2bde6e3432 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 12:19:26 +0200 Subject: [PATCH 23/31] Fix tests --- .../impl/bugreport/FakeBugReporter.kt | 4 ++ .../impl/reporter/DefaultBugReporterTest.kt | 59 +++++++------------ .../matrix/test/tracing/FakeTracingService.kt | 26 ++++++++ 3 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt index 1f10cd0ab1..ebaa524bd5 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -53,6 +53,10 @@ class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter { return File("fake") } + override fun setLogDirectorySubfolder(subfolderName: String?) { + // No op + } + override fun setCurrentTracingLogLevel(logLevel: String) { // No op } diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index e73c7863b4..d85c35595c 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -10,14 +10,19 @@ package io.element.android.features.rageshake.impl.reporter import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.RageshakeConfig import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.impl.crash.CrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.tracing.FakeTracingService import io.element.android.libraries.network.useragent.DefaultUserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.testCoroutineDispatchers @@ -45,7 +50,7 @@ class DefaultBugReporterTest { .setResponseCode(200) ) server.start() - val sut = createDefaultBugReporter(server) + val sut = createDefaultBugReporter(server = server) var onUploadCancelledCalled = false var onUploadFailedCalled = false val progressValues = mutableListOf() @@ -97,22 +102,14 @@ class DefaultBugReporterTest { storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH")) } - val buildMeta = aBuildMeta() val fakeEncryptionService = FakeEncryptionService() val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") - val sut = DefaultBugReporter( - context = RuntimeEnvironment.getApplication(), - screenshotHolder = FakeScreenshotHolder(), + val sut = createDefaultBugReporter( + server = server, crashDataStore = FakeCrashDataStore(), - coroutineDispatchers = testCoroutineDispatchers(), - okHttpClient = { OkHttpClient.Builder().build() }, - userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), sessionStore = mockSessionStore, - buildMeta = buildMeta, - bugReporterUrlProvider = { server.url("/") }, - sdkMetadata = FakeSdkMetadata("123456789"), matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) ) @@ -166,22 +163,13 @@ class DefaultBugReporterTest { storeData(aSessionData("@foo:example.com", "ABCDEFGH")) } - val buildMeta = aBuildMeta() val fakeEncryptionService = FakeEncryptionService() val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) fakeEncryptionService.givenDeviceKeys(null, null) - val sut = DefaultBugReporter( - context = RuntimeEnvironment.getApplication(), - screenshotHolder = FakeScreenshotHolder(), - crashDataStore = FakeCrashDataStore(), - coroutineDispatchers = testCoroutineDispatchers(), - okHttpClient = { OkHttpClient.Builder().build() }, - userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + val sut = createDefaultBugReporter( + server = server, sessionStore = mockSessionStore, - buildMeta = buildMeta, - bugReporterUrlProvider = { server.url("/") }, - sdkMetadata = FakeSdkMetadata("123456789"), matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) ) @@ -209,21 +197,13 @@ class DefaultBugReporterTest { ) server.start() - val buildMeta = aBuildMeta() val fakeEncryptionService = FakeEncryptionService() fakeEncryptionService.givenDeviceKeys(null, null) - val sut = DefaultBugReporter( - context = RuntimeEnvironment.getApplication(), - screenshotHolder = FakeScreenshotHolder(), + val sut = createDefaultBugReporter( + server = server, crashDataStore = FakeCrashDataStore("I did crash", true), - coroutineDispatchers = testCoroutineDispatchers(), - okHttpClient = { OkHttpClient.Builder().build() }, - userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), sessionStore = InMemorySessionStore(), - buildMeta = buildMeta, - bugReporterUrlProvider = { server.url("/") }, - sdkMetadata = FakeSdkMetadata("123456789"), matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) ) @@ -276,7 +256,7 @@ class DefaultBugReporterTest { .setBody("""{"error": "An error body"}""") ) server.start() - val sut = createDefaultBugReporter(server) + val sut = createDefaultBugReporter(server = server) var onUploadCancelledCalled = false var onUploadFailedCalled = false var onUploadFailedReason: String? = null @@ -319,21 +299,26 @@ class DefaultBugReporterTest { } private fun TestScope.createDefaultBugReporter( - server: MockWebServer + sessionStore: SessionStore = InMemorySessionStore(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + crashDataStore: CrashDataStore = FakeCrashDataStore(), + server: MockWebServer = MockWebServer(), ): DefaultBugReporter { val buildMeta = aBuildMeta() return DefaultBugReporter( context = RuntimeEnvironment.getApplication(), screenshotHolder = FakeScreenshotHolder(), - crashDataStore = FakeCrashDataStore(), + crashDataStore = crashDataStore, coroutineDispatchers = testCoroutineDispatchers(), okHttpClient = { OkHttpClient.Builder().build() }, userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), - sessionStore = InMemorySessionStore(), + sessionStore = sessionStore, buildMeta = buildMeta, bugReporterUrlProvider = { server.url("/") }, sdkMetadata = FakeSdkMetadata("123456789"), - matrixClientProvider = FakeMatrixClientProvider() + matrixClientProvider = matrixClientProvider, + tracingService = FakeTracingService(), + matrixAuthenticationService = FakeMatrixAuthenticationService(), ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt new file mode 100644 index 0000000000..52ffe8f32d --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.tracing + +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import io.element.android.tests.testutils.lambda.lambdaError +import timber.log.Timber + +class FakeTracingService( + private val createTimberTreeResult: (String) -> Timber.Tree = { lambdaError() }, + private val updateWriteToFilesConfigurationResult: (WriteToFilesConfiguration) -> Unit = { lambdaError() } +) : TracingService { + override fun createTimberTree(target: String): Timber.Tree { + return createTimberTreeResult(target) + } + + override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) { + updateWriteToFilesConfigurationResult(config) + } +} From 0b0eab22817c964983e52cec0a8d618e2790de38 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 12:38:10 +0200 Subject: [PATCH 24/31] Add tests --- .../impl/reporter/DefaultBugReporterTest.kt | 93 ++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index d85c35595c..05fcbd26df 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -14,6 +14,9 @@ import io.element.android.features.rageshake.impl.crash.CrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -25,6 +28,7 @@ import io.element.android.libraries.network.useragent.DefaultUserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -298,11 +302,96 @@ class DefaultBugReporterTest { assertThat(onUploadSucceedCalled).isFalse() } + @Test + fun `the log directory is initialized using the last session store data`() = runTest { + val sut = createDefaultBugReporter( + sessionStore = InMemorySessionStore().apply { + storeData(aSessionData(sessionId = "@alice:domain.com")) + } + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.com") + } + + @Test + fun `when the log directory is updated, the tracing service is invoked`() = runTest { + var param: WriteToFilesConfiguration? = null + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sut = createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + sut.setLogDirectorySubfolder("my.sub.folder") + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + assertThat(param).isNotNull() + assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java) + assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs/my.sub.folder") + assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs") + assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168) + assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") + } + + @Test + fun `when the log directory is reset, the tracing service is invoked`() = runTest { + var param: WriteToFilesConfiguration? = null + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sut = createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + sut.setLogDirectorySubfolder(null) + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + assertThat(param).isNotNull() + assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java) + assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs") + assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs") + assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168) + assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") + } + + @Test + fun `when a new MatrixClient is created the logs folder is updated`() = runTest { + var param: WriteToFilesConfiguration? = null + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + givenMatrixClient( + FakeMatrixClient( + userIdServerNameLambda = { "domain.foo.org" }, + ) + ) + } + val sut = createDefaultBugReporter( + matrixAuthenticationService = matrixAuthenticationService, + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs") + matrixAuthenticationService.login("alice", "password") + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.foo.org") + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + assertThat(param).isNotNull() + assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java) + assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs/domain.foo.org") + assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs") + assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168) + assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") + } + private fun TestScope.createDefaultBugReporter( sessionStore: SessionStore = InMemorySessionStore(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), crashDataStore: CrashDataStore = FakeCrashDataStore(), server: MockWebServer = MockWebServer(), + tracingService: TracingService = FakeTracingService(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), ): DefaultBugReporter { val buildMeta = aBuildMeta() return DefaultBugReporter( @@ -317,8 +406,8 @@ class DefaultBugReporterTest { bugReporterUrlProvider = { server.url("/") }, sdkMetadata = FakeSdkMetadata("123456789"), matrixClientProvider = matrixClientProvider, - tracingService = FakeTracingService(), - matrixAuthenticationService = FakeMatrixAuthenticationService(), + tracingService = tracingService, + matrixAuthenticationService = matrixAuthenticationService, ) } From 861758b47dd41f48026fdf346d0d9c842bd5ae4c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 14:02:07 +0200 Subject: [PATCH 25/31] Only change the log folder on enterprise build --- .../impl/reporter/DefaultBugReporter.kt | 23 ++++--- .../impl/reporter/DefaultBugReporterTest.kt | 67 ++++++++++++++++++- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 60b4274689..028b671eb6 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -93,13 +93,16 @@ class DefaultBugReporter @Inject constructor( private var currentLogDirectory: File = baseLogDirectory init { - val logSubfolder = runBlocking { - sessionStore.getLatestSession() - }?.userId?.substringAfter(":") - setCurrentLogDirectory(logSubfolder) - matrixAuthenticationService.listenToNewMatrixClients { - // When a new Matrix client is created, we update the tracing configuration to write to files - setLogDirectorySubfolder(it.userIdServerName()) + if (buildMeta.isEnterpriseBuild) { + val logSubfolder = runBlocking { + sessionStore.getLatestSession() + }?.userId?.substringAfter(":") + setCurrentLogDirectory(logSubfolder) + matrixAuthenticationService.listenToNewMatrixClients { + // When a new Matrix client is created, we update the tracing configuration to write + // the files in a dedicated subfolders. + setLogDirectorySubfolder(it.userIdServerName()) + } } } @@ -312,8 +315,10 @@ class DefaultBugReporter @Inject constructor( } override fun setLogDirectorySubfolder(subfolderName: String?) { - setCurrentLogDirectory(subfolderName) - tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration()) + if (buildMeta.isEnterpriseBuild) { + setCurrentLogDirectory(subfolderName) + tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration()) + } } private fun setCurrentLogDirectory(subfolderName: String?) { diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index 05fcbd26df..c7c424bfae 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -13,6 +13,7 @@ import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.impl.crash.CrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.tracing.TracingService @@ -305,6 +306,7 @@ class DefaultBugReporterTest { @Test fun `the log directory is initialized using the last session store data`() = runTest { val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), sessionStore = InMemorySessionStore().apply { storeData(aSessionData(sessionId = "@alice:domain.com")) } @@ -312,6 +314,16 @@ class DefaultBugReporterTest { assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.com") } + @Test + fun `foss build - the log directory is initialized to the root log directory`() = runTest { + val sut = createDefaultBugReporter( + sessionStore = InMemorySessionStore().apply { + storeData(aSessionData(sessionId = "@alice:domain.com")) + } + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs") + } + @Test fun `when the log directory is updated, the tracing service is invoked`() = runTest { var param: WriteToFilesConfiguration? = null @@ -319,9 +331,10 @@ class DefaultBugReporterTest { param = it } val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), tracingService = FakeTracingService( updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, - ) + ), ) sut.setLogDirectorySubfolder("my.sub.folder") updateWriteToFilesConfigurationResult.assertions().isCalledOnce() @@ -333,6 +346,18 @@ class DefaultBugReporterTest { assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") } + @Test + fun `foss build - when the log directory is updated, the tracing service is not invoked`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val sut = createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + sut.setLogDirectorySubfolder("my.sub.folder") + updateWriteToFilesConfigurationResult.assertions().isNeverCalled() + } + @Test fun `when the log directory is reset, the tracing service is invoked`() = runTest { var param: WriteToFilesConfiguration? = null @@ -340,9 +365,10 @@ class DefaultBugReporterTest { param = it } val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), tracingService = FakeTracingService( updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, - ) + ), ) sut.setLogDirectorySubfolder(null) updateWriteToFilesConfigurationResult.assertions().isCalledOnce() @@ -354,6 +380,18 @@ class DefaultBugReporterTest { assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") } + @Test + fun `foss build - when the log directory is reset, the tracing service is not invoked`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val sut = createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + sut.setLogDirectorySubfolder(null) + updateWriteToFilesConfigurationResult.assertions().isNeverCalled() + } + @Test fun `when a new MatrixClient is created the logs folder is updated`() = runTest { var param: WriteToFilesConfiguration? = null @@ -368,6 +406,7 @@ class DefaultBugReporterTest { ) } val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), matrixAuthenticationService = matrixAuthenticationService, tracingService = FakeTracingService( updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, @@ -385,7 +424,30 @@ class DefaultBugReporterTest { assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") } + @Test + fun `foss build - when a new MatrixClient is created the logs folder is not updated`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + givenMatrixClient( + FakeMatrixClient( + userIdServerNameLambda = { "domain.foo.org" }, + ) + ) + } + val sut = createDefaultBugReporter( + matrixAuthenticationService = matrixAuthenticationService, + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ) + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs") + matrixAuthenticationService.login("alice", "password") + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs") + updateWriteToFilesConfigurationResult.assertions().isNeverCalled() + } + private fun TestScope.createDefaultBugReporter( + buildMeta: BuildMeta = aBuildMeta(), sessionStore: SessionStore = InMemorySessionStore(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), crashDataStore: CrashDataStore = FakeCrashDataStore(), @@ -393,7 +455,6 @@ class DefaultBugReporterTest { tracingService: TracingService = FakeTracingService(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), ): DefaultBugReporter { - val buildMeta = aBuildMeta() return DefaultBugReporter( context = RuntimeEnvironment.getApplication(), screenshotHolder = FakeScreenshotHolder(), From e9da806c1831639da13d1b1ee2ad34b974ae43e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:32:21 +0000 Subject: [PATCH 26/31] Update dependency com.posthog:posthog-android to v3.20.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33834ed2f5..f4cb20da3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -198,7 +198,7 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } # Analytics -posthog = "com.posthog:posthog-android:3.20.1" +posthog = "com.posthog:posthog-android:3.20.2" sentry = "io.sentry:sentry-android:8.18.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" From 5c3054bee43525e8ce207054eb9aaf94a9a9e5af Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 7 Aug 2025 16:04:22 +0200 Subject: [PATCH 27/31] Rename fun. --- .../io/element/android/appnav/di/MatrixSessionCache.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index 0941c7c8cc..1e173474cc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -42,7 +42,7 @@ class MatrixSessionCache @Inject constructor( init { authenticationService.listenToNewMatrixClients { matrixClient -> - onMatrixClient(matrixClient) + onNewMatrixClient(matrixClient) } } @@ -100,14 +100,14 @@ class MatrixSessionCache @Inject constructor( Timber.d("Restore matrix session: $sessionId") return authenticationService.restoreSession(sessionId) .onSuccess { matrixClient -> - onMatrixClient(matrixClient) + onNewMatrixClient(matrixClient) } .onFailure { Timber.e(it, "Fail to restore session") } } - private fun onMatrixClient(matrixClient: MatrixClient) { + private fun onNewMatrixClient(matrixClient: MatrixClient) { val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( matrixClient = matrixClient, From 11b9ae06a2307899c871366eb7665544ab39ab50 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 08:46:47 +0200 Subject: [PATCH 28/31] Update dependency com.google.firebase:firebase-bom to v34.1.0 (#5136) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab557074a0..dc20729ac6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,7 +74,7 @@ kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", ve ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } gms_google_services = "com.google.gms:google-services:4.4.3" # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.0.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.1.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } From 16b9440048acd76bc9fb2867167406586cb5381a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 8 Aug 2025 11:56:15 +0200 Subject: [PATCH 29/31] When mapping invalid notification event, only drop that one (#5137) Previously, this meant the code processing the whole notification batch result failed and other notifications would be lost too. --- .../impl/notification/NotificationMapper.kt | 61 ++++++++++--------- .../notification/RustNotificationService.kt | 4 +- ...imelineEventToNotificationContentMapper.kt | 11 ++-- .../fixtures/fakes/FakeFfiTimelineEvent.kt | 2 +- .../RustNotificationServiceTest.kt | 33 ++++++++++ 5 files changed, 77 insertions(+), 34 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index 9130df5c7a..e7831bc492 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl.notification import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -30,31 +31,33 @@ class NotificationMapper( eventId: EventId, roomId: RoomId, notificationItem: NotificationItem - ): NotificationData { - return notificationItem.use { item -> - val isDm = isDm( - isDirect = item.roomInfo.isDirect, - activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), - ) - NotificationData( - sessionId = sessionId, - eventId = eventId, - // FIXME once the `NotificationItem` in the SDK returns the thread id - threadId = null, - roomId = roomId, - senderAvatarUrl = item.senderInfo.avatarUrl, - senderDisplayName = item.senderInfo.displayName, - senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous, - roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm }, - roomDisplayName = item.roomInfo.displayName, - isDirect = item.roomInfo.isDirect, - isDm = isDm, - isEncrypted = item.roomInfo.isEncrypted.orFalse(), - isNoisy = item.isNoisy.orFalse(), - timestamp = item.timestamp() ?: clock.epochMillis(), - content = item.event.use { notificationContentMapper.map(it) }, - hasMention = item.hasMention.orFalse(), - ) + ): Result { + return runCatchingExceptions { + notificationItem.use { item -> + val isDm = isDm( + isDirect = item.roomInfo.isDirect, + activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), + ) + NotificationData( + sessionId = sessionId, + eventId = eventId, + // FIXME once the `NotificationItem` in the SDK returns the thread id + threadId = null, + roomId = roomId, + senderAvatarUrl = item.senderInfo.avatarUrl, + senderDisplayName = item.senderInfo.displayName, + senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous, + roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm }, + roomDisplayName = item.roomInfo.displayName, + isDirect = item.roomInfo.isDirect, + isDm = isDm, + isEncrypted = item.roomInfo.isEncrypted.orFalse(), + isNoisy = item.isNoisy.orFalse(), + timestamp = item.timestamp() ?: clock.epochMillis(), + content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(), + hasMention = item.hasMention.orFalse(), + ) + } } } } @@ -62,11 +65,13 @@ class NotificationMapper( class NotificationContentMapper { private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper() - fun map(notificationEvent: NotificationEvent): NotificationContent = + fun map(notificationEvent: NotificationEvent): Result = when (notificationEvent) { is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event) - is NotificationEvent.Invite -> NotificationContent.Invite( - senderId = UserId(notificationEvent.sender), + is NotificationEvent.Invite -> Result.success( + NotificationContent.Invite( + senderId = UserId(notificationEvent.sender), + ) ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index 4b4693c73b..1379392ae3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -53,7 +53,9 @@ class RustNotificationService( is BatchNotificationResult.Ok -> { when (val status = result.status) { is NotificationStatus.Event -> { - put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item))) + val result = notificationMapper.map(sessionId, eventId, roomId, status.item) + result.onFailure { Timber.e(it, "Could not map notification event $eventId") } + put(eventId, result) } is NotificationStatus.EventNotFound -> { Timber.e("Could not retrieve event for notification with $eventId - event not found") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index cf914abe8d..85f87b271f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.impl.notification +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.CallNotifyType @@ -21,10 +22,12 @@ import org.matrix.rustcomponents.sdk.TimelineEventType import org.matrix.rustcomponents.sdk.use class TimelineEventToNotificationContentMapper { - fun map(timelineEvent: TimelineEvent): NotificationContent { - return timelineEvent.use { - timelineEvent.eventType().use { eventType -> - eventType.toContent(senderId = UserId(timelineEvent.senderId())) + fun map(timelineEvent: TimelineEvent): Result { + return runCatchingExceptions { + timelineEvent.use { + timelineEvent.eventType().use { eventType -> + eventType.toContent(senderId = UserId(timelineEvent.senderId())) + } } } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt index e514cef7a8..41eb9c798e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt @@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEventType -class FakeFfiTimelineEvent( +open class FakeFfiTimelineEvent( val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(), val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(), val senderId: String = A_USER_ID_2.value, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt index 117d164ab9..2b6717910b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt @@ -12,8 +12,12 @@ import io.element.android.libraries.matrix.api.exception.NotificationResolverExc import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEvent import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -26,6 +30,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.NotificationStatus +import org.matrix.rustcomponents.sdk.TimelineEventType class RustNotificationServiceTest { @Test @@ -49,6 +55,33 @@ class RustNotificationServiceTest { ) } + @Test + fun `test mapping invalid item only drops that item`() = runTest { + val error = IllegalStateException("This event type is not supported") + val faultyEvent = object : FakeFfiTimelineEvent() { + override fun eventType(): TimelineEventType { + throw error + } + } + val notificationClient = FakeFfiNotificationClient( + notificationItemResult = mapOf( + AN_EVENT_ID.value to aRustBatchNotificationResult( + notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent))) + ), + AN_EVENT_ID_2.value to aRustBatchNotificationResult() + ), + ) + val sut = createRustNotificationService( + notificationClient = notificationClient, + ) + val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID, AN_EVENT_ID_2))).getOrThrow() + val exception = result[AN_EVENT_ID]!!.exceptionOrNull() + assertThat(exception).isEqualTo(error) + + val successfulResult = result[AN_EVENT_ID_2] + assertThat(successfulResult?.isSuccess).isTrue() + } + @Test fun `test unable to resolve event`() = runTest { val notificationClient = FakeFfiNotificationClient( From 132b08b51a5c875a90bcfeaa7c714558375368f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 8 Aug 2025 12:04:42 +0200 Subject: [PATCH 30/31] Setting version for the release 25.08.2 --- plugins/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index eca866747d..7edbc7b3f9 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -32,7 +32,7 @@ private const val versionYear = 25 private const val versionMonth = 8 // Note: must be in [0,99] -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber From 32003c974c34c51cc135c141b84505d6c9a847d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 8 Aug 2025 12:05:09 +0200 Subject: [PATCH 31/31] Adding fastlane file for version 25.08.2 --- fastlane/metadata/android/en-US/changelogs/202508020.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202508020.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202508020.txt b/fastlane/metadata/android/en-US/changelogs/202508020.txt new file mode 100644 index 0000000000..4da3e65049 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508020.txt @@ -0,0 +1,3 @@ +Main changes in this version: +- Fix a bug with notifications being incorrectly dropped. +Full changelog: https://github.com/element-hq/element-x-android/releases