Merge branch 'release/25.08.2'
This commit is contained in:
commit
917fbbf82f
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
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
- name: Download APK artifact from previous job
|
- name: Download APK artifact from previous job
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: elementx-apk-maestro
|
name: elementx-apk-maestro
|
||||||
- name: Enable KVM group perms
|
- name: Enable KVM group perms
|
||||||
|
|
|
||||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -284,7 +284,7 @@ jobs:
|
||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
- name: Download reports from previous jobs
|
- name: Download reports from previous jobs
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
- name: Prepare Danger
|
- name: Prepare Danger
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
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
|
Changes in Element X v25.08.0
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,10 @@ package io.element.android.x.initializer
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.system.Os
|
import android.system.Os
|
||||||
import androidx.startup.Initializer
|
import androidx.startup.Initializer
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
||||||
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
|
||||||
import io.element.android.x.di.AppBindings
|
import io.element.android.x.di.AppBindings
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
@ -34,7 +33,7 @@ class PlatformInitializer : Initializer<Unit> {
|
||||||
val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() }
|
val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() }
|
||||||
val tracingConfiguration = TracingConfiguration(
|
val tracingConfiguration = TracingConfiguration(
|
||||||
writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) },
|
writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) },
|
||||||
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
|
writesToFilesConfiguration = bugReporter.createWriteToFilesConfiguration(),
|
||||||
logLevel = logLevel,
|
logLevel = logLevel,
|
||||||
extraTargets = listOf(ELEMENT_X_TARGET),
|
extraTargets = listOf(ELEMENT_X_TARGET),
|
||||||
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
|
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
|
||||||
|
|
@ -45,14 +44,5 @@ class PlatformInitializer : Initializer<Unit> {
|
||||||
Os.setenv("RUST_BACKTRACE", "1", true)
|
Os.setenv("RUST_BACKTRACE", "1", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultWriteToDiskConfiguration(bugReporter: BugReporter): WriteToFilesConfiguration.Enabled {
|
|
||||||
return WriteToFilesConfiguration.Enabled(
|
|
||||||
directory = bugReporter.logDirectory().absolutePath,
|
|
||||||
filenamePrefix = "logs",
|
|
||||||
// Keep a maximum of 1 week of log files.
|
|
||||||
numberOfFiles = 7 * 24,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
|
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.features.login.api)
|
||||||
|
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,10 @@ import io.element.android.appnav.intent.ResolvedIntent
|
||||||
import io.element.android.appnav.root.RootNavStateFlowFactory
|
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||||
import io.element.android.appnav.root.RootPresenter
|
import io.element.android.appnav.root.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
import io.element.android.appnav.root.RootView
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
|
||||||
import io.element.android.features.login.api.LoginParams
|
import io.element.android.features.login.api.LoginParams
|
||||||
|
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
|
|
@ -64,7 +65,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
@Assisted val buildContext: BuildContext,
|
@Assisted val buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val accountProviderAccessControl: AccountProviderAccessControl,
|
||||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||||
private val matrixSessionCache: MatrixSessionCache,
|
private val matrixSessionCache: MatrixSessionCache,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
|
|
@ -73,6 +74,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||||
private val intentResolver: IntentResolver,
|
private val intentResolver: IntentResolver,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
|
private val bugReporter: BugReporter,
|
||||||
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.SplashScreen,
|
initialElement = NavTarget.SplashScreen,
|
||||||
|
|
@ -123,6 +125,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
private fun switchToNotLoggedInFlow(params: LoginParams?) {
|
private fun switchToNotLoggedInFlow(params: LoginParams?) {
|
||||||
matrixSessionCache.removeAll()
|
matrixSessionCache.removeAll()
|
||||||
|
bugReporter.setLogDirectorySubfolder(null)
|
||||||
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
|
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,7 +296,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
val latestSessionId = authenticationService.getLatestSessionId()
|
val latestSessionId = authenticationService.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
// No session, open login
|
// No session, open login
|
||||||
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
|
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
||||||
switchToNotLoggedInFlow(params)
|
switchToNotLoggedInFlow(params)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,7 @@ class MatrixSessionCache @Inject constructor(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
authenticationService.listenToNewMatrixClients { matrixClient ->
|
authenticationService.listenToNewMatrixClients { matrixClient ->
|
||||||
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
|
onNewMatrixClient(matrixClient)
|
||||||
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
|
|
||||||
matrixClient = matrixClient,
|
|
||||||
syncOrchestrator = syncOrchestrator,
|
|
||||||
)
|
|
||||||
syncOrchestrator.start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,17 +100,21 @@ class MatrixSessionCache @Inject constructor(
|
||||||
Timber.d("Restore matrix session: $sessionId")
|
Timber.d("Restore matrix session: $sessionId")
|
||||||
return authenticationService.restoreSession(sessionId)
|
return authenticationService.restoreSession(sessionId)
|
||||||
.onSuccess { matrixClient ->
|
.onSuccess { matrixClient ->
|
||||||
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
|
onNewMatrixClient(matrixClient)
|
||||||
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
|
|
||||||
matrixClient = matrixClient,
|
|
||||||
syncOrchestrator = syncOrchestrator,
|
|
||||||
)
|
|
||||||
syncOrchestrator.start()
|
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
Timber.e(it, "Fail to restore session")
|
Timber.e(it, "Fail to restore session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onNewMatrixClient(matrixClient: MatrixClient) {
|
||||||
|
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
|
||||||
|
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
syncOrchestrator = syncOrchestrator,
|
||||||
|
)
|
||||||
|
syncOrchestrator.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InMemoryMatrixSession(
|
private data class InMemoryMatrixSession(
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ allprojects {
|
||||||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.26")
|
detektPlugins("io.nlopez.compose.rules:detekt:0.4.27")
|
||||||
detektPlugins(project(":tests:detekt-rules"))
|
detektPlugins(project(":tests:detekt-rules"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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(
|
contentPadding = PaddingValues(
|
||||||
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
|
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
|
||||||
// and include provided bottom padding
|
// and include provided bottom padding
|
||||||
bottom = 80.dp + padding.calculateBottomPadding(),
|
// Disable contentPadding due to navigation issue using the keyboard
|
||||||
top = padding.calculateTopPadding()
|
// See https://issuetracker.google.com/issues/436432313
|
||||||
|
bottom = 80.dp,
|
||||||
|
// bottom = 80.dp + padding.calculateBottomPadding(),
|
||||||
|
// top = padding.calculateTopPadding()
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(
|
.padding(
|
||||||
PaddingValues(
|
PaddingValues(
|
||||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
||||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
||||||
|
// Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed
|
||||||
|
bottom = padding.calculateBottomPadding(),
|
||||||
|
top = padding.calculateTopPadding()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import io.element.android.libraries.core.extensions.orEmpty
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
|
@ -170,14 +171,15 @@ private fun RoomSummaryScaffoldRow(
|
||||||
hideAvatarImage: Boolean = false,
|
hideAvatarImage: Boolean = false,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val clickModifier = Modifier.combinedClickable(
|
val clickModifier = Modifier
|
||||||
onClick = { onClick(room) },
|
.combinedClickable(
|
||||||
onLongClick = { onLongClick(room) },
|
onClick = { onClick(room) },
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClick = { onLongClick(room) },
|
||||||
indication = ripple(),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
indication = ripple(),
|
||||||
)
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
|
.onKeyboardContextMenuAction { onLongClick(room) }
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
|
@ -27,7 +27,7 @@ import javax.inject.Inject
|
||||||
class ChangeServerPresenter @Inject constructor(
|
class ChangeServerPresenter @Inject constructor(
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
) : Presenter<ChangeServerState> {
|
) : Presenter<ChangeServerState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ChangeServerState {
|
override fun present(): ChangeServerState {
|
||||||
|
|
@ -55,12 +55,10 @@ class ChangeServerPresenter @Inject constructor(
|
||||||
changeServerAction: MutableState<AsyncData<Unit>>,
|
changeServerAction: MutableState<AsyncData<Unit>>,
|
||||||
) = launch {
|
) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
if (enterpriseService.isAllowedToConnectToHomeserver(data.url).not()) {
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||||
throw UnauthorizedAccountProviderException(
|
title = data.title,
|
||||||
unauthorisedAccountProviderTitle = data.title,
|
accountProviderUrl = data.url,
|
||||||
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
authenticationService.setHomeserver(data.url).map {
|
authenticationService.setHomeserver(data.url).map {
|
||||||
authenticationService.getHomeserverDetails().value!!
|
authenticationService.getHomeserverDetails().value!!
|
||||||
// Valid, remember user choice
|
// Valid, remember user choice
|
||||||
|
|
|
||||||
|
|
@ -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.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -31,6 +34,7 @@ fun ChangeServerView(
|
||||||
onSuccess: () -> Unit,
|
onSuccess: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
when (state.changeServerAction) {
|
when (state.changeServerAction) {
|
||||||
is AsyncData.Failure -> {
|
is AsyncData.Failure -> {
|
||||||
|
|
@ -56,6 +60,24 @@ fun ChangeServerView(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ChangeServerError.NeedElementPro -> {
|
||||||
|
ConfirmationDialog(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
|
||||||
|
content = stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
),
|
||||||
|
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
|
||||||
|
onSubmitClick = {
|
||||||
|
context.openGooglePlay(error.applicationId)
|
||||||
|
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
is ChangeServerError.UnauthorizedAccountProvider -> {
|
is ChangeServerError.UnauthorizedAccountProvider -> {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -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.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
sealed class ChangeServerError : Throwable() {
|
sealed class ChangeServerError : Exception() {
|
||||||
data class Error(
|
data class Error(
|
||||||
@StringRes val messageId: Int? = null,
|
@StringRes val messageId: Int? = null,
|
||||||
val messageStr: String? = null,
|
val messageStr: String? = null,
|
||||||
|
|
@ -26,6 +26,11 @@ sealed class ChangeServerError : Throwable() {
|
||||||
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
|
fun message(): String = messageStr ?: stringResource(messageId ?: CommonStrings.error_unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NeedElementPro(
|
||||||
|
val unauthorisedAccountProviderTitle: String,
|
||||||
|
val applicationId: String,
|
||||||
|
) : ChangeServerError()
|
||||||
|
|
||||||
data class UnauthorizedAccountProvider(
|
data class UnauthorizedAccountProvider(
|
||||||
val unauthorisedAccountProviderTitle: String,
|
val unauthorisedAccountProviderTitle: String,
|
||||||
val authorisedAccountProviderTitles: List<String>,
|
val authorisedAccountProviderTitles: List<String>,
|
||||||
|
|
@ -37,7 +42,11 @@ sealed class ChangeServerError : Throwable() {
|
||||||
fun from(error: Throwable): ChangeServerError = when (error) {
|
fun from(error: Throwable): ChangeServerError = when (error) {
|
||||||
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
|
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
|
||||||
is AuthenticationException.Oidc -> Error(messageStr = error.message)
|
is AuthenticationException.Oidc -> Error(messageStr = error.message)
|
||||||
is UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
|
is AccountProviderAccessException.NeedElementProException -> NeedElementPro(
|
||||||
|
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
||||||
|
applicationId = error.applicationId,
|
||||||
|
)
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> UnauthorizedAccountProvider(
|
||||||
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle,
|
||||||
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
|
authorisedAccountProviderTitles = error.authorisedAccountProviderTitles,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.login.impl.login
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
||||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
|
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -28,6 +35,7 @@ fun LoginModeView(
|
||||||
onNeedLoginPassword: () -> Unit,
|
onNeedLoginPassword: () -> Unit,
|
||||||
onCreateAccountContinue: (url: String) -> Unit
|
onCreateAccountContinue: (url: String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
when (loginMode) {
|
when (loginMode) {
|
||||||
is AsyncData.Failure -> {
|
is AsyncData.Failure -> {
|
||||||
when (val error = loginMode.error) {
|
when (val error = loginMode.error) {
|
||||||
|
|
@ -48,6 +56,21 @@ fun LoginModeView(
|
||||||
onDismiss = onClearError,
|
onDismiss = onClearError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ChangeServerError.NeedElementPro -> {
|
||||||
|
ConfirmationDialog(
|
||||||
|
title = stringResource(R.string.screen_change_server_error_element_pro_required_title),
|
||||||
|
content = stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
),
|
||||||
|
submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android),
|
||||||
|
onSubmitClick = {
|
||||||
|
context.openGooglePlay(error.applicationId)
|
||||||
|
onClearError()
|
||||||
|
},
|
||||||
|
onDismiss = onClearError,
|
||||||
|
)
|
||||||
|
}
|
||||||
is ChangeServerError.UnauthorizedAccountProvider -> {
|
is ChangeServerError.UnauthorizedAccountProvider -> {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
content = stringResource(
|
content = stringResource(
|
||||||
|
|
@ -87,3 +110,18 @@ fun LoginModeView(
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
|
||||||
|
ElementPreview {
|
||||||
|
LoginModeView(
|
||||||
|
loginMode = AsyncData.Failure(error),
|
||||||
|
onClearError = {},
|
||||||
|
onLearnMoreClick = {},
|
||||||
|
onOidcDetails = {},
|
||||||
|
onNeedLoginPassword = {},
|
||||||
|
onCreateAccountContinue = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,6 @@ import kotlinx.serialization.Serializable
|
||||||
data class ElementWellKnown(
|
data class ElementWellKnown(
|
||||||
@SerialName("registration_helper_url")
|
@SerialName("registration_helper_url")
|
||||||
val registrationHelperUrl: String? = null,
|
val registrationHelperUrl: String? = null,
|
||||||
|
@SerialName("enforce_element_pro")
|
||||||
|
val enforceElementPro: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.appconfig.OnBoardingConfig
|
import io.element.android.appconfig.OnBoardingConfig
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
|
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.login.LoginHelper
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -34,6 +35,7 @@ class OnBoardingPresenter @AssistedInject constructor(
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val enterpriseService: EnterpriseService,
|
||||||
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val loginHelper: LoginHelper,
|
private val loginHelper: LoginHelper,
|
||||||
) : Presenter<OnBoardingState> {
|
) : Presenter<OnBoardingState> {
|
||||||
|
|
@ -63,7 +65,12 @@ class OnBoardingPresenter @AssistedInject constructor(
|
||||||
val linkAccountProvider by produceState<String?>(initialValue = null) {
|
val linkAccountProvider by produceState<String?>(initialValue = null) {
|
||||||
// Account provider from the link, if allowed by the enterprise service
|
// Account provider from the link, if allowed by the enterprise service
|
||||||
value = params.accountProvider?.takeIf {
|
value = params.accountProvider?.takeIf {
|
||||||
enterpriseService.isAllowedToConnectToHomeserver(it)
|
try {
|
||||||
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val defaultAccountProvider = remember(linkAccountProvider) {
|
val defaultAccountProvider = remember(linkAccountProvider) {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
|
||||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
|
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -38,7 +37,7 @@ class QrCodeScanPresenter @Inject constructor(
|
||||||
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
|
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
|
||||||
private val qrCodeLoginManager: QrCodeLoginManager,
|
private val qrCodeLoginManager: QrCodeLoginManager,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||||
) : Presenter<QrCodeScanState> {
|
) : Presenter<QrCodeScanState> {
|
||||||
private var isScanning by mutableStateOf(true)
|
private var isScanning by mutableStateOf(true)
|
||||||
|
|
||||||
|
|
@ -97,10 +96,10 @@ class QrCodeScanPresenter @Inject constructor(
|
||||||
Timber.e(it, "Error parsing QR code data")
|
Timber.e(it, "Error parsing QR code data")
|
||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
val serverName = data.serverName()
|
val serverName = data.serverName()
|
||||||
if (serverName != null && enterpriseService.isAllowedToConnectToHomeserver(serverName).not()) {
|
if (serverName != null) {
|
||||||
throw UnauthorizedAccountProviderException(
|
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||||
unauthorisedAccountProviderTitle = serverName,
|
title = serverName,
|
||||||
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
|
accountProviderUrl = serverName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
data
|
data
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
package io.element.android.features.login.impl.screens.qrcode.scan
|
package io.element.android.features.login.impl.screens.qrcode.scan
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
|
||||||
|
|
@ -23,12 +23,21 @@ open class QrCodeScanStateProvider : PreviewParameterProvider<QrCodeScanState> {
|
||||||
aQrCodeScanState(
|
aQrCodeScanState(
|
||||||
isScanning = false,
|
isScanning = false,
|
||||||
authenticationAction = AsyncAction.Failure(
|
authenticationAction = AsyncAction.Failure(
|
||||||
UnauthorizedAccountProviderException(
|
AccountProviderAccessException.UnauthorizedAccountProviderException(
|
||||||
unauthorisedAccountProviderTitle = "example.com",
|
unauthorisedAccountProviderTitle = "example.com",
|
||||||
authorisedAccountProviderTitles = listOf("element.io", "element.org"),
|
authorisedAccountProviderTitles = listOf("element.io", "element.org"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
aQrCodeScanState(
|
||||||
|
isScanning = false,
|
||||||
|
authenticationAction = AsyncAction.Failure(
|
||||||
|
AccountProviderAccessException.NeedElementProException(
|
||||||
|
unauthorisedAccountProviderTitle = "example.com",
|
||||||
|
applicationId = "applicationId"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
|
|
@ -145,7 +145,10 @@ private fun ColumnScope.Buttons(
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = when (error) {
|
text = when (error) {
|
||||||
is UnauthorizedAccountProviderException -> {
|
is AccountProviderAccessException.NeedElementProException -> {
|
||||||
|
stringResource(R.string.screen_change_server_error_element_pro_required_title)
|
||||||
|
}
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> {
|
||||||
stringResource(
|
stringResource(
|
||||||
id = R.string.screen_change_server_error_unauthorized_homeserver_title,
|
id = R.string.screen_change_server_error_unauthorized_homeserver_title,
|
||||||
error.unauthorisedAccountProviderTitle,
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
|
@ -163,7 +166,13 @@ private fun ColumnScope.Buttons(
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = when (error) {
|
text = when (error) {
|
||||||
is UnauthorizedAccountProviderException -> {
|
is AccountProviderAccessException.NeedElementProException -> {
|
||||||
|
stringResource(
|
||||||
|
R.string.screen_change_server_error_element_pro_required_message,
|
||||||
|
error.unauthorisedAccountProviderTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AccountProviderAccessException.UnauthorizedAccountProviderException -> {
|
||||||
stringResource(
|
stringResource(
|
||||||
id = R.string.screen_change_server_error_unauthorized_homeserver_content,
|
id = R.string.screen_change_server_error_unauthorized_homeserver_content,
|
||||||
error.authorisedAccountProviderTitles.joinToString(),
|
error.authorisedAccountProviderTitles.joinToString(),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<string name="screen_change_account_provider_other">"Other"</string>
|
<string name="screen_change_account_provider_other">"Other"</string>
|
||||||
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
||||||
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
||||||
|
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
|
||||||
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
|
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
|
||||||
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
|
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
|
||||||
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
|
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
|
||||||
|
|
|
||||||
|
|
@ -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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
|
|
@ -106,13 +111,48 @@ class ChangeServerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - change server element pro required error`() = runTest {
|
||||||
|
val retrieveResult = lambdaRecorder<String, ElementWellKnown> {
|
||||||
|
ElementWellKnown(
|
||||||
|
enforceElementPro = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
createPresenter(
|
||||||
|
elementWellknownRetriever = FakeElementWellknownRetriever(
|
||||||
|
retrieveResult = retrieveResult,
|
||||||
|
),
|
||||||
|
).test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||||
|
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
|
||||||
|
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(
|
||||||
|
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
|
||||||
|
).isEqualTo(anAccountProvider.title)
|
||||||
|
assertThat(
|
||||||
|
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
|
||||||
|
).isEqualTo("io.element.enterprise")
|
||||||
|
retrieveResult.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_HOMESERVER_URL.ensureProtocol()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
) = ChangeServerPresenter(
|
) = ChangeServerPresenter(
|
||||||
authenticationService = authenticationService,
|
authenticationService = authenticationService,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
enterpriseService = enterpriseService,
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import io.element.android.appconfig.OnBoardingConfig
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
import io.element.android.features.login.impl.login.LoginHelper
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
|
|
@ -235,6 +238,7 @@ private fun createPresenter(
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
rageshakeFeatureAvailability: () -> Boolean = { true },
|
rageshakeFeatureAvailability: () -> Boolean = { true },
|
||||||
loginHelper: LoginHelper = createLoginHelper(),
|
loginHelper: LoginHelper = createLoginHelper(),
|
||||||
) = OnBoardingPresenter(
|
) = OnBoardingPresenter(
|
||||||
|
|
@ -242,6 +246,10 @@ private fun createPresenter(
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
enterpriseService = enterpriseService,
|
enterpriseService = enterpriseService,
|
||||||
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
loginHelper = loginHelper,
|
loginHelper = loginHelper,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.impl.changeserver.UnauthorizedAccountProviderException
|
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
|
||||||
|
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||||
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
|
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
|
|
@ -91,9 +94,15 @@ class QrCodeScanPresenterTest {
|
||||||
assertThat(awaitItem().isScanning).isFalse()
|
assertThat(awaitItem().isScanning).isFalse()
|
||||||
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
|
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle)
|
assertThat(
|
||||||
|
(state.authenticationAction
|
||||||
|
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle
|
||||||
|
)
|
||||||
.isEqualTo("example.com")
|
.isEqualTo("example.com")
|
||||||
assertThat((state.authenticationAction.errorOrNull() as UnauthorizedAccountProviderException).authorisedAccountProviderTitles)
|
assertThat(
|
||||||
|
(state.authenticationAction
|
||||||
|
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles
|
||||||
|
)
|
||||||
.containsExactly("element.io")
|
.containsExactly("element.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,10 +162,14 @@ class QrCodeScanPresenterTest {
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
|
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
|
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
|
||||||
) = QrCodeScanPresenter(
|
) = QrCodeScanPresenter(
|
||||||
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
||||||
qrCodeLoginManager = qrCodeLoginManager,
|
qrCodeLoginManager = qrCodeLoginManager,
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
enterpriseService = enterpriseService,
|
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
|
elementWellknownRetriever = elementWellknownRetriever,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.model.bubble.BubbleSta
|
||||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
|
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
|
||||||
import io.element.android.libraries.core.extensions.to01
|
import io.element.android.libraries.core.extensions.to01
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
|
@ -96,12 +97,14 @@ fun MessageEventBubble(
|
||||||
val clickableModifier = if (isTalkbackActive()) {
|
val clickableModifier = if (isTalkbackActive()) {
|
||||||
Modifier
|
Modifier
|
||||||
} else {
|
} else {
|
||||||
Modifier.combinedClickable(
|
Modifier
|
||||||
onClick = onClick,
|
.combinedClickable(
|
||||||
onLongClick = onLongClick,
|
onClick = onClick,
|
||||||
indication = ripple(),
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource
|
indication = ripple(),
|
||||||
)
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore state.isHighlighted for now, we need a design decision on it.
|
// Ignore state.isHighlighted for now, we need a design decision on it.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
|
@ -46,7 +47,8 @@ fun MessageStateEventContainer(
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
indication = ripple(),
|
indication = ripple(),
|
||||||
interactionSource = interactionSource
|
interactionSource = interactionSource
|
||||||
),
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick),
|
||||||
color = backgroundColor,
|
color = backgroundColor,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
content = content
|
content = content
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.AggregatedReacti
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
|
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
|
||||||
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
||||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
|
@ -107,6 +108,7 @@ fun MessagesReactionButton(
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
onLongClick = onLongClick
|
onLongClick = onLongClick
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
// Inner border, to highlight when selected
|
// Inner border, to highlight when selected
|
||||||
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.features.roomcall.api.RoomCallStateProvider
|
import io.element.android.features.roomcall.api.RoomCallStateProvider
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
|
@ -57,6 +58,7 @@ internal fun TimelineItemCallNotifyView(
|
||||||
onLongClick = { onLongClick(event) },
|
onLongClick = { onLongClick(event) },
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction { onLongClick(event) }
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.modifiers.subtleColorStops
|
import io.element.android.libraries.designsystem.modifiers.subtleColorStops
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -148,11 +149,13 @@ internal fun TimelineItemRow(
|
||||||
// Custom clickable that applies over the whole item for accessibility
|
// Custom clickable that applies over the whole item for accessibility
|
||||||
.then(
|
.then(
|
||||||
if (isTalkbackActive()) {
|
if (isTalkbackActive()) {
|
||||||
Modifier.combinedClickable(
|
Modifier
|
||||||
onClick = { onContentClick(timelineItem) },
|
.combinedClickable(
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
onClick = { onContentClick(timelineItem) },
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
)
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
|
)
|
||||||
|
.onKeyboardContextMenuAction { onLongClick(timelineItem) }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
||||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||||
|
|
@ -91,10 +92,12 @@ fun TimelineItemImageView(
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (!isTalkbackActive() && onContentClick != null) {
|
if (!isTalkbackActive() && onContentClick != null) {
|
||||||
Modifier.combinedClickable(
|
Modifier
|
||||||
onClick = onContentClick,
|
.combinedClickable(
|
||||||
onLongClick = onLongClick
|
onClick = onContentClick,
|
||||||
)
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
@ -74,6 +75,7 @@ fun TimelineItemStickerView(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
||||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -105,10 +106,12 @@ fun TimelineItemVideoView(
|
||||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (!isTalkbackActive && onContentClick != null) {
|
if (!isTalkbackActive && onContentClick != null) {
|
||||||
Modifier.combinedClickable(
|
Modifier
|
||||||
onClick = onContentClick,
|
.combinedClickable(
|
||||||
onLongClick = onLongClick
|
onClick = onContentClick,
|
||||||
)
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
|
@ -83,8 +82,6 @@ fun ReactionSummaryView(
|
||||||
state: ReactionSummaryState,
|
state: ReactionSummaryState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
|
|
||||||
fun onDismiss() {
|
fun onDismiss() {
|
||||||
state.eventSink(ReactionSummaryEvents.Clear)
|
state.eventSink(ReactionSummaryEvents.Clear)
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +89,6 @@ fun ReactionSummaryView(
|
||||||
if (state.target != null) {
|
if (state.target != null) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = ::onDismiss,
|
onDismissRequest = ::onDismiss,
|
||||||
sheetState = sheetState,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
ReactionSummaryViewContent(summary = state.target)
|
ReactionSummaryViewContent(summary = state.target)
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,6 @@ dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
fun logDirectory(): File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the subfolder name for the log directory.
|
||||||
|
* This will create a subfolder in the log directory with the given name.
|
||||||
|
* It will also configure the Rust SDK to use this subfolder for its logs.
|
||||||
|
* If the name is null, the log files will be stored in the base folder for the logs.
|
||||||
|
*/
|
||||||
|
fun setLogDirectorySubfolder(subfolderName: String?)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current tracing log level.
|
* Set the current tracing log level.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.appconfig.RageshakeConfig
|
import io.element.android.appconfig.RageshakeConfig
|
||||||
|
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporterListener
|
import io.element.android.features.rageshake.api.reporter.BugReporterListener
|
||||||
import io.element.android.features.rageshake.impl.crash.CrashDataStore
|
import io.element.android.features.rageshake.impl.crash.CrashDataStore
|
||||||
|
|
@ -28,11 +29,14 @@ import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.SdkMetadata
|
import io.element.android.libraries.matrix.api.SdkMetadata
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TracingService
|
||||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -71,6 +75,8 @@ class DefaultBugReporter @Inject constructor(
|
||||||
private val bugReporterUrlProvider: BugReporterUrlProvider,
|
private val bugReporterUrlProvider: BugReporterUrlProvider,
|
||||||
private val sdkMetadata: SdkMetadata,
|
private val sdkMetadata: SdkMetadata,
|
||||||
private val matrixClientProvider: MatrixClientProvider,
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
|
private val tracingService: TracingService,
|
||||||
|
matrixAuthenticationService: MatrixAuthenticationService,
|
||||||
) : BugReporter {
|
) : BugReporter {
|
||||||
companion object {
|
companion object {
|
||||||
// filenames
|
// filenames
|
||||||
|
|
@ -81,7 +87,24 @@ class DefaultBugReporter @Inject constructor(
|
||||||
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
|
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
|
||||||
private var currentTracingLogLevel: String? = null
|
private var currentTracingLogLevel: String? = null
|
||||||
|
|
||||||
private val logCatErrFile = File(logDirectory().absolutePath, LOG_CAT_FILENAME)
|
private val logCatErrFile: File
|
||||||
|
get() = File(logDirectory(), LOG_CAT_FILENAME)
|
||||||
|
private val baseLogDirectory = File(context.cacheDir, LOG_DIRECTORY_NAME)
|
||||||
|
private var currentLogDirectory: File = baseLogDirectory
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (buildMeta.isEnterpriseBuild) {
|
||||||
|
val logSubfolder = runBlocking {
|
||||||
|
sessionStore.getLatestSession()
|
||||||
|
}?.userId?.substringAfter(":")
|
||||||
|
setCurrentLogDirectory(logSubfolder)
|
||||||
|
matrixAuthenticationService.listenToNewMatrixClients {
|
||||||
|
// When a new Matrix client is created, we update the tracing configuration to write
|
||||||
|
// the files in a dedicated subfolders.
|
||||||
|
setLogDirectorySubfolder(it.userIdServerName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendBugReport(
|
override suspend fun sendBugReport(
|
||||||
withDevicesLogs: Boolean,
|
withDevicesLogs: Boolean,
|
||||||
|
|
@ -286,16 +309,44 @@ class DefaultBugReporter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logDirectory(): File {
|
override fun logDirectory(): File {
|
||||||
return File(context.cacheDir, LOG_DIRECTORY_NAME).apply {
|
return currentLogDirectory.apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setLogDirectorySubfolder(subfolderName: String?) {
|
||||||
|
if (buildMeta.isEnterpriseBuild) {
|
||||||
|
setCurrentLogDirectory(subfolderName)
|
||||||
|
tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCurrentLogDirectory(subfolderName: String?) {
|
||||||
|
currentLogDirectory = if (subfolderName == null) {
|
||||||
|
baseLogDirectory
|
||||||
|
} else {
|
||||||
|
File(baseLogDirectory, subfolderName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun deleteAllFiles(predicate: (File) -> Boolean) {
|
suspend fun deleteAllFiles(predicate: (File) -> Boolean) {
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
getLogFiles()
|
deleteAllFilesRecursive(baseLogDirectory, predicate)
|
||||||
.filter(predicate)
|
}
|
||||||
.forEach { it.safeDelete() }
|
}
|
||||||
|
|
||||||
|
private fun deleteAllFilesRecursive(
|
||||||
|
directory: File,
|
||||||
|
predicate: (File) -> Boolean,
|
||||||
|
) {
|
||||||
|
directory.listFiles()?.forEach { file ->
|
||||||
|
if (file.isDirectory) {
|
||||||
|
deleteAllFilesRecursive(file, predicate)
|
||||||
|
} else {
|
||||||
|
if (predicate(file)) {
|
||||||
|
file.safeDelete()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,11 +376,12 @@ class DefaultBugReporter @Inject constructor(
|
||||||
* @return the file if the operation succeeds
|
* @return the file if the operation succeeds
|
||||||
*/
|
*/
|
||||||
override fun saveLogCat() {
|
override fun saveLogCat() {
|
||||||
if (logCatErrFile.exists()) {
|
val file = logCatErrFile
|
||||||
logCatErrFile.safeDelete()
|
if (file.exists()) {
|
||||||
|
file.safeDelete()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
logCatErrFile.writer().use {
|
file.writer().use {
|
||||||
getLogCatError(it)
|
getLogCatError(it)
|
||||||
}
|
}
|
||||||
} catch (error: OutOfMemoryError) {
|
} catch (error: OutOfMemoryError) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter {
|
||||||
return File("fake")
|
return File("fake")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setLogDirectorySubfolder(subfolderName: String?) {
|
||||||
|
// No op
|
||||||
|
}
|
||||||
|
|
||||||
override fun setCurrentTracingLogLevel(logLevel: String) {
|
override fun setCurrentTracingLogLevel(logLevel: String) {
|
||||||
// No op
|
// No op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,26 @@ package io.element.android.features.rageshake.impl.reporter
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.appconfig.RageshakeConfig
|
import io.element.android.appconfig.RageshakeConfig
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporterListener
|
import io.element.android.features.rageshake.api.reporter.BugReporterListener
|
||||||
|
import io.element.android.features.rageshake.impl.crash.CrashDataStore
|
||||||
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
|
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
|
||||||
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
|
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TracingService
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.FakeSdkMetadata
|
import io.element.android.libraries.matrix.test.FakeSdkMetadata
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
|
import io.element.android.libraries.matrix.test.tracing.FakeTracingService
|
||||||
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
|
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -45,7 +55,7 @@ class DefaultBugReporterTest {
|
||||||
.setResponseCode(200)
|
.setResponseCode(200)
|
||||||
)
|
)
|
||||||
server.start()
|
server.start()
|
||||||
val sut = createDefaultBugReporter(server)
|
val sut = createDefaultBugReporter(server = server)
|
||||||
var onUploadCancelledCalled = false
|
var onUploadCancelledCalled = false
|
||||||
var onUploadFailedCalled = false
|
var onUploadFailedCalled = false
|
||||||
val progressValues = mutableListOf<Int>()
|
val progressValues = mutableListOf<Int>()
|
||||||
|
|
@ -97,22 +107,14 @@ class DefaultBugReporterTest {
|
||||||
storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH"))
|
storeData(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildMeta = aBuildMeta()
|
|
||||||
val fakeEncryptionService = FakeEncryptionService()
|
val fakeEncryptionService = FakeEncryptionService()
|
||||||
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
|
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
|
||||||
|
|
||||||
fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY")
|
fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY")
|
||||||
val sut = DefaultBugReporter(
|
val sut = createDefaultBugReporter(
|
||||||
context = RuntimeEnvironment.getApplication(),
|
server = server,
|
||||||
screenshotHolder = FakeScreenshotHolder(),
|
|
||||||
crashDataStore = FakeCrashDataStore(),
|
crashDataStore = FakeCrashDataStore(),
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
|
||||||
okHttpClient = { OkHttpClient.Builder().build() },
|
|
||||||
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
|
|
||||||
sessionStore = mockSessionStore,
|
sessionStore = mockSessionStore,
|
||||||
buildMeta = buildMeta,
|
|
||||||
bugReporterUrlProvider = { server.url("/") },
|
|
||||||
sdkMetadata = FakeSdkMetadata("123456789"),
|
|
||||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -166,22 +168,13 @@ class DefaultBugReporterTest {
|
||||||
storeData(aSessionData("@foo:example.com", "ABCDEFGH"))
|
storeData(aSessionData("@foo:example.com", "ABCDEFGH"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildMeta = aBuildMeta()
|
|
||||||
val fakeEncryptionService = FakeEncryptionService()
|
val fakeEncryptionService = FakeEncryptionService()
|
||||||
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
|
val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService)
|
||||||
|
|
||||||
fakeEncryptionService.givenDeviceKeys(null, null)
|
fakeEncryptionService.givenDeviceKeys(null, null)
|
||||||
val sut = DefaultBugReporter(
|
val sut = createDefaultBugReporter(
|
||||||
context = RuntimeEnvironment.getApplication(),
|
server = server,
|
||||||
screenshotHolder = FakeScreenshotHolder(),
|
|
||||||
crashDataStore = FakeCrashDataStore(),
|
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
|
||||||
okHttpClient = { OkHttpClient.Builder().build() },
|
|
||||||
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
|
|
||||||
sessionStore = mockSessionStore,
|
sessionStore = mockSessionStore,
|
||||||
buildMeta = buildMeta,
|
|
||||||
bugReporterUrlProvider = { server.url("/") },
|
|
||||||
sdkMetadata = FakeSdkMetadata("123456789"),
|
|
||||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -209,21 +202,13 @@ class DefaultBugReporterTest {
|
||||||
)
|
)
|
||||||
server.start()
|
server.start()
|
||||||
|
|
||||||
val buildMeta = aBuildMeta()
|
|
||||||
val fakeEncryptionService = FakeEncryptionService()
|
val fakeEncryptionService = FakeEncryptionService()
|
||||||
|
|
||||||
fakeEncryptionService.givenDeviceKeys(null, null)
|
fakeEncryptionService.givenDeviceKeys(null, null)
|
||||||
val sut = DefaultBugReporter(
|
val sut = createDefaultBugReporter(
|
||||||
context = RuntimeEnvironment.getApplication(),
|
server = server,
|
||||||
screenshotHolder = FakeScreenshotHolder(),
|
|
||||||
crashDataStore = FakeCrashDataStore("I did crash", true),
|
crashDataStore = FakeCrashDataStore("I did crash", true),
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
|
||||||
okHttpClient = { OkHttpClient.Builder().build() },
|
|
||||||
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
|
|
||||||
sessionStore = InMemorySessionStore(),
|
sessionStore = InMemorySessionStore(),
|
||||||
buildMeta = buildMeta,
|
|
||||||
bugReporterUrlProvider = { server.url("/") },
|
|
||||||
sdkMetadata = FakeSdkMetadata("123456789"),
|
|
||||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) })
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -276,7 +261,7 @@ class DefaultBugReporterTest {
|
||||||
.setBody("""{"error": "An error body"}""")
|
.setBody("""{"error": "An error body"}""")
|
||||||
)
|
)
|
||||||
server.start()
|
server.start()
|
||||||
val sut = createDefaultBugReporter(server)
|
val sut = createDefaultBugReporter(server = server)
|
||||||
var onUploadCancelledCalled = false
|
var onUploadCancelledCalled = false
|
||||||
var onUploadFailedCalled = false
|
var onUploadFailedCalled = false
|
||||||
var onUploadFailedReason: String? = null
|
var onUploadFailedReason: String? = null
|
||||||
|
|
@ -318,22 +303,172 @@ class DefaultBugReporterTest {
|
||||||
assertThat(onUploadSucceedCalled).isFalse()
|
assertThat(onUploadSucceedCalled).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the log directory is initialized using the last session store data`() = runTest {
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
buildMeta = aBuildMeta(isEnterpriseBuild = true),
|
||||||
|
sessionStore = InMemorySessionStore().apply {
|
||||||
|
storeData(aSessionData(sessionId = "@alice:domain.com"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build - the log directory is initialized to the root log directory`() = runTest {
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
sessionStore = InMemorySessionStore().apply {
|
||||||
|
storeData(aSessionData(sessionId = "@alice:domain.com"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the log directory is updated, the tracing service is invoked`() = runTest {
|
||||||
|
var param: WriteToFilesConfiguration? = null
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {
|
||||||
|
param = it
|
||||||
|
}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
buildMeta = aBuildMeta(isEnterpriseBuild = true),
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sut.setLogDirectorySubfolder("my.sub.folder")
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isCalledOnce()
|
||||||
|
assertThat(param).isNotNull()
|
||||||
|
assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs/my.sub.folder")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build - when the log directory is updated, the tracing service is not invoked`() = runTest {
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sut.setLogDirectorySubfolder("my.sub.folder")
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the log directory is reset, the tracing service is invoked`() = runTest {
|
||||||
|
var param: WriteToFilesConfiguration? = null
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {
|
||||||
|
param = it
|
||||||
|
}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
buildMeta = aBuildMeta(isEnterpriseBuild = true),
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sut.setLogDirectorySubfolder(null)
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isCalledOnce()
|
||||||
|
assertThat(param).isNotNull()
|
||||||
|
assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build - when the log directory is reset, the tracing service is not invoked`() = runTest {
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sut.setLogDirectorySubfolder(null)
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when a new MatrixClient is created the logs folder is updated`() = runTest {
|
||||||
|
var param: WriteToFilesConfiguration? = null
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {
|
||||||
|
param = it
|
||||||
|
}
|
||||||
|
val matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
|
||||||
|
givenMatrixClient(
|
||||||
|
FakeMatrixClient(
|
||||||
|
userIdServerNameLambda = { "domain.foo.org" },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
buildMeta = aBuildMeta(isEnterpriseBuild = true),
|
||||||
|
matrixAuthenticationService = matrixAuthenticationService,
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs")
|
||||||
|
matrixAuthenticationService.login("alice", "password")
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.foo.org")
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isCalledOnce()
|
||||||
|
assertThat(param).isNotNull()
|
||||||
|
assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs/domain.foo.org")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs")
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168)
|
||||||
|
assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foss build - when a new MatrixClient is created the logs folder is not updated`() = runTest {
|
||||||
|
val updateWriteToFilesConfigurationResult = lambdaRecorder<WriteToFilesConfiguration, Unit> {}
|
||||||
|
val matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
|
||||||
|
givenMatrixClient(
|
||||||
|
FakeMatrixClient(
|
||||||
|
userIdServerNameLambda = { "domain.foo.org" },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sut = createDefaultBugReporter(
|
||||||
|
matrixAuthenticationService = matrixAuthenticationService,
|
||||||
|
tracingService = FakeTracingService(
|
||||||
|
updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs")
|
||||||
|
matrixAuthenticationService.login("alice", "password")
|
||||||
|
assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs")
|
||||||
|
updateWriteToFilesConfigurationResult.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createDefaultBugReporter(
|
private fun TestScope.createDefaultBugReporter(
|
||||||
server: MockWebServer
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
|
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
|
crashDataStore: CrashDataStore = FakeCrashDataStore(),
|
||||||
|
server: MockWebServer = MockWebServer(),
|
||||||
|
tracingService: TracingService = FakeTracingService(),
|
||||||
|
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
): DefaultBugReporter {
|
): DefaultBugReporter {
|
||||||
val buildMeta = aBuildMeta()
|
|
||||||
return DefaultBugReporter(
|
return DefaultBugReporter(
|
||||||
context = RuntimeEnvironment.getApplication(),
|
context = RuntimeEnvironment.getApplication(),
|
||||||
screenshotHolder = FakeScreenshotHolder(),
|
screenshotHolder = FakeScreenshotHolder(),
|
||||||
crashDataStore = FakeCrashDataStore(),
|
crashDataStore = crashDataStore,
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
okHttpClient = { OkHttpClient.Builder().build() },
|
okHttpClient = { OkHttpClient.Builder().build() },
|
||||||
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
|
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
|
||||||
sessionStore = InMemorySessionStore(),
|
sessionStore = sessionStore,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
bugReporterUrlProvider = { server.url("/") },
|
bugReporterUrlProvider = { server.url("/") },
|
||||||
sdkMetadata = FakeSdkMetadata("123456789"),
|
sdkMetadata = FakeSdkMetadata("123456789"),
|
||||||
matrixClientProvider = FakeMatrixClientProvider()
|
matrixClientProvider = matrixClientProvider,
|
||||||
|
tracingService = tracingService,
|
||||||
|
matrixAuthenticationService = matrixAuthenticationService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
|
|
@ -145,11 +143,7 @@ private fun ChangeOwnRoleBottomSheet(
|
||||||
eventSink: (RolesAndPermissionsEvents) -> Unit,
|
eventSink: (RolesAndPermissionsEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val sheetState = if (LocalInspectionMode.current) {
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
sheetStateForPreview()
|
|
||||||
} else {
|
|
||||||
rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
}
|
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
sheetState.hide(coroutineScope) {
|
sheetState.hide(coroutineScope) {
|
||||||
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", ve
|
||||||
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
|
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
|
||||||
gms_google_services = "com.google.gms:google-services:4.4.3"
|
gms_google_services = "com.google.gms:google-services:4.4.3"
|
||||||
# https://firebase.google.com/docs/android/setup#available-libraries
|
# https://firebase.google.com/docs/android/setup#available-libraries
|
||||||
google_firebase_bom = "com.google.firebase:firebase-bom:34.0.0"
|
google_firebase_bom = "com.google.firebase:firebase-bom:34.1.0"
|
||||||
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
|
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
|
||||||
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
|
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
|
||||||
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
|
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
|
||||||
|
|
@ -182,7 +182,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
|
||||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
|
sqlcipher = "net.zetetic:sqlcipher-android:4.10.0"
|
||||||
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
|
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
|
||||||
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
|
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
|
||||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||||
|
|
@ -198,7 +198,7 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
posthog = "com.posthog:posthog-android:3.20.1"
|
posthog = "com.posthog:posthog-android:3.20.2"
|
||||||
sentry = "io.sentry:sentry-android:8.18.0"
|
sentry = "io.sentry:sentry-android:8.18.0"
|
||||||
# main branch can be tested replacing the version with main-SNAPSHOT
|
# main branch can be tested replacing the version with main-SNAPSHOT
|
||||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
|
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ fun Context.startSharePlainTextIntent(
|
||||||
fun Context.openUrlInExternalApp(
|
fun Context.openUrlInExternalApp(
|
||||||
url: String,
|
url: String,
|
||||||
errorMessage: String = getString(R.string.error_no_compatible_app_found),
|
errorMessage: String = getString(R.string.error_no_compatible_app_found),
|
||||||
|
throwInCaseOfError: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
if (this !is Activity) {
|
if (this !is Activity) {
|
||||||
|
|
@ -173,10 +174,27 @@ fun Context.openUrlInExternalApp(
|
||||||
try {
|
try {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||||
|
if (throwInCaseOfError) throw activityNotFoundException
|
||||||
toast(errorMessage)
|
toast(errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Google Play on the provided application Id.
|
||||||
|
*/
|
||||||
|
fun Context.openGooglePlay(
|
||||||
|
appId: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
openUrlInExternalApp(
|
||||||
|
url = "market://details?id=$appId",
|
||||||
|
throwInCaseOfError = true,
|
||||||
|
)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Not in KTX anymore
|
// Not in KTX anymore
|
||||||
fun Context.toast(resId: Int) {
|
fun Context.toast(resId: Int) {
|
||||||
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
||||||
|
|
|
||||||
|
|
@ -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.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -54,7 +59,17 @@ fun ModalBottomSheet(
|
||||||
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
|
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
|
||||||
androidx.compose.material3.ModalBottomSheet(
|
androidx.compose.material3.ModalBottomSheet(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
modifier = modifier,
|
modifier = modifier.onKeyEvent { keyEvent ->
|
||||||
|
// It seems that on some devices, we have to handle the Escape key manually to close the bottom sheet.
|
||||||
|
// This is not the case using an emulator, but is necessary on some physical devices.
|
||||||
|
if (keyEvent.type == KeyEventType.KeyUp &&
|
||||||
|
keyEvent.key == Key.Escape) {
|
||||||
|
onDismissRequest()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
sheetState = safeSheetState,
|
sheetState = safeSheetState,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
containerColor = containerColor,
|
containerColor = containerColor,
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,6 @@ import timber.log.Timber
|
||||||
|
|
||||||
interface TracingService {
|
interface TracingService {
|
||||||
fun createTimberTree(target: String): Timber.Tree
|
fun createTimberTree(target: String): Timber.Tree
|
||||||
|
|
||||||
|
fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private var currentClient: Client? = null
|
private var currentClient: Client? = null
|
||||||
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
|
||||||
private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null
|
private val newMatrixClientObservers = mutableListOf<(MatrixClient) -> Unit>()
|
||||||
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
|
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
|
||||||
newMatrixClientObserver = lambda
|
newMatrixClientObservers.add(lambda)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rotateSessionPath(): SessionPaths {
|
private fun rotateSessionPath(): SessionPaths {
|
||||||
|
|
@ -155,7 +155,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
passphrase = pendingPassphrase,
|
passphrase = pendingPassphrase,
|
||||||
sessionPaths = currentSessionPaths,
|
sessionPaths = currentSessionPaths,
|
||||||
)
|
)
|
||||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
val matrixClient = rustMatrixClientFactory.create(client)
|
||||||
|
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
|
||||||
sessionStore.storeData(sessionData)
|
sessionStore.storeData(sessionData)
|
||||||
|
|
||||||
// Clean up the strong reference held here since it's no longer necessary
|
// Clean up the strong reference held here since it's no longer necessary
|
||||||
|
|
@ -246,7 +247,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
pendingOAuthAuthorizationData?.close()
|
pendingOAuthAuthorizationData?.close()
|
||||||
pendingOAuthAuthorizationData = null
|
pendingOAuthAuthorizationData = null
|
||||||
|
|
||||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
val matrixClient = rustMatrixClientFactory.create(client)
|
||||||
|
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
|
||||||
sessionStore.storeData(sessionData)
|
sessionStore.storeData(sessionData)
|
||||||
|
|
||||||
// Clean up the strong reference held here since it's no longer necessary
|
// Clean up the strong reference held here since it's no longer necessary
|
||||||
|
|
@ -290,7 +292,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
passphrase = pendingPassphrase,
|
passphrase = pendingPassphrase,
|
||||||
sessionPaths = emptySessionPaths,
|
sessionPaths = emptySessionPaths,
|
||||||
)
|
)
|
||||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
val matrixClient = rustMatrixClientFactory.create(client)
|
||||||
|
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
|
||||||
sessionStore.storeData(sessionData)
|
sessionStore.storeData(sessionData)
|
||||||
|
|
||||||
// Clean up the strong reference held here since it's no longer necessary
|
// Clean up the strong reference held here since it's no longer necessary
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.matrix.impl.notification
|
package io.element.android.libraries.matrix.impl.notification
|
||||||
|
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -30,31 +31,33 @@ class NotificationMapper(
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
notificationItem: NotificationItem
|
notificationItem: NotificationItem
|
||||||
): NotificationData {
|
): Result<NotificationData> {
|
||||||
return notificationItem.use { item ->
|
return runCatchingExceptions {
|
||||||
val isDm = isDm(
|
notificationItem.use { item ->
|
||||||
isDirect = item.roomInfo.isDirect,
|
val isDm = isDm(
|
||||||
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
|
isDirect = item.roomInfo.isDirect,
|
||||||
)
|
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
|
||||||
NotificationData(
|
)
|
||||||
sessionId = sessionId,
|
NotificationData(
|
||||||
eventId = eventId,
|
sessionId = sessionId,
|
||||||
// FIXME once the `NotificationItem` in the SDK returns the thread id
|
eventId = eventId,
|
||||||
threadId = null,
|
// FIXME once the `NotificationItem` in the SDK returns the thread id
|
||||||
roomId = roomId,
|
threadId = null,
|
||||||
senderAvatarUrl = item.senderInfo.avatarUrl,
|
roomId = roomId,
|
||||||
senderDisplayName = item.senderInfo.displayName,
|
senderAvatarUrl = item.senderInfo.avatarUrl,
|
||||||
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
|
senderDisplayName = item.senderInfo.displayName,
|
||||||
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm },
|
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
|
||||||
roomDisplayName = item.roomInfo.displayName,
|
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm },
|
||||||
isDirect = item.roomInfo.isDirect,
|
roomDisplayName = item.roomInfo.displayName,
|
||||||
isDm = isDm,
|
isDirect = item.roomInfo.isDirect,
|
||||||
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
|
isDm = isDm,
|
||||||
isNoisy = item.isNoisy.orFalse(),
|
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
|
||||||
timestamp = item.timestamp() ?: clock.epochMillis(),
|
isNoisy = item.isNoisy.orFalse(),
|
||||||
content = item.event.use { notificationContentMapper.map(it) },
|
timestamp = item.timestamp() ?: clock.epochMillis(),
|
||||||
hasMention = item.hasMention.orFalse(),
|
content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(),
|
||||||
)
|
hasMention = item.hasMention.orFalse(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,11 +65,13 @@ class NotificationMapper(
|
||||||
class NotificationContentMapper {
|
class NotificationContentMapper {
|
||||||
private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper()
|
private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper()
|
||||||
|
|
||||||
fun map(notificationEvent: NotificationEvent): NotificationContent =
|
fun map(notificationEvent: NotificationEvent): Result<NotificationContent> =
|
||||||
when (notificationEvent) {
|
when (notificationEvent) {
|
||||||
is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event)
|
is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event)
|
||||||
is NotificationEvent.Invite -> NotificationContent.Invite(
|
is NotificationEvent.Invite -> Result.success(
|
||||||
senderId = UserId(notificationEvent.sender),
|
NotificationContent.Invite(
|
||||||
|
senderId = UserId(notificationEvent.sender),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ class RustNotificationService(
|
||||||
is BatchNotificationResult.Ok -> {
|
is BatchNotificationResult.Ok -> {
|
||||||
when (val status = result.status) {
|
when (val status = result.status) {
|
||||||
is NotificationStatus.Event -> {
|
is NotificationStatus.Event -> {
|
||||||
put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item)))
|
val result = notificationMapper.map(sessionId, eventId, roomId, status.item)
|
||||||
|
result.onFailure { Timber.e(it, "Could not map notification event $eventId") }
|
||||||
|
put(eventId, result)
|
||||||
}
|
}
|
||||||
is NotificationStatus.EventNotFound -> {
|
is NotificationStatus.EventNotFound -> {
|
||||||
Timber.e("Could not retrieve event for notification with $eventId - event not found")
|
Timber.e("Could not retrieve event for notification with $eventId - event not found")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.notification
|
package io.element.android.libraries.matrix.impl.notification
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.notification.CallNotifyType
|
import io.element.android.libraries.matrix.api.notification.CallNotifyType
|
||||||
|
|
@ -21,10 +22,12 @@ import org.matrix.rustcomponents.sdk.TimelineEventType
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
|
||||||
class TimelineEventToNotificationContentMapper {
|
class TimelineEventToNotificationContentMapper {
|
||||||
fun map(timelineEvent: TimelineEvent): NotificationContent {
|
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
|
||||||
return timelineEvent.use {
|
return runCatchingExceptions {
|
||||||
timelineEvent.eventType().use { eventType ->
|
timelineEvent.use {
|
||||||
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
|
timelineEvent.eventType().use { eventType ->
|
||||||
|
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
||||||
import io.element.android.libraries.matrix.api.tracing.TracingService
|
import io.element.android.libraries.matrix.api.tracing.TracingService
|
||||||
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
||||||
import org.matrix.rustcomponents.sdk.TracingFileConfiguration
|
import org.matrix.rustcomponents.sdk.TracingFileConfiguration
|
||||||
|
import org.matrix.rustcomponents.sdk.reloadTracingFileWriter
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -23,6 +24,12 @@ class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) :
|
||||||
override fun createTimberTree(target: String): Timber.Tree {
|
override fun createTimberTree(target: String): Timber.Tree {
|
||||||
return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable)
|
return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) {
|
||||||
|
config.toTracingFileConfiguration()?.let {
|
||||||
|
reloadTracingFileWriter(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel {
|
private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
|
||||||
import org.matrix.rustcomponents.sdk.TimelineEvent
|
import org.matrix.rustcomponents.sdk.TimelineEvent
|
||||||
import org.matrix.rustcomponents.sdk.TimelineEventType
|
import org.matrix.rustcomponents.sdk.TimelineEventType
|
||||||
|
|
||||||
class FakeFfiTimelineEvent(
|
open class FakeFfiTimelineEvent(
|
||||||
val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(),
|
val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(),
|
||||||
val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(),
|
val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(),
|
||||||
val senderId: String = A_USER_ID_2.value,
|
val senderId: String = A_USER_ID_2.value,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,12 @@ import io.element.android.libraries.matrix.api.exception.NotificationResolverExc
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
|
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEvent
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
|
@ -26,6 +30,8 @@ import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.matrix.rustcomponents.sdk.NotificationClient
|
import org.matrix.rustcomponents.sdk.NotificationClient
|
||||||
|
import org.matrix.rustcomponents.sdk.NotificationStatus
|
||||||
|
import org.matrix.rustcomponents.sdk.TimelineEventType
|
||||||
|
|
||||||
class RustNotificationServiceTest {
|
class RustNotificationServiceTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -49,6 +55,33 @@ class RustNotificationServiceTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test mapping invalid item only drops that item`() = runTest {
|
||||||
|
val error = IllegalStateException("This event type is not supported")
|
||||||
|
val faultyEvent = object : FakeFfiTimelineEvent() {
|
||||||
|
override fun eventType(): TimelineEventType {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val notificationClient = FakeFfiNotificationClient(
|
||||||
|
notificationItemResult = mapOf(
|
||||||
|
AN_EVENT_ID.value to aRustBatchNotificationResult(
|
||||||
|
notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent)))
|
||||||
|
),
|
||||||
|
AN_EVENT_ID_2.value to aRustBatchNotificationResult()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val sut = createRustNotificationService(
|
||||||
|
notificationClient = notificationClient,
|
||||||
|
)
|
||||||
|
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID, AN_EVENT_ID_2))).getOrThrow()
|
||||||
|
val exception = result[AN_EVENT_ID]!!.exceptionOrNull()
|
||||||
|
assertThat(exception).isEqualTo(error)
|
||||||
|
|
||||||
|
val successfulResult = result[AN_EVENT_ID_2]
|
||||||
|
assertThat(successfulResult?.isSuccess).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test unable to resolve event`() = runTest {
|
fun `test unable to resolve event`() = runTest {
|
||||||
val notificationClient = FakeFfiNotificationClient(
|
val notificationClient = FakeFfiNotificationClient(
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const val A_REDACTION_REASON = "A redaction reason"
|
||||||
const val A_HOMESERVER_URL = "matrix.org"
|
const val A_HOMESERVER_URL = "matrix.org"
|
||||||
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
||||||
|
|
||||||
|
const val AN_ACCOUNT_PROVIDER_URL = "https://account.provider.org"
|
||||||
const val AN_ACCOUNT_PROVIDER = "matrix.org"
|
const val AN_ACCOUNT_PROVIDER = "matrix.org"
|
||||||
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
|
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
|
||||||
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
|
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
|
||||||
|
|
|
||||||
|
|
@ -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.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.core.extensions.withBrackets
|
import io.element.android.libraries.core.extensions.withBrackets
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
|
|
@ -84,6 +85,7 @@ private fun FilenameRow(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.core.extensions.withBrackets
|
import io.element.android.libraries.core.extensions.withBrackets
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
|
|
@ -84,6 +85,7 @@ private fun FilenameRow(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
|
|
@ -44,7 +45,8 @@ fun ImageItemView(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
),
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick),
|
||||||
) {
|
) {
|
||||||
var isLoaded by remember { mutableStateOf(false) }
|
var isLoaded by remember { mutableStateOf(false) }
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
|
@ -54,7 +55,8 @@ fun VideoItemView(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
),
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick),
|
||||||
) {
|
) {
|
||||||
var isLoaded by remember { mutableStateOf(false) }
|
var isLoaded by remember { mutableStateOf(false) }
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
|
@ -105,6 +106,7 @@ private fun VoiceInfoRow(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
|
.onKeyboardContextMenuAction(onLongClick)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ private const val versionYear = 25
|
||||||
private const val versionMonth = 8
|
private const val versionMonth = 8
|
||||||
|
|
||||||
// Note: must be in [0,99]
|
// Note: must be in [0,99]
|
||||||
private const val versionReleaseNumber = 1
|
private const val versionReleaseNumber = 2
|
||||||
|
|
||||||
object Versions {
|
object Versions {
|
||||||
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber
|
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:eb923fffd20dc775f31b998e23c39acf79013a831a7c292c4cdba5b9142b4094
|
oid sha256:6fd52151e94328a68ef5b65bdc0e5e0d730bd617ed2b58ad92c350fb409c75d2
|
||||||
size 68227
|
size 63096
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a9066615bfa2f78b77784302ce0272e3a486c79e267629c50c35ec1221c58046
|
oid sha256:a67035a18b067b07f52090ee0ca9e3defa34a158e7dcefc55062131ac1599823
|
||||||
size 64443
|
size 59193
|
||||||
|
|
|
||||||
|
|
@ -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