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: | 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 ============================= 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/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..3d02739ba6 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -33,9 +33,10 @@ 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.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 @@ -64,7 +65,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, @@ -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)) } @@ -293,7 +296,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/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..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,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() + onNewMatrixClient(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() + onNewMatrixClient(matrixClient) } .onFailure { Timber.e(it, "Fail to restore session") } } + + private fun onNewMatrixClient(matrixClient: MatrixClient) { + val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) + sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( + matrixClient = matrixClient, + syncOrchestrator = syncOrchestrator, + ) + syncOrchestrator.start() + } } private data class InMemoryMatrixSession( 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")) } 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 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, -) 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 97ba7a62fd..c2ef2e4bc5 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,20 @@ 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, + // 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) 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..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,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.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 @@ -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() } + ) + .onKeyboardContextMenuAction { onLongClick(room) } Row( modifier = modifier .fillMaxWidth() 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/AccountProviderAccessException.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt new file mode 100644 index 0000000000..5c48f346fe --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.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.login.impl.changeserver + +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/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 deleted file mode 100644 index 1ceb2ab343..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/UnauthorizedAccountProviderException.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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.changeserver - -class UnauthorizedAccountProviderException( - val unauthorisedAccountProviderTitle: String, - val authorisedAccountProviderTitles: List, -) : Exception() 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/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 abf1327913..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 @@ -8,13 +8,20 @@ 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 @@ -28,6 +35,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 +56,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( @@ -87,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 = {} + ) + } +} 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..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,4 +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, ) 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/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..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,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.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 @@ -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 + ) + .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 c988679626..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,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.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 @@ -46,7 +47,8 @@ fun MessageStateEventContainer( onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), indication = ripple(), interactionSource = interactionSource - ), + ) + .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 8c2a1d9ff6..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,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.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 @@ -107,6 +108,7 @@ fun MessagesReactionButton( onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClick = 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 ca34184491..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,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.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 @@ -57,6 +58,7 @@ internal fun TimelineItemCallNotifyView( onLongClick = { onLongClick(event) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .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 59ec63d842..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,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.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 @@ -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), + ) + .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 41d3873a65..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,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.onKeyboardContextMenuAction 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, + ) + .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 dc7cfb2d52..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,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.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 @@ -74,6 +75,7 @@ fun TimelineItemStickerView( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .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 18a59325d5..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,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.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 @@ -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, + ) + .onKeyboardContextMenuAction(onLongClick) } else { Modifier } 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) 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..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 @@ -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,24 @@ 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 { + 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()) + } + } + } override suspend fun sendBugReport( withDevicesLogs: Boolean, @@ -286,16 +309,44 @@ 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?) { + if (buildMeta.isEnterpriseBuild) { + 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() - .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() + } + } } } @@ -325,11 +376,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/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..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 @@ -10,16 +10,26 @@ 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.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 +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 +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.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -45,7 +55,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 +107,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 +168,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 +202,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 +261,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 @@ -318,22 +303,172 @@ class DefaultBugReporterTest { assertThat(onUploadSucceedCalled).isFalse() } + @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")) + } + ) + 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 + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + 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 `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 + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + 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 `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 + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + givenMatrixClient( + FakeMatrixClient( + userIdServerNameLambda = { "domain.foo.org" }, + ) + ) + } + val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + 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") + } + + @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( - server: MockWebServer + buildMeta: BuildMeta = aBuildMeta(), + sessionStore: SessionStore = InMemorySessionStore(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + crashDataStore: CrashDataStore = FakeCrashDataStore(), + server: MockWebServer = MockWebServer(), + tracingService: TracingService = FakeTracingService(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), ): 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 = tracingService, + matrixAuthenticationService = matrixAuthenticationService, ) } 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33834ed2f5..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" } @@ -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" @@ -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" 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/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..c8c8b8769e --- /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 action The callback to invoke when Shift + F10 is pressed. + */ +fun Modifier.onKeyboardContextMenuAction( + action: (() -> Unit)?, +): Modifier = then( + if (action == 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) { + action() + true + } else { + false + } + } + } +) 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, 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/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/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 { 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( 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" 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) + } +} 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..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,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.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 @@ -84,6 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .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 1144fc3eaf..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,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.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 @@ -84,6 +85,7 @@ private fun FilenameRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .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 917c60df9e..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,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.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 @@ -44,7 +45,8 @@ fun ImageItemView( onClick = onClick, onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ), + ) + .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 cdc66c4eae..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,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.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 @@ -54,7 +55,8 @@ fun VideoItemView( onClick = onClick, onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), - ), + ) + .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 5d84b79f8f..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,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.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 @@ -105,6 +106,7 @@ private fun VoiceInfoRow( onLongClick = onLongClick, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) + .onKeyboardContextMenuAction(onLongClick) .fillMaxWidth() .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, 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 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 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.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 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