Merge branch 'release/25.08.2'

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.androidutils.system.openGooglePlay
import io.element.android.libraries.architecture.AsyncData
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -31,6 +34,7 @@ fun ChangeServerView(
onSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val eventSink = state.eventSink
when (state.changeServerAction) {
is AsyncData.Failure -> {
@@ -56,6 +60,24 @@ fun ChangeServerView(
}
)
}
is ChangeServerError.NeedElementPro -> {
ConfirmationDialog(
modifier = modifier,
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
content = stringResource(
R.string.screen_change_server_error_element_pro_required_message,
error.unauthorisedAccountProviderTitle,
),
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
onSubmitClick = {
context.openGooglePlay(error.applicationId)
eventSink.invoke(ChangeServerEvents.ClearError)
},
onDismiss = {
eventSink.invoke(ChangeServerEvents.ClearError)
},
)
}
is ChangeServerError.UnauthorizedAccountProvider -> {
ErrorDialog(
modifier = modifier,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
<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_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_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>

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 io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -106,13 +111,48 @@ class ChangeServerPresenterTest {
}
}
@Test
fun `present - change server element pro required error`() = runTest {
val retrieveResult = lambdaRecorder<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(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
) = ChangeServerPresenter(
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever,
),
)
}

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

View File

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

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

View File

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

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

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

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

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

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

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.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -105,10 +106,12 @@ fun TimelineItemVideoView(
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive && onContentClick != null) {
Modifier.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick
)
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}

View File

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

View File

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

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
/**
* 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.
*/

View File

