Merge branch 'release/25.08.2'
This commit is contained in:
commit
8b4b321712
86 changed files with 1221 additions and 223 deletions
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eb923fffd20dc775f31b998e23c39acf79013a831a7c292c4cdba5b9142b4094
|
||||
size 68227
|
||||
oid sha256:6fd52151e94328a68ef5b65bdc0e5e0d730bd617ed2b58ad92c350fb409c75d2
|
||||
size 63096
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a9066615bfa2f78b77784302ce0272e3a486c79e267629c50c35ec1221c58046
|
||||
size 64443
|
||||
oid sha256:a67035a18b067b07f52090ee0ca9e3defa34a158e7dcefc55062131ac1599823
|
||||
size 59193
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f682f46ad20ea337ed76ca04e9a8f09f2b214e3f900a69463676d97675579e32
|
||||
size 26776
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f63e4f092e4bd7e6d0eeec2f4176963b2460d1d434a8a7ecc89a72c34ec3b3a9
|
||||
size 25068
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:baa26e4da095cdd3f17d90bfd17a410d4d04c2c255287afdd2f6212764e2417d
|
||||
size 32142
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77f1ad1ffa6b0f8d8e23a276464c5b9930ec7a694b1646e80b135c1de6d99893
|
||||
size 11352
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:182d98e4af86fa766fc1fd51fa1b295875cdd7b8523716eac6875cda8d78205b
|
||||
size 26213
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1042d3c9994ff687684f47f3e4c64d1d0e7fdb4c35f4b898024b1fdc187fe503
|
||||
size 15322
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fcaa6f6abb01095e62ece79207536c035e70335403a4c6d73c7e18bdc7d0aa5d
|
||||
size 27024
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e84510ac41d16fd20099a2e8d3bedb0b062796d6d8e709b24ce327d0d17d071e
|
||||
size 30022
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ebb8817e231bfae265698c45d030aa055f43848976993de600ae375cb5831f52
|
||||
size 9946
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:628864c47de8fa14e821cf9e4f141888eb70bd4df629c523bd39c0674c6d4fae
|
||||
size 24426
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:248864ef59f926d634045de482019dd9f9927d96cde2a34efeadae0b005e95fb
|
||||
size 13580
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:066175e7a3408f163dc0b1126b3644ab83d08f2627bd0429e3fa4a14e1444c03
|
||||
size 25335
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83ba365f6653a558c2e85f981477fb9f044ad57006c8c44f51435130ff773216
|
||||
size 30457
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0bb3cc28228eb14cb0552a5ab3e42f3188e70076a6697c4199acab1ca7ac4142
|
||||
size 29240
|
||||
Loading…
Add table
Add a link
Reference in a new issue