Merge branch 'release/25.08.2'

This commit is contained in:
Jorge Martín 2025-08-08 12:05:10 +02:00
commit 917fbbf82f
86 changed files with 1221 additions and 223 deletions

View file

@ -69,7 +69,7 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Download APK artifact from previous job - name: Download APK artifact from previous job
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
- name: Enable KVM group perms - name: Enable KVM group perms

View file

@ -284,7 +284,7 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Download reports from previous jobs - name: Download reports from previous jobs
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
- name: Prepare Danger - name: Prepare Danger
if: always() if: always()
run: | run: |

View file

@ -1,3 +1,22 @@
Changes in Element X v25.08.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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 Changes in Element X v25.08.0
============================= =============================

View file

@ -10,11 +10,10 @@ package io.element.android.x.initializer
import android.content.Context import android.content.Context
import android.system.Os import android.system.Os
import androidx.startup.Initializer 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.architecture.bindings
import io.element.android.libraries.featureflag.api.FeatureFlags 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.TracingConfiguration
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -34,7 +33,7 @@ class PlatformInitializer : Initializer<Unit> {
val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() } val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() }
val tracingConfiguration = TracingConfiguration( val tracingConfiguration = TracingConfiguration(
writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) }, writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) },
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter), writesToFilesConfiguration = bugReporter.createWriteToFilesConfiguration(),
logLevel = logLevel, logLevel = logLevel,
extraTargets = listOf(ELEMENT_X_TARGET), extraTargets = listOf(ELEMENT_X_TARGET),
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() }, traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
@ -45,14 +44,5 @@ class PlatformInitializer : Initializer<Unit> {
Os.setenv("RUST_BACKTRACE", "1", true) 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<Class<out Initializer<*>>> = mutableListOf() override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
} }

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api)
implementation(libs.coil) implementation(libs.coil)

View file

@ -33,9 +33,10 @@ import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView 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.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.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -64,7 +65,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val enterpriseService: EnterpriseService, private val accountProviderAccessControl: AccountProviderAccessControl,
private val navStateFlowFactory: RootNavStateFlowFactory, private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache, private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter, private val presenter: RootPresenter,
@ -73,6 +74,7 @@ class RootFlowNode @AssistedInject constructor(
private val signedOutEntryPoint: SignedOutEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val bugReporter: BugReporter,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.SplashScreen,
@ -123,6 +125,7 @@ class RootFlowNode @AssistedInject constructor(
private fun switchToNotLoggedInFlow(params: LoginParams?) { private fun switchToNotLoggedInFlow(params: LoginParams?) {
matrixSessionCache.removeAll() matrixSessionCache.removeAll()
bugReporter.setLogDirectorySubfolder(null)
backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
} }
@ -293,7 +296,7 @@ class RootFlowNode @AssistedInject constructor(
val latestSessionId = authenticationService.getLatestSessionId() val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
// No session, open login // No session, open login
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) { if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
switchToNotLoggedInFlow(params) switchToNotLoggedInFlow(params)
} else { } else {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver") Timber.w("Login link ignored, we are not allowed to connect to the homeserver")

View file

@ -42,12 +42,7 @@ class MatrixSessionCache @Inject constructor(
init { init {
authenticationService.listenToNewMatrixClients { matrixClient -> authenticationService.listenToNewMatrixClients { matrixClient ->
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) onNewMatrixClient(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
} }
} }
@ -105,17 +100,21 @@ class MatrixSessionCache @Inject constructor(
Timber.d("Restore matrix session: $sessionId") Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId) return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient -> .onSuccess { matrixClient ->
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) onNewMatrixClient(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
} }
.onFailure { .onFailure {
Timber.e(it, "Fail to restore session") 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( private data class InMemoryMatrixSession(

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.26") detektPlugins("io.nlopez.compose.rules:detekt:0.4.27")
detektPlugins(project(":tests:detekt-rules")) detektPlugins(project(":tests:detekt-rules"))
} }

View file

@ -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

View file

@ -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<MatrixUser>,
)

View file

@ -240,14 +240,20 @@ private fun HomeScaffold(
contentPadding = PaddingValues( contentPadding = PaddingValues(
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80, // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
// and include provided bottom padding // and include provided bottom padding
bottom = 80.dp + padding.calculateBottomPadding(), // Disable contentPadding due to navigation issue using the keyboard
top = padding.calculateTopPadding() // See https://issuetracker.google.com/issues/436432313
bottom = 80.dp,
// bottom = 80.dp + padding.calculateBottomPadding(),
// top = padding.calculateTopPadding()
), ),
modifier = Modifier modifier = Modifier
.padding( .padding(
PaddingValues( PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current), start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(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) .consumeWindowInsets(padding)

View file

@ -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.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -170,14 +171,15 @@ private fun RoomSummaryScaffoldRow(
hideAvatarImage: Boolean = false, hideAvatarImage: Boolean = false,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
val clickModifier = Modifier.combinedClickable( val clickModifier = Modifier
onClick = { onClick(room) }, .combinedClickable(
onLongClick = { onLongClick(room) }, onClick = { onClick(room) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClick = { onLongClick(room) },
indication = ripple(), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
interactionSource = remember { MutableInteractionSource() } indication = ripple(),
) interactionSource = remember { MutableInteractionSource() }
)
.onKeyboardContextMenuAction { onLongClick(room) }
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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
}
}
}

View file

@ -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<String>,
) : AccountProviderAccessException()
}

View file

@ -12,7 +12,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource 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.error.ChangeServerError
@ -27,7 +27,7 @@ import javax.inject.Inject
class ChangeServerPresenter @Inject constructor( class ChangeServerPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val enterpriseService: EnterpriseService, private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
) : Presenter<ChangeServerState> { ) : Presenter<ChangeServerState> {
@Composable @Composable
override fun present(): ChangeServerState { override fun present(): ChangeServerState {
@ -55,12 +55,10 @@ class ChangeServerPresenter @Inject constructor(
changeServerAction: MutableState<AsyncData<Unit>>, changeServerAction: MutableState<AsyncData<Unit>>,
) = launch { ) = launch {
suspend { suspend {
if (enterpriseService.isAllowedToConnectToHomeserver(data.url).not()) { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
throw UnauthorizedAccountProviderException( title = data.title,
unauthorisedAccountProviderTitle = data.title, accountProviderUrl = data.url,
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), )
)
}
authenticationService.setHomeserver(data.url).map { authenticationService.setHomeserver(data.url).map {
authenticationService.getHomeserverDetails().value!! authenticationService.getHomeserverDetails().value!!
// Valid, remember user choice // Valid, remember user choice

View file

@ -26,6 +26,14 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
) )
) )
), ),
aChangeServerState(
changeServerAction = AsyncData.Failure(
ChangeServerError.NeedElementPro(
unauthorisedAccountProviderTitle = "example.com",
applicationId = "applicationId",
),
)
),
) )
} }

