Merge branch 'release/25.08.2'
This commit is contained in:
2
.github/workflows/maestro-local.yml
vendored
2
.github/workflows/maestro-local.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
@@ -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: |
|
||||
|
||||
19
CHANGES.md
19
CHANGES.md
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
3
fastlane/metadata/android/en-US/changelogs/202508020.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/202508020.txt
Normal 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
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,14 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
|
||||
)
|
||||
)
|
||||
),
|
||||
aChangeServerState(
|
||||
changeServerAction = AsyncData.Failure(
|
||||
ChangeServerError.NeedElementPro(
|
||||
unauthorisedAccountProviderTitle = "example.com",
|
||||
applicationId = "applicationId",
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -11,4 +11,6 @@ import timber.log.Timber
|
||||
|
||||
interface TracingService {
|
||||
fun createTimberTree(target: String): Timber.Tree
|
||||
|
||||
fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user