@@ -13,6 +13,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.impl.crash.CrashDataStore
@@ -28,11 +29,14 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
@@ -71,6 +75,8 @@ class DefaultBugReporter @Inject constructor(
private val bugReporterUrlProvider: BugReporterUrlProvider,
private val sdkMetadata: SdkMetadata,
private val matrixClientProvider: MatrixClientProvider,
private val tracingService: TracingService,
matrixAuthenticationService: MatrixAuthenticationService,
) : BugReporter {
companion object {
// filenames
@@ -81,7 +87,24 @@ class DefaultBugReporter @Inject constructor(
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
private var currentTracingLogLevel: String? = null
private val logCatErrFile = File(logDirectory().absolutePath, LOG_CAT_FILENAME)
private val logCatErrFile: File
get() = File(logDirectory(), LOG_CAT_FILENAME)
private val baseLogDirectory = File(context.cacheDir, LOG_DIRECTORY_NAME)
private var currentLogDirectory: File = baseLogDirectory
init {
if (buildMeta.isEnterpriseBuild) {
val logSubfolder = runBlocking {
sessionStore.getLatestSession()
}?.userId?.substringAfter(":")
setCurrentLogDirectory(logSubfolder)
matrixAuthenticationService.listenToNewMatrixClients {
// When a new Matrix client is created, we update the tracing configuration to write
// the files in a dedicated subfolders.
setLogDirectorySubfolder(it.userIdServerName())
}
}
}
override suspend fun sendBugReport(
withDevicesLogs: Boolean,
@@ -286,16 +309,44 @@ class DefaultBugReporter @Inject constructor(
}
override fun logDirectory(): File {
return File(context.cacheDir, LOG_DIRECTORY_NAME).apply {
return currentLogDirectory.apply {
mkdirs()
}
}
override fun setLogDirectorySubfolder(subfolderName: String?) {
if (buildMeta.isEnterpriseBuild) {
setCurrentLogDirectory(subfolderName)
tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration())
}
}
private fun setCurrentLogDirectory(subfolderName: String?) {
currentLogDirectory = if (subfolderName == null) {
baseLogDirectory
} else {
File(baseLogDirectory, subfolderName)
}
}
suspend fun deleteAllFiles(predicate: (File) -> Boolean) {
withContext(coroutineDispatchers.io) {
getLogFiles()
.filter(predicate)
.forEach { it.safeDelete() }
deleteAllFilesRecursive(baseLogDirectory, predicate)
}
}
private fun deleteAllFilesRecursive(
directory: File,
predicate: (File) -> Boolean,
) {
directory.listFiles()?.forEach { file ->
if (file.isDirectory) {
deleteAllFilesRecursive(file, predicate)
} else {
if (predicate(file)) {
file.safeDelete()
}
}
}
}
@@ -325,11 +376,12 @@ class DefaultBugReporter @Inject constructor(
* @return the file if the operation succeeds
*/
override fun saveLogCat() {
if (logCatErrFile.exists()) {
logCatErrFile.safeDelete()
val file = logCatErrFile
if (file.exists()) {
file.safeDelete()
}
try {
logCatErrFile.writer().use {
file.writer().use {
getLogCatError(it)
}
} catch (error: OutOfMemoryError) {

View File

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

View File

@@ -10,16 +10,26 @@ package io.element.android.features.rageshake.impl.reporter
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.tracing.FakeTracingService
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -45,7 +55,7 @@ class DefaultBugReporterTest {
.setResponseCode(200)
)
server.start()
val sut = createDefaultBugReporter(server)
val sut = createDefaultBugReporter(server = server)
var onUploadCancelledCalled = false
var onUploadFailedCalled = false
val progressValues = mutableListOf<Int>()
@@ -97,22 +107,14 @@ class DefaultBugReporterTest {
storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH"))
}
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY")
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
val sut = createDefaultBugReporter(
server = server,
crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
)
@@ -166,22 +168,13 @@ class DefaultBugReporterTest {
storeData(aSessionData("@foo:example.com", "ABCDEFGH"))
}
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
val sut = createDefaultBugReporter(
server = server,
sessionStore = mockSessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
)
@@ -209,21 +202,13 @@ class DefaultBugReporterTest {
)
server.start()
val buildMeta = aBuildMeta()
val fakeEncryptionService = FakeEncryptionService()
fakeEncryptionService.givenDeviceKeys(null, null)
val sut = DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
val sut = createDefaultBugReporter(
server = server,
crashDataStore = FakeCrashDataStore("I did crash", true),
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = InMemorySessionStore(),
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) })
)
@@ -276,7 +261,7 @@ class DefaultBugReporterTest {
.setBody("""{"error": "An error body"}""")
)
server.start()
val sut = createDefaultBugReporter(server)
val sut = createDefaultBugReporter(server = server)
var onUploadCancelledCalled = false
var onUploadFailedCalled = false
var onUploadFailedReason: String? = null
@@ -318,22 +303,172 @@ class DefaultBugReporterTest {
assertThat(onUploadSucceedCalled).isFalse()
}
@Test
fun `the log directory is initialized using the last session store data`() = runTest {
val sut = createDefaultBugReporter(
buildMeta = aBuildMeta(isEnterpriseBuild = true),
sessionStore = InMemorySessionStore().apply {
storeData(aSessionData(sessionId = "@alice:domain.com"))
}
)
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.com")
}
@Test
fun `foss build - the log directory is initialized to the root log directory`() = runTest {
val sut = createDefaultBugReporter(
sessionStore = InMemorySessionStore().apply {
storeData(aSessionData(sessionId = "@alice:domain.com"))
}
)
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs")
}
@Test
fun `when the log directory is updated, the tracing service is invoked`() = runTest {
var param: WriteToFilesConfiguration? = null
val updateWriteToFilesConfigurationResult = lambdaRecorder<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(
server: MockWebServer
buildMeta: BuildMeta = aBuildMeta(),
sessionStore: SessionStore = InMemorySessionStore(),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
crashDataStore: CrashDataStore = FakeCrashDataStore(),
server: MockWebServer = MockWebServer(),
tracingService: TracingService = FakeTracingService(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
): DefaultBugReporter {
val buildMeta = aBuildMeta()
return DefaultBugReporter(
context = RuntimeEnvironment.getApplication(),
screenshotHolder = FakeScreenshotHolder(),
crashDataStore = FakeCrashDataStore(),
crashDataStore = crashDataStore,
coroutineDispatchers = testCoroutineDispatchers(),
okHttpClient = { OkHttpClient.Builder().build() },
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = InMemorySessionStore(),
sessionStore = sessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = FakeMatrixClientProvider()
matrixClientProvider = matrixClientProvider,
tracingService = tracingService,
matrixAuthenticationService = matrixAuthenticationService,
)
}

View File

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

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" }
gms_google_services = "com.google.gms:google-services:4.4.3"
# https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:34.0.0"
google_firebase_bom = "com.google.firebase:firebase-bom:34.1.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@@ -182,7 +182,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
sqlcipher = "net.zetetic:sqlcipher-android:4.10.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@@ -198,7 +198,7 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
# Analytics
posthog = "com.posthog:posthog-android:3.20.1"
posthog = "com.posthog:posthog-android:3.20.2"
sentry = "io.sentry:sentry-android:8.18.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"

View File

@@ -165,6 +165,7 @@ fun Context.startSharePlainTextIntent(
fun Context.openUrlInExternalApp(
url: String,
errorMessage: String = getString(R.string.error_no_compatible_app_found),
throwInCaseOfError: Boolean = false,
) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (this !is Activity) {
@@ -173,10 +174,27 @@ fun Context.openUrlInExternalApp(
try {
startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
if (throwInCaseOfError) throw activityNotFoundException
toast(errorMessage)
}
}
/**
* Open Google Play on the provided application Id.
*/
fun Context.openGooglePlay(
appId: String,
) {
try {
openUrlInExternalApp(
url = "market://details?id=$appId",
throwInCaseOfError = true,
)
} catch (_: ActivityNotFoundException) {
openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId")
}
}
// Not in KTX anymore
fun Context.toast(resId: Int) {
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()

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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -54,7 +59,17 @@ fun ModalBottomSheet(
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
modifier = modifier.onKeyEvent { keyEvent ->
// It seems that on some devices, we have to handle the Escape key manually to close the bottom sheet.
// This is not the case using an emulator, but is necessary on some physical devices.
if (keyEvent.type == KeyEventType.KeyUp &&
keyEvent.key == Key.Escape) {
onDismissRequest()
true
} else {
false
}
},
sheetState = safeSheetState,
shape = shape,
containerColor = containerColor,

View File

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

View File

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

View File

@@ -53,7 +53,9 @@ class RustNotificationService(
is BatchNotificationResult.Ok -> {
when (val status = result.status) {
is NotificationStatus.Event -> {
put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item)))
val result = notificationMapper.map(sessionId, eventId, roomId, status.item)
result.onFailure { Timber.e(it, "Could not map notification event $eventId") }
put(eventId, result)
}
is NotificationStatus.EventNotFound -> {
Timber.e("Could not retrieve event for notification with $eventId - event not found")

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
@@ -21,10 +22,12 @@ import org.matrix.rustcomponents.sdk.TimelineEventType
import org.matrix.rustcomponents.sdk.use
class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): NotificationContent {
return timelineEvent.use {
timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return runCatchingExceptions {
timelineEvent.use {
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.WriteToFilesConfiguration
import org.matrix.rustcomponents.sdk.TracingFileConfiguration
import org.matrix.rustcomponents.sdk.reloadTracingFileWriter
import timber.log.Timber
import javax.inject.Inject
@@ -23,6 +24,12 @@ class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) :
override fun createTimberTree(target: String): Timber.Tree {
return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable)
}
override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) {
config.toTracingFileConfiguration()?.let {
reloadTracingFileWriter(it)
}
}
}
private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel {

View File

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

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.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEvent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@@ -26,6 +30,8 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEventType
class RustNotificationServiceTest {
@Test
@@ -49,6 +55,33 @@ class RustNotificationServiceTest {
)
}
@Test
fun `test mapping invalid item only drops that item`() = runTest {
val error = IllegalStateException("This event type is not supported")
val faultyEvent = object : FakeFfiTimelineEvent() {
override fun eventType(): TimelineEventType {
throw error
}
}
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(
AN_EVENT_ID.value to aRustBatchNotificationResult(
notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent)))
),
AN_EVENT_ID_2.value to aRustBatchNotificationResult()
),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID, AN_EVENT_ID_2))).getOrThrow()
val exception = result[AN_EVENT_ID]!!.exceptionOrNull()
assertThat(exception).isEqualTo(error)
val successfulResult = result[AN_EVENT_ID_2]
assertThat(successfulResult?.isSuccess).isTrue()
}
@Test
fun `test unable to resolve event`() = runTest {
val notificationClient = FakeFfiNotificationClient(

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_2 = "matrix-client.org"
const val AN_ACCOUNT_PROVIDER_URL = "https://account.provider.org"
const val AN_ACCOUNT_PROVIDER = "matrix.org"
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
const val AN_ACCOUNT_PROVIDER_3 = "other.io"

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

View File

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

View File

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

View File

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

View File

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

View File

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