View file

@ -12,13 +12,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog 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.ChangeServerError
import io.element.android.libraries.androidutils.system.openGooglePlay
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -31,6 +34,7 @@ fun ChangeServerView(
onSuccess: () -> Unit, onSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val eventSink = state.eventSink val eventSink = state.eventSink
when (state.changeServerAction) { when (state.changeServerAction) {
is AsyncData.Failure -> { 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 -> { is ChangeServerError.UnauthorizedAccountProvider -> {
ErrorDialog( ErrorDialog(
modifier = modifier, modifier = modifier,

View file

@ -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<String>,
) : Exception()

View file

@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R 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.matrix.api.auth.AuthenticationException
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
sealed class ChangeServerError : Throwable() { sealed class ChangeServerError : Exception() {
data class Error( data class Error(
@StringRes val messageId: Int? = null, @StringRes val messageId: Int? = null,
val messageStr: String? = null, val messageStr: String? = null,
@ -26,6 +26,11 @@ sealed class ChangeServerError : Throwable() {
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown) fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
} }
data class NeedElementPro(
val unauthorisedAccountProviderTitle: String,
val applicationId: String,
) : ChangeServerError()
data class UnauthorizedAccountProvider( data class UnauthorizedAccountProvider(
val unauthorisedAccountProviderTitle: String, val unauthorisedAccountProviderTitle: String,
val authorisedAccountProviderTitles: List<String>, val authorisedAccountProviderTitles: List<String>,
@ -37,7 +42,11 @@ sealed class ChangeServerError : Throwable() {
fun from(error: Throwable): ChangeServerError = when (error) { fun from(error: Throwable): ChangeServerError = when (error) {
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
is AuthenticationException.Oidc -> Error(messageStr = error.message) 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, unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles, authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
) )

View file

@ -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<ChangeServerError> {
override val values: Sequence<ChangeServerError>
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,
)
}

View file

@ -8,13 +8,20 @@
package io.element.android.features.login.impl.login package io.element.android.features.login.impl.login
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog 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.ChangeServerError
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported 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.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.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.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -28,6 +35,7 @@ fun LoginModeView(
onNeedLoginPassword: () -> Unit, onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit onCreateAccountContinue: (url: String) -> Unit
) { ) {
val context = LocalContext.current
when (loginMode) { when (loginMode) {
is AsyncData.Failure -> { is AsyncData.Failure -> {
when (val error = loginMode.error) { when (val error = loginMode.error) {
@ -48,6 +56,21 @@ fun LoginModeView(
onDismiss = onClearError, 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 -> { is ChangeServerError.UnauthorizedAccountProvider -> {
ErrorDialog( ErrorDialog(
content = stringResource( content = stringResource(
@ -87,3 +110,18 @@ fun LoginModeView(
AsyncData.Uninitialized -> Unit AsyncData.Uninitialized -> Unit
} }
} }
@PreviewsDayNight
@Composable
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
ElementPreview {
LoginModeView(
loginMode = AsyncData.Failure(error),
onClearError = {},
onLearnMoreClick = {},
onOidcDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {}
)
}
}

View file

@ -23,4 +23,6 @@ import kotlinx.serialization.Serializable
data class ElementWellKnown( data class ElementWellKnown(
@SerialName("registration_helper_url") @SerialName("registration_helper_url")
val registrationHelperUrl: String? = null, val registrationHelperUrl: String? = null,
@SerialName("enforce_element_pro")
val enforceElementPro: Boolean? = null,
) )

View file

@ -21,6 +21,7 @@ import dagger.assisted.AssistedInject
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver 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.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -34,6 +35,7 @@ class OnBoardingPresenter @AssistedInject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper, private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> { ) : Presenter<OnBoardingState> {
@ -63,7 +65,12 @@ class OnBoardingPresenter @AssistedInject constructor(
val linkAccountProvider by produceState<String?>(initialValue = null) { val linkAccountProvider by produceState<String?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service // Account provider from the link, if allowed by the enterprise service
value = params.accountProvider?.takeIf { value = params.accountProvider?.takeIf {
enterpriseService.isAllowedToConnectToHomeserver(it) try {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it)
true
} catch (_: Exception) {
false
}
} }
} }
val defaultAccountProvider = remember(linkAccountProvider) { val defaultAccountProvider = remember(linkAccountProvider) {

View file

@ -15,8 +15,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -38,7 +37,7 @@ class QrCodeScanPresenter @Inject constructor(
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
private val qrCodeLoginManager: QrCodeLoginManager, private val qrCodeLoginManager: QrCodeLoginManager,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val enterpriseService: EnterpriseService, private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
) : Presenter<QrCodeScanState> { ) : Presenter<QrCodeScanState> {
private var isScanning by mutableStateOf(true) private var isScanning by mutableStateOf(true)
@ -97,10 +96,10 @@ class QrCodeScanPresenter @Inject constructor(
Timber.e(it, "Error parsing QR code data") Timber.e(it, "Error parsing QR code data")
}.getOrThrow() }.getOrThrow()
val serverName = data.serverName() val serverName = data.serverName()
if (serverName != null && enterpriseService.isAllowedToConnectToHomeserver(serverName).not()) { if (serverName != null) {
throw UnauthorizedAccountProviderException( defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
unauthorisedAccountProviderTitle = serverName, title = serverName,
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), accountProviderUrl = serverName,
) )
} }
data data

View file

@ -8,7 +8,7 @@
package io.element.android.features.login.impl.screens.qrcode.scan package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
@ -23,12 +23,21 @@ open class QrCodeScanStateProvider : PreviewParameterProvider<QrCodeScanState> {
aQrCodeScanState( aQrCodeScanState(
isScanning = false, isScanning = false,
authenticationAction = AsyncAction.Failure( authenticationAction = AsyncAction.Failure(
UnauthorizedAccountProviderException( AccountProviderAccessException.UnauthorizedAccountProviderException(
unauthorisedAccountProviderTitle = "example.com", unauthorisedAccountProviderTitle = "example.com",
authorisedAccountProviderTitles = listOf("element.io", "element.org"), authorisedAccountProviderTitles = listOf("element.io", "element.org"),
) )
) )
), ),
aQrCodeScanState(
isScanning = false,
authenticationAction = AsyncAction.Failure(
AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = "example.com",
applicationId = "applicationId"
)
)
),
// Add other state here // Add other state here
) )
} }

View file

@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R 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.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
@ -145,7 +145,10 @@ private fun ColumnScope.Buttons(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = when (error) { text = when (error) {
is UnauthorizedAccountProviderException -> { is AccountProviderAccessException.NeedElementProException -> {
stringResource(R.string.screen_change_server_error_element_pro_required_title)
}
is AccountProviderAccessException.UnauthorizedAccountProviderException -> {
stringResource( stringResource(
id = R.string.screen_change_server_error_unauthorized_homeserver_title, id = R.string.screen_change_server_error_unauthorized_homeserver_title,
error.unauthorisedAccountProviderTitle, error.unauthorisedAccountProviderTitle,
@ -163,7 +166,13 @@ private fun ColumnScope.Buttons(
} }
Text( Text(
text = when (error) { 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( stringResource(
id = R.string.screen_change_server_error_unauthorized_homeserver_content, id = R.string.screen_change_server_error_unauthorized_homeserver_content,
error.authorisedAccountProviderTitles.joinToString(), error.authorisedAccountProviderTitles.joinToString(),

View file

@ -13,6 +13,7 @@
<string name="screen_change_account_provider_other">"Other"</string> <string name="screen_change_account_provider_other">"Other"</string>
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string> <string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
<string name="screen_change_account_provider_title">"Change account provider"</string> <string name="screen_change_account_provider_title">"Change account provider"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string> <string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string> <string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
<string name="screen_change_server_error_invalid_homeserver">"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."</string> <string name="screen_change_server_error_invalid_homeserver">"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."</string>

View file

@ -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<String> = 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()
}
}
}

View file

@ -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)
}
}

View file

@ -10,10 +10,15 @@ package io.element.android.features.login.impl.changeserver
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService 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.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource 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.error.ChangeServerError
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
import io.element.android.libraries.architecture.AsyncData 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
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService 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<String, ElementWellKnown> {
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( private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
) = ChangeServerPresenter( ) = ChangeServerPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
enterpriseService = enterpriseService, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever,
),
) )
} }

View file

@ -12,6 +12,9 @@ import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory 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.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
@ -235,6 +238,7 @@ private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
rageshakeFeatureAvailability: () -> Boolean = { true }, rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(), loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter( ) = OnBoardingPresenter(
@ -242,6 +246,10 @@ private fun createPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever,
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper, loginHelper = loginHelper,
) )

View file

@ -13,7 +13,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService 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.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
@ -91,9 +94,15 @@ class QrCodeScanPresenterTest {
assertThat(awaitItem().isScanning).isFalse() assertThat(awaitItem().isScanning).isFalse()
assertThat(awaitItem().authenticationAction.isLoading()).isTrue() assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle) assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle
)
.isEqualTo("example.com") .isEqualTo("example.com")
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).authorisedAccountProviderTitles) assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles
)
.containsExactly("element.io") .containsExactly("element.io")
} }
} }
@ -153,10 +162,14 @@ class QrCodeScanPresenterTest {
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
) = QrCodeScanPresenter( ) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager, qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
enterpriseService = enterpriseService, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever,
),
) )
} }

View file

@ -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.features.messages.impl.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
@ -96,12 +97,14 @@ fun MessageEventBubble(
val clickableModifier = if (isTalkbackActive()) { val clickableModifier = if (isTalkbackActive()) {
Modifier Modifier
} else { } else {
Modifier.combinedClickable( Modifier
onClick = onClick, .combinedClickable(
onLongClick = onLongClick, onClick = onClick,
indication = ripple(), onLongClick = onLongClick,
interactionSource = interactionSource indication = ripple(),
) interactionSource = interactionSource
)
.onKeyboardContextMenuAction(onLongClick)
} }
// Ignore state.isHighlighted for now, we need a design decision on it. // Ignore state.isHighlighted for now, we need a design decision on it.

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
@ -46,7 +47,8 @@ fun MessageStateEventContainer(
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(), indication = ripple(),
interactionSource = interactionSource interactionSource = interactionSource
), )
.onKeyboardContextMenuAction(onLongClick),
color = backgroundColor, color = backgroundColor,
shape = shape, shape = shape,
content = content content = content

View file

@ -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.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
import io.element.android.libraries.designsystem.icons.CompoundDrawables 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
@ -107,6 +108,7 @@ fun MessagesReactionButton(
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
onLongClick = onLongClick onLongClick = onLongClick
) )
.onKeyboardContextMenuAction(onLongClick)
// Inner border, to highlight when selected // Inner border, to highlight when selected
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))

View file

@ -34,6 +34,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
@ -57,6 +58,7 @@ internal fun TimelineItemCallNotifyView(
onLongClick = { onLongClick(event) }, onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp), .padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -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.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState 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.modifiers.subtleColorStops
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 // Custom clickable that applies over the whole item for accessibility
.then( .then(
if (isTalkbackActive()) { if (isTalkbackActive()) {
Modifier.combinedClickable( Modifier
onClick = { onContentClick(timelineItem) }, .combinedClickable(
onLongClick = { onLongClick(timelineItem) }, onClick = { onContentClick(timelineItem) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClick = { onLongClick(timelineItem) },
) onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(timelineItem) }
} else { } else {
Modifier Modifier
} }

View file

@ -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.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
@ -91,10 +92,12 @@ fun TimelineItemImageView(
.then(if (isLoaded) Modifier.background(Color.White) else Modifier) .then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then( .then(
if (!isTalkbackActive() && onContentClick != null) { if (!isTalkbackActive() && onContentClick != null) {
Modifier.combinedClickable( Modifier
onClick = onContentClick, .combinedClickable(
onLongClick = onLongClick onClick = onContentClick,
) onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else { } else {
Modifier Modifier
} }

View file

@ -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.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -74,6 +75,7 @@ fun TimelineItemStickerView(
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction(onLongClick)
} else { } else {
Modifier Modifier
} }

View file

@ -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.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent 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.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.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 (isLoaded) Modifier.background(Color.White) else Modifier)
.then( .then(
if (!isTalkbackActive && onContentClick != null) { if (!isTalkbackActive && onContentClick != null) {
Modifier.combinedClickable( Modifier
onClick = onContentClick, .combinedClickable(
onLongClick = onLongClick onClick = onContentClick,
) onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else { } else {
Modifier Modifier
} }

View file

@ -32,7 +32,6 @@ import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -83,8 +82,6 @@ fun ReactionSummaryView(
state: ReactionSummaryState, state: ReactionSummaryState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState()
fun onDismiss() { fun onDismiss() {
state.eventSink(ReactionSummaryEvents.Clear) state.eventSink(ReactionSummaryEvents.Clear)
} }
@ -92,7 +89,6 @@ fun ReactionSummaryView(
if (state.target != null) { if (state.target != null) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = ::onDismiss, onDismissRequest = ::onDismiss,
sheetState = sheetState,
modifier = modifier modifier = modifier
) { ) {
ReactionSummaryViewContent(summary = state.target) ReactionSummaryViewContent(summary = state.target)

View file

@ -16,5 +16,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
} }

View file

@ -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,
)
}

View file

@ -34,6 +34,14 @@ interface BugReporter {
*/ */
fun logDirectory(): File 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. * Set the current tracing log level.
*/ */

View file

@ -13,6 +13,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig 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.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener 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.CrashDataStore
@ -28,11 +29,14 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SdkMetadata 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.core.UserId
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -71,6 +75,8 @@ class DefaultBugReporter @Inject constructor(
private val bugReporterUrlProvider: BugReporterUrlProvider, private val bugReporterUrlProvider: BugReporterUrlProvider,
private val sdkMetadata: SdkMetadata, private val sdkMetadata: SdkMetadata,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val tracingService: TracingService,
matrixAuthenticationService: MatrixAuthenticationService,
) : BugReporter { ) : BugReporter {
companion object { companion object {
// filenames // filenames
@ -81,7 +87,24 @@ class DefaultBugReporter @Inject constructor(
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
private var currentTracingLogLevel: String? = null 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( override suspend fun sendBugReport(
withDevicesLogs: Boolean, withDevicesLogs: Boolean,
@ -286,16 +309,44 @@ class DefaultBugReporter @Inject constructor(
} }
override fun logDirectory(): File { override fun logDirectory(): File {
return File(context.cacheDir, LOG_DIRECTORY_NAME).apply { return currentLogDirectory.apply {
mkdirs() 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) { suspend fun deleteAllFiles(predicate: (File) -> Boolean) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
getLogFiles() deleteAllFilesRecursive(baseLogDirectory, predicate)
.filter(predicate) }
.forEach { it.safeDelete() } }
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 * @return the file if the operation succeeds
*/ */
override fun saveLogCat() { override fun saveLogCat() {
if (logCatErrFile.exists()) { val file = logCatErrFile
logCatErrFile.safeDelete() if (file.exists()) {
file.safeDelete()
} }
try { try {
logCatErrFile.writer().use { file.writer().use {
getLogCatError(it) getLogCatError(it)
} }
} catch (error: OutOfMemoryError) { } catch (error: OutOfMemoryError) {

View file

@ -53,6 +53,10 @@ class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter {
return File("fake") return File("fake")
} }
override fun setLogDirectorySubfolder(subfolderName: String?) {
// No op
}
override fun setCurrentTracingLogLevel(logLevel: String) { override fun setCurrentTracingLogLevel(logLevel: String) {
// No op // No op
} }

View file

@ -10,16 +10,26 @@ package io.element.android.features.rageshake.impl.reporter
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.RageshakeConfig import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.reporter.BugReporterListener 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.crash.FakeCrashDataStore
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata 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.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService 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.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.impl.memory.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -45,7 +55,7 @@ class DefaultBugReporterTest {
.setResponseCode(200) .setResponseCode(200)
) )
server.start() server.start()
val sut = createDefaultBugReporter(server) val sut = createDefaultBugReporter(server = server)
var onUploadCancelledCalled = false var onUploadCancelledCalled = false
var onUploadFailedCalled = false var onUploadFailedCalled = false
val progressValues = mutableListOf<Int>() val progressValues = mutableListOf<Int>()
@ -97,22 +107,14 @@ class DefaultBugReporterTest {
storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH")) storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH"))
} }
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService() val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY")
val sut = DefaultBugReporter( val sut = createDefaultBugReporter(
context = RuntimeEnvironment.getApplication(), server = server,
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(), crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = mockSessionStore, sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
) )
@ -166,22 +168,13 @@ class DefaultBugReporterTest {
storeData(aSessionData("@foo:example.com", "ABCDEFGH")) storeData(aSessionData("@foo:example.com", "ABCDEFGH"))
} }
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService() val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys(null, null) fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter( val sut = createDefaultBugReporter(
context = RuntimeEnvironment.getApplication(), server = server,
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = mockSessionStore, sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
) )
@ -209,21 +202,13 @@ class DefaultBugReporterTest {
) )
server.start() server.start()
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService() val fakeEncryptionService = FakeEncryptionService()
fakeEncryptionService.givenDeviceKeys(null, null) fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter( val sut = createDefaultBugReporter(
context = RuntimeEnvironment.getApplication(), server = server,
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore("I did crash", true), crashDataStore = FakeCrashDataStore("I did crash", true),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = InMemorySessionStore(), sessionStore = InMemorySessionStore(),
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) })
) )
@ -276,7 +261,7 @@ class DefaultBugReporterTest {
.setBody("""{"error": "An error body"}""") .setBody("""{"error": "An error body"}""")
) )
server.start() server.start()
val sut = createDefaultBugReporter(server) val sut = createDefaultBugReporter(server = server)
var onUploadCancelledCalled = false var onUploadCancelledCalled = false
var onUploadFailedCalled = false var onUploadFailedCalled = false
var onUploadFailedReason: String? = null var onUploadFailedReason: String? = null
@ -318,22 +303,172 @@ class DefaultBugReporterTest {
assertThat(onUploadSucceedCalled).isFalse() 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<WriteToFilesConfiguration, Unit> {
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<WriteToFilesConfiguration, Unit> {}
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<WriteToFilesConfiguration, Unit> {
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<WriteToFilesConfiguration, Unit> {}
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<WriteToFilesConfiguration, Unit> {
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<WriteToFilesConfiguration, Unit> {}
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( 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 { ): DefaultBugReporter {
val buildMeta = aBuildMeta()
return DefaultBugReporter( return DefaultBugReporter(
context = RuntimeEnvironment.getApplication(), context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(), screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(), crashDataStore = crashDataStore,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() }, okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = InMemorySessionStore(), sessionStore = sessionStore,
buildMeta = buildMeta, buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") }, bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"), sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider() matrixClientProvider = matrixClientProvider,
tracingService = tracingService,
matrixAuthenticationService = matrixAuthenticationService,
) )
} }

View file

@ -15,7 +15,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
@ -145,11 +143,7 @@ private fun ChangeOwnRoleBottomSheet(
eventSink: (RolesAndPermissionsEvents) -> Unit, eventSink: (RolesAndPermissionsEvents) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val sheetState = if (LocalInspectionMode.current) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
sheetStateForPreview()
} else {
rememberModalBottomSheetState(skipPartiallyExpanded = true)
}
fun dismiss() { fun dismiss() {
sheetState.hide(coroutineScope) { sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.CancelPendingAction) eventSink(RolesAndPermissionsEvents.CancelPendingAction)

View file

@ -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" } 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" gms_google_services = "com.google.gms:google-services:4.4.3"
# https://firebase.google.com/docs/android/setup#available-libraries # 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" } 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" } 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" } 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-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-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" } 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" sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
unifiedpush = "org.unifiedpush.android:connector:3.0.10" unifiedpush = "org.unifiedpush.android:connector:3.0.10"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" 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" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
# Analytics # 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" sentry = "io.sentry:sentry-android:8.18.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"

View file

@ -165,6 +165,7 @@ fun Context.startSharePlainTextIntent(
fun Context.openUrlInExternalApp( fun Context.openUrlInExternalApp(
url: String, url: String,
errorMessage: String = getString(R.string.error_no_compatible_app_found), errorMessage: String = getString(R.string.error_no_compatible_app_found),
throwInCaseOfError: Boolean = false,
) { ) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri()) val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (this !is Activity) { if (this !is Activity) {
@ -173,10 +174,27 @@ fun Context.openUrlInExternalApp(
try { try {
startActivity(intent) startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) { } catch (activityNotFoundException: ActivityNotFoundException) {
if (throwInCaseOfError) throw activityNotFoundException
toast(errorMessage) 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 // Not in KTX anymore
fun Context.toast(resId: Int) { fun Context.toast(resId: Int) {
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show() Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()

View file

@ -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
}
}
}
)

View file

@ -23,6 +23,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape 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.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -54,7 +59,17 @@ fun ModalBottomSheet(
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
androidx.compose.material3.ModalBottomSheet( androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismissRequest, 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, sheetState = safeSheetState,
shape = shape, shape = shape,
containerColor = containerColor, containerColor = containerColor,

View file

@ -11,4 +11,6 @@ import timber.log.Timber
interface TracingService { interface TracingService {
fun createTimberTree(target: String): Timber.Tree fun createTimberTree(target: String): Timber.Tree
fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration)
} }

View file

@ -71,9 +71,9 @@ class RustMatrixAuthenticationService @Inject constructor(
private var currentClient: Client? = null private var currentClient: Client? = null
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null private val newMatrixClientObservers = mutableListOf<(MatrixClient) -> Unit>()
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) { override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
newMatrixClientObserver = lambda newMatrixClientObservers.add(lambda)
} }
private fun rotateSessionPath(): SessionPaths { private fun rotateSessionPath(): SessionPaths {
@ -155,7 +155,8 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
// Clean up the strong reference held here since it's no longer necessary // Clean up the strong reference held here since it's no longer necessary
@ -246,7 +247,8 @@ class RustMatrixAuthenticationService @Inject constructor(
pendingOAuthAuthorizationData?.close() pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null pendingOAuthAuthorizationData = null
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
// Clean up the strong reference held here since it's no longer necessary // Clean up the strong reference held here since it's no longer necessary
@ -290,7 +292,8 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
) )
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
// Clean up the strong reference held here since it's no longer necessary // Clean up the strong reference held here since it's no longer necessary

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl.notification package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.bool.orFalse 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -30,31 +31,33 @@ class NotificationMapper(
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
notificationItem: NotificationItem notificationItem: NotificationItem
): NotificationData { ): Result<NotificationData> {
return notificationItem.use { item -> return runCatchingExceptions {
val isDm = isDm( notificationItem.use { item ->
isDirect = item.roomInfo.isDirect, val isDm = isDm(
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), isDirect = item.roomInfo.isDirect,
) activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
NotificationData( )
sessionId = sessionId, NotificationData(
eventId = eventId, sessionId = sessionId,
// FIXME once the `NotificationItem` in the SDK returns the thread id eventId = eventId,
threadId = null, // FIXME once the `NotificationItem` in the SDK returns the thread id
roomId = roomId, threadId = null,
senderAvatarUrl = item.senderInfo.avatarUrl, roomId = roomId,
senderDisplayName = item.senderInfo.displayName, senderAvatarUrl = item.senderInfo.avatarUrl,
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous, senderDisplayName = item.senderInfo.displayName,
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm }, senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
roomDisplayName = item.roomInfo.displayName, roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm },
isDirect = item.roomInfo.isDirect, roomDisplayName = item.roomInfo.displayName,
isDm = isDm, isDirect = item.roomInfo.isDirect,
isEncrypted = item.roomInfo.isEncrypted.orFalse(), isDm = isDm,
isNoisy = item.isNoisy.orFalse(), isEncrypted = item.roomInfo.isEncrypted.orFalse(),
timestamp = item.timestamp() ?: clock.epochMillis(), isNoisy = item.isNoisy.orFalse(),
content = item.event.use { notificationContentMapper.map(it) }, timestamp = item.timestamp() ?: clock.epochMillis(),
hasMention = item.hasMention.orFalse(), content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(),
) hasMention = item.hasMention.orFalse(),
)
}
} }
} }
} }
@ -62,11 +65,13 @@ class NotificationMapper(
class NotificationContentMapper { class NotificationContentMapper {
private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper() private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper()
fun map(notificationEvent: NotificationEvent): NotificationContent = fun map(notificationEvent: NotificationEvent): Result<NotificationContent> =
when (notificationEvent) { when (notificationEvent) {
is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event) is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event)
is NotificationEvent.Invite -> NotificationContent.Invite( is NotificationEvent.Invite -> Result.success(
senderId = UserId(notificationEvent.sender), NotificationContent.Invite(
senderId = UserId(notificationEvent.sender),
)
) )
} }
} }

View file

@ -53,7 +53,9 @@ class RustNotificationService(
is BatchNotificationResult.Ok -> { is BatchNotificationResult.Ok -> {
when (val status = result.status) { when (val status = result.status) {
is NotificationStatus.Event -> { 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 -> { is NotificationStatus.EventNotFound -> {
Timber.e("Could not retrieve event for notification with $eventId - event not found") Timber.e("Could not retrieve event for notification with $eventId - event not found")

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.notification 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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType 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 import org.matrix.rustcomponents.sdk.use
class TimelineEventToNotificationContentMapper { class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): NotificationContent { fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return timelineEvent.use { return runCatchingExceptions {
timelineEvent.eventType().use { eventType -> timelineEvent.use {
eventType.toContent(senderId = UserId(timelineEvent.senderId())) timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
}
} }
} }
} }

View file

@ -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.TracingService
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
import org.matrix.rustcomponents.sdk.TracingFileConfiguration import org.matrix.rustcomponents.sdk.TracingFileConfiguration
import org.matrix.rustcomponents.sdk.reloadTracingFileWriter
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -23,6 +24,12 @@ class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) :
override fun createTimberTree(target: String): Timber.Tree { override fun createTimberTree(target: String): Timber.Tree {
return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable) 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 { private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel {

View file

@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType import org.matrix.rustcomponents.sdk.TimelineEventType
class FakeFfiTimelineEvent( open class FakeFfiTimelineEvent(
val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(), val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(),
val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(), val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(),
val senderId: String = A_USER_ID_2.value, val senderId: String = A_USER_ID_2.value,

View file

@ -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.notification.NotificationContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType 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.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.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
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_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -26,6 +30,8 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEventType
class RustNotificationServiceTest { class RustNotificationServiceTest {
@Test @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 @Test
fun `test unable to resolve event`() = runTest { fun `test unable to resolve event`() = runTest {
val notificationClient = FakeFfiNotificationClient( val notificationClient = FakeFfiNotificationClient(

View file

@ -69,6 +69,7 @@ const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.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 = "matrix.org"
const val AN_ACCOUNT_PROVIDER_2 = "element.io" const val AN_ACCOUNT_PROVIDER_2 = "element.io"
const val AN_ACCOUNT_PROVIDER_3 = "other.io" const val AN_ACCOUNT_PROVIDER_3 = "other.io"

View file

@ -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)
}
}

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.extensions.withBrackets 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@ -84,6 +85,7 @@ private fun FilenameRow(
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.extensions.withBrackets 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@ -84,6 +85,7 @@ private fun FilenameRow(
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.mediaviewer.impl.model.MediaItem import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@ -44,7 +45,8 @@ fun ImageItemView(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
), )
.onKeyboardContextMenuAction(onLongClick),
) { ) {
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(

View file

@ -33,6 +33,7 @@ import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -54,7 +55,8 @@ fun VideoItemView(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
), )
.onKeyboardContextMenuAction(onLongClick),
) { ) {
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@ -105,6 +106,7 @@ private fun VoiceInfoRow(
onLongClick = onLongClick, onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -32,7 +32,7 @@ private const val versionYear = 25
private const val versionMonth = 8 private const val versionMonth = 8
// Note: must be in [0,99] // Note: must be in [0,99]
private const val versionReleaseNumber = 1 private const val versionReleaseNumber = 2
object Versions { object Versions {
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eb923fffd20dc775f31b998e23c39acf79013a831a7c292c4cdba5b9142b4094 oid sha256:6fd52151e94328a68ef5b65bdc0e5e0d730bd617ed2b58ad92c350fb409c75d2
size 68227 size 63096

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a9066615bfa2f78b77784302ce0272e3a486c79e267629c50c35ec1221c58046 oid sha256:a67035a18b067b07f52090ee0ca9e3defa34a158e7dcefc55062131ac1599823
size 64443 size 59193

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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