Merge branch 'develop' into fix/1270-verification-request-timeout

This commit is contained in:
Benoit Marty
2026-08-25 13:30:57 +02:00
committed by GitHub
1054 changed files with 9298 additions and 5468 deletions
-1
View File
@@ -7,7 +7,6 @@
"PR-Dependencies",
],
"ignoreDeps": [
"string:app_name",
"gradle",
],
"minimumReleaseAge": "7 days",
-3
View File
@@ -31,9 +31,6 @@ __pycache__/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
+1 -1
View File
@@ -1,6 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="© &amp;#36;today.year Element Creations Ltd.&#10;&#10;Element Creations Ltd, Element Software SARL, Element Software Inc.,&#10;and Element Software GmbH (the &quot;Element Group&quot;) only make this file available&#10;under a proprietary license model.&#10;&#10;Without a proprietary license with us, you cannot use this file. The terms of&#10;the proprietary license agreement between you and any member of the Element Group&#10;shall always apply to your use of this file. Unauthorised use, copying, distribution,&#10;or modification of this file, via any medium, is strictly prohibited.&#10;&#10;For details about the licensing terms, you must either visit our website or contact&#10;a member of our sales team." />
<option name="notice" value="© &amp;#36;today.year Element Creations Ltd.&#10;&#10;SPDX-License-Identifier: LicenseRef-Element-Commercial&#10;SPDX-License-Identifier: LicenseRef-Element-Proprietary" />
<option name="myName" value="Element Enterprise" />
</copyright>
</component>
-21
View File
@@ -1,21 +0,0 @@
appId: ${MAESTRO_APP_ID}
---
- tapOn:
id: "login-change_server"
- takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "matrix.org"
- tapOn:
id: "login-change_server"
- tapOn: "Other"
- tapOn:
id: "change_server-server"
- inputText: "element"
- hideKeyboard
- extendedWaitUntil:
visible: "element.io"
timeout: 10000
- tapOn: "element.io"
# Revert to matrix.org
- tapOn:
id: "login-change_server"
- tapOn: "matrix.org"
+1 -2
View File
@@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml
# The account provider field defaults to matrix.org, so continue straight to the MAS login.
- tapOn:
id: "login-continue"
## MAS page
@@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Change account provider"
visible: "Enter your account provider"
timeout: 10000
+3 -2
View File
@@ -37,8 +37,9 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states.
- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states.
- Wrap previews in `ElementPreview { ... }`.
- When writing tests, never try to record the screenshots, the CI will do it.
---
@@ -88,7 +89,7 @@ We use [Appyx](https://bumble-tech.github.io/appyx/) for navigation and [Molecul
| `FooView.kt` | Stateless Composable rendering the UI from `FooState`. |
| `FooState.kt` | Data class representing the immutable UI state. |
| `FooEvent.kt` | Sealed interface for UI actions sent to the Presenter. |
| `FooStateProvider.kt` | Provides sample states for Previews and Screenshot tests. |
| `FooStatePreviewParam.kt` | Provides sample states for Previews and Screenshot tests. |
| `FooPresenterTest.kt` | Unit tests for the Presenter logic using Turbine. |
---
+83
View File
@@ -1,3 +1,86 @@
Changes in Element X v26.08.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.2 -->
**Note: this release in an internal release, it will not be pushed to the stores.**
## What's Changed
### ✨ Features
* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412
### 🙌 Improvements
* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416
* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448
* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429
* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438
* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440
* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403
* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394
### 🐛 Bugfixes
* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413
* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395
* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433
* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452
* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472
* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477
* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454
* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456
* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436
* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446
### 🧱 Build
* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407
* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418
* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473
* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478
### 📄 Documentation
* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426
### 🚧 In development 🚧
* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428
### Dependency upgrades
* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396
* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415
* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421
* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423
* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420
* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431
* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479
* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427
* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483
### 💼 Element Pro
* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419
* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469
### Others
* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379
* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343
* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355
* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345
* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370
* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354
* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353
* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349
* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347
* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417
* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424
* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430
* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461
* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455
* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451
* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445
* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442
* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441
* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439
* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437
* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450
* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466
## New Contributors
* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2
Changes in Element X v26.08.1
=============================
@@ -14,16 +14,16 @@ import kotlin.reflect.KClass
* Adds Node to the specified component graph.
* Equivalent to the following declaration:
*
* ```
* @BindingContainer
* @ContributesTo(Scope::class)
* abstract class YourNodeModule {
* @Binds
* @IntoMap
* @NodeKey(YourNode::class)
* abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
*}
* interface YourNodeBindingContainer {
* @Binds
* @IntoMap
* @NodeKey(YourNode::class)
* fun bindYourNodeAssistedFactory(factory: YourNodeAssistedFactory): AssistedNodeFactory<*>
* }
* ```
*/
@Target(AnnotationTarget.CLASS)
annotation class ContributesNode(
+28 -18
View File
@@ -127,24 +127,11 @@ android {
optimization {
enable = true
keepRules {
files.add(File(projectDir, "common-proguard-rules.pro"))
files.add(getDefaultProguardFile("proguard-android-optimize.txt"))
// Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
val flavorProguardFile = if (isEnterpriseBuild) {
// Custom rules for enterprise builds
File(projectDir, "enterprise-proguard-rules.pro")
} else {
// These default rules prevent the OSS app from being obfuscated
File(projectDir, "default-proguard-rules.pro")
}
if (flavorProguardFile.exists()) {
files.add(flavorProguardFile)
} else {
logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist")
}
// Equivalent of adding `getDefaultProguardFile("proguard-android-optimize.txt")` (this is the default value).
includeDefault = true
}
// Our custom keep rules are registered as `keepRules` source folders in the `androidComponents` block below,
// as the former `keepRules.files` DSL is deprecated since AGP 9.
}
}
@@ -229,6 +216,30 @@ androidComponents {
)
onVariants { variant ->
// Register the R8 keep rules source folders for optimized build types (release, nightly).
// Replaces the deprecated `optimization.keepRules.files` DSL (AGP 9+).
if (variant.buildType != "debug") {
variant.sources.keepRules?.let { keepRules ->
// Common rules, always applied.
keepRules.addStaticSourceDirectory("proguard/common")
// Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
val flavorProguardDir = if (isEnterpriseBuild) {
// Custom rules for enterprise builds
"../enterprise/proguard"
} else {
// Custom fules for FOSS builds
"proguard/foss"
}
if (File(projectDir, flavorProguardDir).exists()) {
keepRules.addStaticSourceDirectory(flavorProguardDir)
} else {
logger.warn("Proguard folder ${File(projectDir, flavorProguardDir).absolutePath} does not exist")
}
}
}
// Assigns a different version code for each output APK
// other than the universal APK.
variant.outputs.forEach { output ->
@@ -259,7 +270,6 @@ dependencies {
if (isEnterpriseBuild) {
allEnterpriseImpl(project)
implementation(projects.appicon.enterprise)
implementation(project(":enterprise:features:enterprise:shared"))
} else {
implementation(projects.features.enterprise.implFoss)
implementation(projects.appicon.element)
@@ -38,7 +38,7 @@ import java.io.File
@BindingContainer
@ContributesTo(AppScope::class)
object AppModule {
object AppBindingContainer {
@Provides
@BaseDirectory
fun providesBaseDirectory(@ApplicationContext context: Context): File {
@@ -111,7 +111,7 @@ object AppModule {
@Provides
@SingleIn(AppScope::class)
fun provideSnackbarDispatcher(): SnackbarDispatcher {
fun providesSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
}
@@ -1,16 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.appconfig
object PushConfig {
/**
* Note: pusher_app_id cannot exceed 64 chars.
*/
const val PUSHER_APP_ID: String = "im.vector.app.android"
}
+6
View File
@@ -61,17 +61,23 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.accountselect.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.features.announcement.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.preferences.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.signedout.test)
testImplementation(projects.services.apperror.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
@@ -335,11 +335,11 @@ class LoggedInFlowNode(
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) {
lifecycleScope.launch {
attachRoom(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom, eventId = eventId),
clearBackstack = false,
)
}
@@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.room.RoomFlowNode
@@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -109,6 +109,13 @@ class RootFlowNode(
buildContext = buildContext,
plugins = plugins
) {
/**
* Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow.
* Kept here so that the root nav target can be computed from both the logged in state and the pending
* login params, whatever the order in which the intent and the first nav state emission are processed.
*/
private var pendingLoginParams: LoginParams? = null
override fun onBuilt() {
analyticsColdStartWatcher.start()
appCoroutineScope.launch {
@@ -152,7 +159,7 @@ class RootFlowNode(
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
switchToNotLoggedInFlow(pendingLoginParams)
}
}
}
@@ -194,15 +201,18 @@ class RootFlowNode(
}
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow(params: LoginParams?) {
Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}")
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
@@ -319,6 +329,7 @@ class RootFlowNode(
}
override fun onDone() {
pendingLoginParams = null
backstack.pop()
}
}
@@ -418,6 +429,7 @@ class RootFlowNode(
Timber.w("Login link ignored, multi account is disabled")
}
} else {
pendingLoginParams = params
switchToNotLoggedInFlow(params)
}
} else {
@@ -76,6 +76,7 @@ class IntentResolver(
}
private fun Intent.canBeIgnored(): Boolean {
if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true
return action == Intent.ACTION_MAIN &&
categories?.contains(Intent.CATEGORY_LAUNCHER) == true
}
@@ -8,11 +8,11 @@
package io.element.android.appnav.loggedin
sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
data object DismissLocalNetworkPermissionPrompt : LoggedInEvents
sealed interface LoggedInEvent {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvent
data object CheckSlidingSyncProxyAvailability : LoggedInEvent
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvent
data object DismissLocalNetworkPermissionPrompt : LoggedInEvent
data object RequestLocationNetworkPermission : LoggedInEvents
data object RequestLocationNetworkPermission : LoggedInEvent
}
@@ -30,8 +30,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -67,7 +65,6 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -124,13 +121,9 @@ class LoggedInPresenter(
}
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
// Keep automatic call status (m.call) in sync with homeserver support.
val enabled = matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState()
@@ -156,9 +149,9 @@ class LoggedInPresenter(
else -> LocalNetworkPermissionDialog.Settings
}
fun handleEvent(event: LoggedInEvents) {
fun handleEvent(event: LoggedInEvent) {
when (event) {
is LoggedInEvents.CloseErrorDialog -> {
is LoggedInEvent.CloseErrorDialog -> {
pusherRegistrationState.value = AsyncData.Uninitialized
if (event.doNotShowAgain) {
coroutineScope.launch {
@@ -166,17 +159,17 @@ class LoggedInPresenter(
}
}
}
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
LoggedInEvent.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
}
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
LoggedInEvent.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
LoggedInEvents.DismissLocalNetworkPermissionPrompt -> {
LoggedInEvent.DismissLocalNetworkPermissionPrompt -> {
localNetworkPromptDismissedThisSession = true
}
LoggedInEvents.RequestLocationNetworkPermission -> {
LoggedInEvent.RequestLocationNetworkPermission -> {
if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) {
localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
} else {
@@ -18,5 +18,5 @@ data class LoggedInState(
val forceNativeSlidingSyncMigration: Boolean,
val appName: String,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoggedInEvents) -> Unit,
val eventSink: (LoggedInEvent) -> Unit,
)
@@ -13,7 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PusherRegistrationFailure
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
@@ -37,7 +37,7 @@ fun LoggedInView(
) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
state.eventSink(LoggedInEvent.CheckSlidingSyncProxyAvailability)
}
}
Box(
@@ -63,10 +63,10 @@ fun LoggedInView(
content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason),
cancelText = stringResource(id = CommonStrings.common_settings),
onDismiss = {
state.eventSink(LoggedInEvents.CloseErrorDialog(it))
state.eventSink(LoggedInEvent.CloseErrorDialog(it))
},
onCancel = {
state.eventSink(LoggedInEvents.CloseErrorDialog(false))
state.eventSink(LoggedInEvent.CloseErrorDialog(false))
navigateToNotificationTroubleshoot()
}
)
@@ -79,15 +79,15 @@ fun LoggedInView(
ForceNativeSlidingSyncMigrationDialog(
appName = state.appName,
onSubmit = {
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
state.eventSink(LoggedInEvent.LogoutAndMigrateToNativeSlidingSync)
}
)
}
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = { state.eventSink(LoggedInEvents.RequestLocationNetworkPermission) },
onDismiss = { state.eventSink(LoggedInEvents.DismissLocalNetworkPermissionPrompt) },
onSubmit = { state.eventSink(LoggedInEvent.RequestLocationNetworkPermission) },
onDismiss = { state.eventSink(LoggedInEvent.DismissLocalNetworkPermissionPrompt) },
)
}
@@ -124,7 +124,7 @@ private fun ForceNativeSlidingSyncMigrationDialog(
@PreviewsDayNight
@Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStatePreviewParam::class) state: LoggedInState) = ElementPreview {
LoggedInView(
state = state,
navigateToNotificationTroubleshoot = {},
@@ -29,7 +29,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
import io.element.android.libraries.matrix.ui.room.LoadingRoomStatePreviewParam
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -84,7 +84,7 @@ private fun LoadingRoomTopBar(
@PreviewsDayNight
@Composable
internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview {
internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStatePreviewParam::class) state: LoadingRoomState) = ElementPreview {
LoadingRoomNodeView(
state = state,
onBackClick = {}
@@ -11,7 +11,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
@@ -14,7 +14,7 @@ import io.element.android.features.rageshake.api.detection.aRageshakeDetectionSt
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
open class RootStateProvider : PreviewParameterProvider<RootState> {
open class RootStatePreviewParam : PreviewParameterProvider<RootState> {
override val values: Sequence<RootState>
get() = sequenceOf(
aRootState().copy(
@@ -60,7 +60,7 @@ fun RootView(
@PreviewsDayNight
@Composable
internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
internal fun RootViewPreview(@PreviewParameter(RootStatePreviewParam::class) rootState: RootState) = ElementPreview {
RootView(
state = rootState,
onOpenBugReport = {},
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.di
package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap
@@ -115,7 +115,7 @@ class MatrixSessionCache(
private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(
syncService = matrixClient.syncService,
matrixClient = matrixClient,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.di
package io.element.android.appnav.session
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
@@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
@@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class SyncOrchestrator(
@Assisted private val syncService: SyncService,
@Assisted private val matrixClient: MatrixClient,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
@@ -47,13 +47,15 @@ class SyncOrchestrator(
@AssistedFactory
interface Factory {
fun create(
syncService: SyncService,
matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
}
private val tag = "SyncOrchestrator"
private val syncService = matrixClient.syncService
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
@@ -94,7 +96,7 @@ class SyncOrchestrator(
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
appForegroundStateService.isSharingLiveLocation
appForegroundStateService.isSharingLiveLocation,
)
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
@@ -103,6 +105,11 @@ class SyncOrchestrator(
networkMonitor.connectivity,
isAppActiveFlow,
) { syncState, networkState, isAppActive ->
if (matrixClient.isShuttingDown) {
Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again")
return@combine SyncStateAction.NoOp
}
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.appnav
import android.content.Intent
import android.net.Uri
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint
import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
import io.element.android.libraries.architecture.AssistedNodeFactory
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com"
private val A_LOGIN_PARAMS = LoginParams(
accountProvider = "example.com",
loginHint = "mxid:@alice:example.com",
)
private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params(
accountProvider = A_LOGIN_PARAMS.accountProvider,
loginHint = A_LOGIN_PARAMS.loginHint,
)
class RootFlowNodeTest : RobolectricTest() {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest {
var loginEntryPointParams: LoginEntryPoint.Params? = null
val rootFlowNode = createRootFlowNode(
loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
loginEntryPointParams = params
node(buildContext) {}
},
)
rootFlowNode.parentNodeTestHelper()
// The intent is handled first, this is what happens when the deep link cold starts the app.
rootFlowNode.handleIntent(aLoginIntent())
assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
// Then the first nav state emission lands, it must not override the login params.
runCurrent()
assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
}
@Test
fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest {
var loginEntryPointParams: LoginEntryPoint.Params? = null
val rootFlowNode = createRootFlowNode(
loginEntryPoint = FakeLoginEntryPoint { buildContext, params ->
loginEntryPointParams = params
node(buildContext) {}
},
)
rootFlowNode.parentNodeTestHelper()
// The nav state is observed first, this is what happens when the app is already running.
runCurrent()
assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null))
rootFlowNode.handleIntent(aLoginIntent())
runCurrent()
assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS))
assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS)
}
private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK))
private fun TestScope.createRootFlowNode(
loginEntryPoint: LoginEntryPoint,
sessionStore: SessionStore = InMemorySessionStore(),
): RootFlowNode {
val matrixSessionCache = MatrixSessionCache(
authenticationService = FakeMatrixAuthenticationService(),
syncOrchestratorFactory = FakeSyncOrchestratorFactory(),
analyticsService = FakeAnalyticsService(),
)
val parentNode = FakeParentNode(
graph = FakeNodeFactoriesBindings(
mapOf(
NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins ->
NotLoggedInFlowNode(
buildContext = buildContext,
plugins = plugins,
loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
)
}
)
)
)
return RootFlowNode(
buildContext = BuildContext(
ancestryInfo = AncestryInfo.Child(anchor = parentNode),
savedStateMap = null,
customisations = NodeCustomisationDirectoryImpl(),
),
plugins = emptyList(),
sessionStore = sessionStore,
accountProviderAccessControl = FakeAccountProviderAccessControl { true },
navStateFlowFactory = RootNavStateFlowFactory(
sessionStore = sessionStore,
cacheService = FakeCacheService(),
matrixSessionCache = matrixSessionCache,
imageLoaderHolder = FakeImageLoaderHolder(),
sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(),
),
matrixSessionCache = matrixSessionCache,
presenter = RootPresenter(
crashDetectionPresenter = NotUsedPresenter(),
rageshakeDetectionPresenter = NotUsedPresenter(),
appErrorStateService = FakeAppErrorStateService(),
analyticsService = FakeAnalyticsService(),
sdkMetadata = FakeSdkMetadata("sha"),
),
bugReportEntryPoint = FakeBugReportEntryPoint(),
signedOutEntryPoint = FakeSignedOutEntryPoint(),
accountSelectEntryPoint = FakeAccountSelectEntryPoint(),
intentResolver = IntentResolver(
deeplinkParser = { null },
loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS },
oAuthIntentResolver = FakeOAuthIntentResolver { null },
permalinkParser = FakePermalinkParser(),
shareIntentHandler = FakeShareIntentHandler(),
),
oAuthActionFlow = FakeOAuthActionFlow(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
analyticsService = FakeAnalyticsService(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
appCoroutineScope = backgroundScope,
)
}
}
@@ -8,10 +8,11 @@
package io.element.android.appnav
import io.element.android.appnav.di.SyncOrchestrator
import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@@ -40,7 +41,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -60,7 +61,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -85,7 +86,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -117,7 +118,7 @@ class SyncOrchestratorTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -165,7 +166,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -208,7 +209,7 @@ class SyncOrchestratorTest {
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -252,7 +253,7 @@ class SyncOrchestratorTest {
initialHasRingingCall = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -296,7 +297,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -339,7 +340,7 @@ class SyncOrchestratorTest {
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
@@ -369,7 +370,7 @@ class SyncOrchestratorTest {
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
)
@@ -381,12 +382,44 @@ class SyncOrchestratorTest {
startSyncRecorder.assertions().isNeverCalled()
}
@Test
fun `when the client is shutting down, the orchestrator does nothing`() = runTest {
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
initialIsInCallValue = false,
)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val syncOrchestrator = createSyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// These should still not trigger a sync, sync the client is shutting down
networkMonitor.givenNetworkBlocked(false)
appForegroundStateService.updateIsInCallState(true)
appForegroundStateService.isInForeground.value = true
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isNeverCalled()
}
private fun TestScope.createSyncOrchestrator(
syncService: FakeSyncService = FakeSyncService(),
matrixClient: FakeMatrixClient = FakeMatrixClient(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
syncService = syncService,
matrixClient = matrixClient,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
@@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() {
assertThat(result).isNull()
}
@Test
fun `resolve intent replayed from the history should return null`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test resolve navigation intent root`() {
val sut = createIntentResolver(
@@ -17,9 +17,6 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -131,7 +128,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -214,7 +210,7 @@ class LoggedInPresenterTest {
lambda.assertions()
.isCalledOnce()
// Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false))
finalState.eventSink(LoggedInEvent.CloseErrorDialog(doNotShowAgain = false))
val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(lastState.ignoreRegistrationError).isFalse()
@@ -244,7 +240,7 @@ class LoggedInPresenterTest {
lambda.assertions()
.isCalledOnce()
// Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true))
finalState.eventSink(LoggedInEvent.CloseErrorDialog(doNotShowAgain = true))
skipItems(1)
setIgnoreRegistrationErrorLambda.assertions()
.isCalledOnce()
@@ -300,7 +296,7 @@ class LoggedInPresenterTest {
).test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
initialState.eventSink(LoggedInEvent.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
}
}
@@ -322,7 +318,7 @@ class LoggedInPresenterTest {
).test {
val initialState = awaitItem()
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
initialState.eventSink(LoggedInEvent.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle()
@@ -362,9 +358,6 @@ class LoggedInPresenterTest {
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
@@ -374,7 +367,7 @@ class LoggedInPresenterTest {
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
fun `present - enables automatic call status when server supports it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
@@ -382,23 +375,13 @@ class LoggedInPresenterTest {
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
.isCalledOnce()
.with(value(true))
}
}
@@ -418,7 +401,6 @@ class LoggedInPresenterTest {
),
buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@@ -430,7 +412,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations 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.appnav.session
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.CoroutineScope
class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory {
override fun create(
matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator = lambdaError()
}
@@ -6,12 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.di
package io.element.android.appnav.session
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
@@ -138,11 +138,11 @@ class MatrixSessionCacheTest {
return object : SyncOrchestrator.Factory {
override fun create(
syncService: SyncService,
matrixClient: MatrixClient,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator(
syncService = syncService,
matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
+1 -1
View File
@@ -98,7 +98,7 @@ allprojects {
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
allWarningsAsErrors = findProperty("allWarningsAsErrors") == "true"
// Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
@@ -61,32 +61,32 @@ class ContributesNodeProcessor(
if (config.enableLogging) {
logger.warn("Processing ${ksClass.qualifiedName?.asString()}")
}
generateModule(ksClass)
generateBindingContainer(ksClass)
generateFactory(ksClass)
}
return invalidSymbols
}
private fun generateModule(ksClass: KSClassDeclaration) {
private fun generateBindingContainer(ksClass: KSClassDeclaration) {
val annotation = ksClass.annotations.find { it.shortName.asString() == "ContributesNode" }!!
val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType
val modulePackage = ksClass.packageName.asString()
val moduleClassName = "${ksClass.simpleName.asString()}_Module"
val bindingContainerPackage = ksClass.packageName.asString()
val bindingContainerClassName = "${ksClass.simpleName.asString()}BindingContainer"
val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString())
val content = FileSpec.builder(
packageName = modulePackage,
fileName = moduleClassName,
packageName = bindingContainerPackage,
fileName = bindingContainerClassName,
)
.addType(
TypeSpec.interfaceBuilder(moduleClassName)
TypeSpec.interfaceBuilder(bindingContainerClassName)
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember(CLASS_PLACEHOLDER, nodeClassName).build())
.addAnnotation(BindingContainer::class)
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember(CLASS_PLACEHOLDER, scope.toTypeName()).build())
.addFunction(
FunSpec.builder("bind${ksClass.simpleName.asString()}Factory")
FunSpec.builder("bind${ksClass.simpleName.asString()}AssistedFactory")
.addModifiers(KModifier.ABSTRACT)
.addParameter("factory", ClassName(modulePackage, "${ksClass.simpleName.asString()}_AssistedFactory"))
.addParameter("factory", ClassName(bindingContainerPackage, "${ksClass.simpleName.asString()}AssistedFactory"))
.returns(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(STAR))
.addAnnotation(Binds::class)
.addAnnotation(IntoMap::class)
@@ -114,7 +114,7 @@ class ContributesNodeProcessor(
@OptIn(KspExperimental::class)
private fun generateFactory(ksClass: KSClassDeclaration) {
val generatedPackage = ksClass.packageName.asString()
val assistedFactoryClassName = "${ksClass.simpleName.asString()}_AssistedFactory"
val assistedFactoryClassName = "${ksClass.simpleName.asString()}AssistedFactory"
val constructor = ksClass.getConstructors().first { it.parameters.isNotEmpty() }
val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) }
if (assistedParameters.size != 2) {
+4 -4
View File
@@ -322,10 +322,10 @@ Example for a new feature called RoomDetails:
4. Verify that the structure looks ok and click on Finish
5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
7. You can now add more Presentation classes (Event, State, StatePreviewParam, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`<BaseName>StatePreviewParam.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`PreferencesRoot` in the `features/preferences/impl/root` package.
@@ -360,9 +360,9 @@ We have 3 tests frameworks in place, and this should be sufficient to guarantee
- Maestro to test the global usage of the application. See the related [documentation](../.maestro/README.md).
- Combination of [Showkase](https://github.com/airbnb/Showkase) and [Paparazzi](https://github.com/cashapp/paparazzi), to test UI pixel perfect. To add test,
just add `@Preview` for the composable you are adding. See the related [documentation](screenshot_testing.md) and see in the template the
file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide
file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewParam classes to provide
different states. See for instance the
file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt)
file [TemplateStatePreviewParam.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStatePreviewParam.kt)
- Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt).
**Note** For now we want to avoid using class mocking (with library such as *mockk*), because this should be not necessary. We prefer to create Fake
-2
View File
@@ -6,8 +6,6 @@ Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
Server list: https://github.com/element-hq/oidc-playground
Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName,
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -8,6 +8,6 @@
package io.element.android.features.analytics.api
sealed interface AnalyticsOptInEvents {
data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvents
sealed interface AnalyticsOptInEvent {
data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvent
}
@@ -8,11 +8,11 @@
package io.element.android.features.analytics.api.preferences
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
data class AnalyticsPreferencesState(
val applicationName: String,
val isEnabled: Boolean,
val policyUrl: String,
val eventSink: (AnalyticsOptInEvents) -> Unit,
val eventSink: (AnalyticsOptInEvent) -> Unit,
)
@@ -10,7 +10,7 @@ package io.element.android.features.analytics.api.preferences
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<AnalyticsPreferencesState> {
open class AnalyticsPreferencesStatePreviewParam : PreviewParameterProvider<AnalyticsPreferencesState> {
override val values: Sequence<AnalyticsPreferencesState>
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
@@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.features.analytics.api.R
import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -30,7 +30,7 @@ fun AnalyticsPreferencesView(
modifier: Modifier = Modifier,
) {
fun onEnabledChanged(isEnabled: Boolean) {
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
state.eventSink(AnalyticsOptInEvent.EnableAnalytics(isEnabled = isEnabled))
}
val supportingText = stringResource(
@@ -66,7 +66,7 @@ fun AnalyticsPreferencesView(
@PreviewsDayNight
@Composable
internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStatePreviewParam::class) state: AnalyticsPreferencesState) =
ElementPreview {
AnalyticsPreferencesView(
state = state,
@@ -12,7 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.services.analytics.api.AnalyticsService
@@ -28,9 +28,9 @@ class AnalyticsOptInPresenter(
override fun present(): AnalyticsOptInState {
val localCoroutineScope = rememberCoroutineScope()
fun handleEvent(event: AnalyticsOptInEvents) {
fun handleEvent(event: AnalyticsOptInEvent) {
when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
is AnalyticsOptInEvent.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
}
localCoroutineScope.launch {
analyticsService.setDidAskUserConsent()
@@ -8,10 +8,10 @@
package io.element.android.features.analytics.impl
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
data class AnalyticsOptInState(
val applicationName: String,
val hasPolicyLink: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit
val eventSink: (AnalyticsOptInEvent) -> Unit
)
@@ -10,7 +10,7 @@ package io.element.android.features.analytics.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AnalyticsOptInStateProvider : PreviewParameterProvider<AnalyticsOptInState> {
open class AnalyticsOptInStatePreviewParam : PreviewParameterProvider<AnalyticsOptInState> {
override val values: Sequence<AnalyticsOptInState>
get() = sequenceOf(
aAnalyticsOptInState(),
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -55,11 +55,11 @@ fun AnalyticsOptInView(
val eventSink = state.eventSink
fun onAcceptTerms() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
eventSink(AnalyticsOptInEvent.EnableAnalytics(true))
}
fun onDeclineTerms() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
eventSink(AnalyticsOptInEvent.EnableAnalytics(false))
}
BackHandler(onBack = ::onDeclineTerms)
@@ -171,7 +171,7 @@ private fun AnalyticsOptInFooter(
@PreviewsDayNight
@Composable
internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreview {
internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStatePreviewParam::class) state: AnalyticsOptInState) = ElementPreview {
AnalyticsOptInView(
state = state,
onClickTerms = {},
@@ -18,7 +18,7 @@ import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
interface AnalyticsModule {
interface AnalyticsBindingContainer {
@Binds
fun bindAnalyticsPreferencesPresenter(presenter: AnalyticsPreferencesPresenter): Presenter<AnalyticsPreferencesState>
}
@@ -13,7 +13,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -31,9 +31,9 @@ class AnalyticsPreferencesPresenter(
val localCoroutineScope = rememberCoroutineScope()
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
fun handleEvent(event: AnalyticsOptInEvents) {
fun handleEvent(event: AnalyticsOptInEvent) {
when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
is AnalyticsOptInEvent.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
}
}
@@ -12,7 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
@@ -37,7 +37,7 @@ class AnalyticsOptInPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(true))
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.userConsentFlow.first()).isTrue()
}
@@ -55,7 +55,7 @@ class AnalyticsOptInPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(false))
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.userConsentFlow.first()).isFalse()
}
@@ -13,7 +13,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
@@ -67,9 +67,9 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(false))
assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(true))
assertThat(awaitItem().isEnabled).isTrue()
}
}
@@ -11,7 +11,7 @@ package io.element.android.features.announcement.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.announcement.api.Announcement
open class AnnouncementStateProvider : PreviewParameterProvider<AnnouncementState> {
open class AnnouncementStatePreviewParam : PreviewParameterProvider<AnnouncementState> {
override val values: Sequence<AnnouncementState>
get() = sequenceOf(
anAnnouncementState(),
@@ -33,7 +33,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent
import io.element.android.features.announcement.impl.AnnouncementState
import io.element.android.features.announcement.impl.AnnouncementStateProvider
import io.element.android.features.announcement.impl.AnnouncementStatePreviewParam
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -216,7 +216,7 @@ private fun Announcement.Fullscreen.notice(): String? = when (this) {
@PreviewsDayNight
@Composable
internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStateProvider::class) state: AnnouncementState) = ElementPreview {
internal fun FullscreenAnnouncementViewPreview(@PreviewParameter(AnnouncementStatePreviewParam::class) state: AnnouncementState) = ElementPreview {
FullscreenAnnouncementView(
state = state,
)
@@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvent {
data object OnCallStarted : PictureInPictureEvent
data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent
}
@@ -38,6 +38,9 @@ class PictureInPicturePresenter(
fun handleEvent(event: PictureInPictureEvent) {
when (event) {
is PictureInPictureEvent.OnCallStarted -> {
pipController?.setupPipCallbacks()
}
is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController
}
@@ -55,6 +58,12 @@ class PictureInPicturePresenter(
pipController?.exitPip()
}
}
is PictureInPictureEvent.SetPipOrientation -> {
Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}")
if (isPipSupported) {
pipView?.setPipOrientation(event.orientation)
}
}
}
}
@@ -8,8 +8,15 @@
package io.element.android.features.call.impl.pip
import android.os.Build
import androidx.annotation.RequiresApi
interface PipView {
@RequiresApi(Build.VERSION_CODES.O)
fun setPipOrientation(orientation: Int?)
@RequiresApi(Build.VERSION_CODES.O)
fun setPipParams()
@RequiresApi(Build.VERSION_CODES.O)
fun enterPipMode(): Boolean
fun hangUp()
}
@@ -16,7 +16,7 @@ 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.UserId
open class CallNotificationDataProvider : PreviewParameterProvider<CallNotificationData> {
open class CallNotificationDataPreviewParam : PreviewParameterProvider<CallNotificationData> {
override val values: Sequence<CallNotificationData>
get() = sequenceOf(
aCallNotificationData(
@@ -11,7 +11,7 @@ package io.element.android.features.call.impl.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
open class CallScreenStatePreviewParam : PreviewParameterProvider<CallScreenState> {
override val values: Sequence<CallScreenState>
get() = sequenceOf(
aCallScreenState(),
@@ -118,6 +118,7 @@ internal fun CallScreenView(
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
pipState.eventSink(PictureInPictureEvent.OnCallStarted)
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
@@ -130,7 +131,12 @@ internal fun CallScreenView(
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
val pipController = WebViewPipController(
webView = webView,
updatePipOrientation = { orientation ->
pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation))
}
)
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
@@ -260,7 +266,7 @@ private fun WebView.dispatchEscKeyEvent() {
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
@PreviewParameter(CallScreenStateProvider::class) state: CallScreenState,
@PreviewParameter(CallScreenStatePreviewParam::class) state: CallScreenState,
) = ElementPreview {
CallScreenView(
state = state,
@@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui
import android.Manifest
import android.app.PictureInPictureParams
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.compose.setContent
@@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.audio.api.AudioFocus
@@ -86,6 +87,8 @@ class ElementCallActivity :
private var eventSink: ((CallScreenEvent) -> Unit)? = null
private var currentPipOrientation: Int? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -199,6 +202,9 @@ class ElementCallActivity :
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setPipParams()
}
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
@@ -296,11 +302,18 @@ class ElementCallActivity :
}
}
@RequiresApi(Build.VERSION_CODES.O)
override fun setPipOrientation(orientation: Int?) {
currentPipOrientation = orientation
setPictureInPictureParams(getPictureInPictureParams())
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getPictureInPictureParams(): PictureInPictureParams {
// Portrait for calls seems more appropriate as a fallback value
val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT
return PictureInPictureParams.Builder()
// Portrait for calls seems more appropriate
.setAspectRatio(Rational(3, 5))
.setAspectRatioFromOrientation(orientation)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
@@ -161,7 +161,7 @@ private fun ActionButton(
@PreviewsDayNight
@Composable
internal fun IncomingCallScreenPreview(
@PreviewParameter(CallNotificationDataProvider::class) state: CallNotificationData,
@PreviewParameter(CallNotificationDataPreviewParam::class) state: CallNotificationData,
) = ElementPreview {
IncomingCallScreen(
notificationData = state,
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.utils
interface PipController {
fun setupPipCallbacks()
suspend fun canEnterPip(): Boolean
fun enterPip()
fun exitPip()
@@ -250,7 +250,7 @@ class WebViewAudioManager(
hasRegisteredCallbacks = true
}
}
},
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
@@ -8,7 +8,10 @@
package io.element.android.features.call.impl.utils
import android.content.res.Configuration
import android.webkit.JavascriptInterface
import android.webkit.WebView
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine
*/
class WebViewPipController(
private val webView: WebView,
updatePipOrientation: (orientation: Int?) -> Unit,
) : PipController {
init {
// Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it
webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge")
}
override fun setupPipCallbacks() {
Timber.d("Adding callback in controls.onPipMediaOrientationUpdate")
webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null)
}
override suspend fun canEnterPip(): Boolean {
return suspendCoroutine { continuation ->
webView.evaluateJavascript("controls.canEnterPip()") { result ->
@@ -36,3 +50,19 @@ class WebViewPipController(
webView.evaluateJavascript("controls.disablePip()", null)
}
}
private class PipBridge(
private val onOrientationChange: (orientation: Int?) -> Unit,
) {
@JavascriptInterface
fun setPipOrientation(orientation: String?) {
Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation")
// This callback is used to update the picture-in-picture orientation in the WebView when it changes
val orientation = when (orientation) {
"portrait" -> Configuration.ORIENTATION_PORTRAIT
"landscape" -> Configuration.ORIENTATION_LANDSCAPE
else -> return
}
onOrientationChange(orientation)
}
}
@@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController
import io.element.android.tests.testutils.lambda.lambdaError
class FakePipController(
private val setupPipCallbacksResult: () -> Unit = { lambdaError() },
private val canEnterPipResult: () -> Boolean = { lambdaError() },
private val enterPipResult: () -> Unit = { lambdaError() },
private val exitPipResult: () -> Unit = { lambdaError() },
) : PipController {
override fun setupPipCallbacks() = setupPipCallbacksResult()
override suspend fun canEnterPip(): Boolean = canEnterPipResult()
override fun enterPip() = enterPipResult()
@@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePipView(
private val setPipParamsResult: () -> Unit = { lambdaError() },
private val enterPipModeResult: () -> Boolean = { lambdaError() },
private val handUpResult: () -> Unit = { lambdaError() }
private val handUpResult: () -> Unit = { lambdaError() },
private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() },
) : PipView {
override fun setPipParams() = setPipParamsResult()
override fun enterPipMode(): Boolean = enterPipModeResult()
override fun hangUp() = handUpResult()
override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation)
}
@@ -21,7 +21,7 @@ import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object ContentScannerModule {
object ContentScannerBindingContainer {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
@@ -15,16 +15,16 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ContentScannerModuleTest {
class ContentScannerBindingContainerTest {
@Test
fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest {
fun `ContentScannerBindingContainer provides NoopContentScannerService when ContentScanner is null`() = runTest {
// Given
val contentScanner: ContentScanner? = null
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
val contentScannerService = ContentScannerBindingContainer.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
@@ -35,14 +35,14 @@ class ContentScannerModuleTest {
}
@Test
fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
fun `ContentScannerBindingContainer provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
val contentScannerService = ContentScannerBindingContainer.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
@@ -21,9 +21,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.features.invitepeople.api.InvitePeopleStateProvider
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -56,7 +56,7 @@ fun AddPeopleView(
Button(
text = stringResource(CommonStrings.action_finish),
onClick = {
state.eventSink(InvitePeopleEvents.SendInvites)
state.eventSink(InvitePeopleEvent.SendInvites)
},
enabled = state.canInvite,
modifier = Modifier
@@ -86,7 +86,7 @@ private fun AddPeopleTopBar(
@PreviewsDayNight
@Composable
internal fun AddPeopleViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview {
internal fun AddPeopleViewPreview(@PreviewParameter(InvitePeopleStatePreviewParam::class) state: InvitePeopleState) = ElementPreview {
AddPeopleView(
state = state,
invitePeopleView = {},
@@ -11,13 +11,13 @@ package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents
sealed interface ConfigureRoomEvent {
data class RoomNameChanged(val name: String) : ConfigureRoomEvent
data class TopicChanged(val topic: String) : ConfigureRoomEvent
data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvent
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvent
data object CreateRoom : ConfigureRoomEvent
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvent
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvent
data object CancelCreateRoom : ConfigureRoomEvent
}
@@ -188,14 +188,14 @@ class ConfigureRoomPresenter(
localCoroutineScope.createRoom(config, createRoomAction)
}
fun handleEvent(event: ConfigureRoomEvents) {
fun handleEvent(event: ConfigureRoomEvent) {
when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> {
is ConfigureRoomEvent.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvent.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvent.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvent.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvent.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvent.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
@@ -207,10 +207,10 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
is ConfigureRoomEvents.SetParentSpace -> {
is ConfigureRoomEvent.SetParentSpace -> {
dataStore.setParentSpace(event.space, false)
}
ConfigureRoomEvents.CancelCreateRoom -> {
ConfigureRoomEvent.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
}
}
@@ -26,7 +26,7 @@ data class ConfigureRoomState(
val homeserverName: String,
val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit
val eventSink: (ConfigureRoomEvent) -> Unit
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) &&
@@ -21,7 +21,7 @@ import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
open class ConfigureRoomStatePreviewParam : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState>
get() = sequenceOf(
aConfigureRoomState(),
@@ -143,7 +143,7 @@ fun aConfigureRoomState(
)
},
spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { },
eventSink: (ConfigureRoomEvent) -> Unit = { },
) = ConfigureRoomState(
config = config,
isSpace = isSpace,
@@ -96,7 +96,7 @@ fun ConfigureRoomView(
onBackClick = onBackClick,
onNextClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom)
state.eventSink(ConfigureRoomEvent.CreateRoom)
},
)
}
@@ -114,20 +114,20 @@ fun ConfigureRoomView(
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
onChangeRoomName = { state.eventSink(ConfigureRoomEvent.RoomNameChanged(it)) },
)
Spacer(modifier = Modifier.height(16.dp))
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
onTopicChange = { state.eventSink(ConfigureRoomEvent.TopicChanged(it)) },
)
Spacer(modifier = Modifier.height(16.dp))
if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
onSelectSpace = { state.eventSink(ConfigureRoomEvent.SetParentSpace(it)) },
)
}
RoomJoinRuleOptions(
@@ -136,7 +136,7 @@ fun ConfigureRoomView(
parentSpace = state.config.parentSpace,
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
state.eventSink(ConfigureRoomEvent.JoinRuleChanged(it))
},
)
if (state.config.visibilityState !is RoomVisibilityState.Private) {
@@ -146,7 +146,7 @@ fun ConfigureRoomView(
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
onAddressChange = { state.eventSink(ConfigureRoomEvent.RoomAddressChanged(it)) },
label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
@@ -158,7 +158,7 @@ fun ConfigureRoomView(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
onSelectAction = { state.eventSink(ConfigureRoomEvent.HandleAvatarAction(it)) }
)
AsyncActionView(
@@ -170,8 +170,8 @@ fun ConfigureRoomView(
},
onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
onRetry = { state.eventSink(ConfigureRoomEvent.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvent.CancelCreateRoom) },
)
PermissionsView(
@@ -349,12 +349,12 @@ private fun RoomJoinRuleOptions(
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStatePreviewParam::class) state: ConfigureRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStatePreviewParam::class) state: ConfigureRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@@ -12,7 +12,7 @@ import androidx.core.net.toUri
import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvents
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvent
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPresenter
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
@@ -106,14 +106,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
assertThat(initialState.isValid).isFalse()
// Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
initialState.eventSink(ConfigureRoomEvent.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isValid).isTrue()
// Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState.eventSink(ConfigureRoomEvent.RoomNameChanged(""))
newState = awaitItem()
config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config)
@@ -136,13 +136,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig)
// Room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
initialState.eventSink(ConfigureRoomEvent.RoomNameChanged(A_ROOM_NAME))
var newState = awaitItem()
expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room topic
newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
newState.eventSink(ConfigureRoomEvent.TopicChanged(A_MESSAGE))
newState = awaitItem()
expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
assertThat(newState.config).isEqualTo(expectedConfig)
@@ -153,7 +153,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// From gallery
val uriFromGallery = AN_URI_FROM_GALLERY
pickerProvider.givenResult(uriFromGallery.toUri())
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig)
@@ -161,7 +161,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
val uriFromCamera = AN_URI_FROM_CAMERA
pickerProvider.givenResult(uriFromCamera.toUri())
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue()
permissionsPresenter.setPermissionGranted()
@@ -173,18 +173,18 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Do it again, no permission is requested
val uriFromCamera2 = AN_URI_FROM_CAMERA_2
pickerProvider.givenResult(uriFromCamera2.toUri())
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = null)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem()
expectedConfig = expectedConfig.copy(
visibilityState = RoomVisibilityState.Public(
@@ -208,7 +208,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -239,13 +239,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
initialState.eventSink(ConfigureRoomEvent.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
@@ -282,13 +282,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
initialState.eventSink(ConfigureRoomEvent.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
// We immediately receive the room power levels info needed for adding the child to a space
val powerLevels = RoomPowerLevels(
@@ -339,7 +339,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
skipItems(2)
val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
@@ -369,14 +369,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
stateAfterCreateRoom.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -399,14 +399,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
stateAfterCreateRoom.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem()
@@ -414,7 +414,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Cancel
stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom)
stateAfterRetry.eventSink(ConfigureRoomEvent.CancelCreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@@ -430,9 +430,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("invalid address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
@@ -450,9 +450,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
@@ -470,9 +470,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("address"))
skipItems(1)
advanceUntilIdle()
awaitItem().also { state ->
@@ -488,22 +488,22 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
val initialState = initialState()
// First change the join rule to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
// (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
initialState.eventSink(ConfigureRoomEvent.SetParentSpace(aSpaceRoom()))
skipItems(1) // Skip intermediate state
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) // Skip intermediate state (Public is still invalid)
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// Then remove the parent space, the join rule stays private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
initialState.eventSink(ConfigureRoomEvent.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
}
}
@@ -514,7 +514,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
initialState.eventSink(ConfigureRoomEvent.SetParentSpace(aSpaceRoom()))
assertThat(awaitError())
}
@@ -534,8 +534,8 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction.isLoading()).isTrue()
assertThat(awaitItem().createRoomAction.isSuccess()).isTrue()
@@ -8,9 +8,9 @@
package io.element.android.features.logout.impl
sealed interface AccountDeactivationEvents {
data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvents
data class SetPassword(val password: String) : AccountDeactivationEvents
data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvents
data object CloseDialogs : AccountDeactivationEvents
sealed interface AccountDeactivationEvent {
data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent
data class SetPassword(val password: String) : AccountDeactivationEvent
data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvent
data object CloseDialogs : AccountDeactivationEvent
}
@@ -34,19 +34,19 @@ class AccountDeactivationPresenter(
val formState = remember { mutableStateOf(DeactivateFormState.Default) }
fun handleEvent(event: AccountDeactivationEvents) {
fun handleEvent(event: AccountDeactivationEvent) {
when (event) {
is AccountDeactivationEvents.SetEraseData -> {
is AccountDeactivationEvent.SetEraseData -> {
updateFormState(formState) {
copy(eraseData = event.eraseData)
}
}
is AccountDeactivationEvents.SetPassword -> {
is AccountDeactivationEvent.SetPassword -> {
updateFormState(formState) {
copy(password = event.password)
}
}
is AccountDeactivationEvents.DeactivateAccount ->
is AccountDeactivationEvent.DeactivateAccount ->
if (action.value.isConfirming() || event.isRetry) {
localCoroutineScope.deactivateAccount(
formState = formState.value,
@@ -55,7 +55,7 @@ class AccountDeactivationPresenter(
} else {
action.value = AsyncAction.ConfirmingNoParams
}
AccountDeactivationEvents.CloseDialogs -> {
AccountDeactivationEvent.CloseDialogs -> {
action.value = AsyncAction.Uninitialized
}
}
@@ -15,7 +15,7 @@ import kotlinx.parcelize.Parcelize
data class AccountDeactivationState(
val deactivateFormState: DeactivateFormState,
val accountDeactivationAction: AsyncAction<Unit>,
val eventSink: (AccountDeactivationEvents) -> Unit,
val eventSink: (AccountDeactivationEvent) -> Unit,
) {
val submitEnabled: Boolean
get() = accountDeactivationAction is AsyncAction.Uninitialized &&
@@ -11,7 +11,7 @@ package io.element.android.features.logout.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class AccountDeactivationStateProvider : PreviewParameterProvider<AccountDeactivationState> {
open class AccountDeactivationStatePreviewParam : PreviewParameterProvider<AccountDeactivationState> {
private val filledForm = aDeactivateFormState(eraseData = true, password = "password")
override val values: Sequence<AccountDeactivationState>
get() = sequenceOf(
@@ -45,7 +45,7 @@ internal fun aDeactivateFormState(
internal fun anAccountDeactivationState(
deactivateFormState: DeactivateFormState = aDeactivateFormState(),
accountDeactivationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AccountDeactivationEvents) -> Unit = {},
eventSink: (AccountDeactivationEvent) -> Unit = {},
) = AccountDeactivationState(
deactivateFormState = deactivateFormState,
accountDeactivationAction = accountDeactivationAction,
@@ -102,14 +102,14 @@ fun AccountDeactivationView(
Content(
state = state,
onSubmitClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
}
)
Spacer(modifier = Modifier.height(32.dp))
Buttons(
state = state,
onSubmitClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
}
)
}
@@ -117,13 +117,13 @@ fun AccountDeactivationView(
AccountDeactivationActionDialog(
state.accountDeactivationAction,
onConfirmClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
},
onRetryClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true))
eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = true))
},
onDismissDialog = {
eventSink(AccountDeactivationEvents.CloseDialogs)
eventSink(AccountDeactivationEvent.CloseDialogs)
},
)
}
@@ -239,7 +239,7 @@ private fun Content(
headline = stringResource(R.string.screen_deactivate_account_delete_all_messages),
value = eraseData,
onChange = {
eventSink(AccountDeactivationEvents.SetEraseData(it))
eventSink(AccountDeactivationEvent.SetEraseData(it))
},
enabled = !isLoading,
)
@@ -275,7 +275,7 @@ private fun Content(
onValueChange = {
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(AccountDeactivationEvents.SetPassword(sanitized))
eventSink(AccountDeactivationEvent.SetPassword(sanitized))
},
placeholder = stringResource(CommonStrings.common_password),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@@ -299,16 +299,16 @@ private fun Content(
}
/**
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
* Ensure that the string does not contain any line separator, which can happen when pasting values.
*/
private fun String.sanitize(): String {
return replace("\n", "")
return filterNot { it == '\n' || it == '\r' }
}
@PreviewsDayNight
@Composable
internal fun AccountDeactivationViewPreview(
@PreviewParameter(AccountDeactivationStateProvider::class) state: AccountDeactivationState,
@PreviewParameter(AccountDeactivationStatePreviewParam::class) state: AccountDeactivationState,
) = ElementPreview {
AccountDeactivationView(
state,
@@ -47,11 +47,11 @@ class AccountDeactivationPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default)
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true))
initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
val updatedState = awaitItem()
assertThat(updatedState.deactivateFormState).isEqualTo(DeactivateFormState.Default.copy(eraseData = true))
assertThat(updatedState.submitEnabled).isFalse()
updatedState.eventSink(AccountDeactivationEvents.SetPassword("password"))
updatedState.eventSink(AccountDeactivationEvent.SetPassword("password"))
val updatedState2 = awaitItem()
assertThat(updatedState2.deactivateFormState).isEqualTo(DeactivateFormState(password = "password", eraseData = true))
assertThat(updatedState2.submitEnabled).isTrue()
@@ -71,12 +71,12 @@ class AccountDeactivationPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
skipItems(1)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
@@ -98,20 +98,20 @@ class AccountDeactivationPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true))
initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
recorder.assertions().isCalledOnce().with(value("password"), value(true))
// Retry
finalState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true))
finalState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = true))
val finalState2 = awaitItem()
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
@@ -131,20 +131,20 @@ class AccountDeactivationPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true))
initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false))
updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
recorder.assertions().isCalledOnce().with(value("password"), value(true))
// Cancel
finalState.eventSink(AccountDeactivationEvents.CloseDialogs)
finalState.eventSink(AccountDeactivationEvent.CloseDialogs)
val finalState2 = awaitItem()
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
}
@@ -35,7 +35,7 @@ import org.robolectric.annotation.Config
class AccountDeactivationViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>(expectEvents = false)
ensureCalledOnce {
setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder),
@@ -48,7 +48,7 @@ class AccountDeactivationViewTest : RobolectricTest() {
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
@@ -58,12 +58,12 @@ class AccountDeactivationViewTest : RobolectricTest() {
),
)
clickOn(CommonStrings.action_delete)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(false))
}
@Test
fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
@@ -74,12 +74,12 @@ class AccountDeactivationViewTest : RobolectricTest() {
),
)
pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(false))
}
@Test
fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
@@ -90,24 +90,24 @@ class AccountDeactivationViewTest : RobolectricTest() {
),
)
clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true))
eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(true))
}
@Test
fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
eventSink = eventsRecorder,
),
)
clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true))
eventsRecorder.assertSingle(AccountDeactivationEvent.SetEraseData(true))
}
@Test
fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
@@ -117,13 +117,13 @@ class AccountDeactivationViewTest : RobolectricTest() {
),
)
clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false))
eventsRecorder.assertSingle(AccountDeactivationEvent.SetEraseData(false))
}
@Config(qualifiers = "h1024dp")
@Test
fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
@@ -133,7 +133,7 @@ class AccountDeactivationViewTest : RobolectricTest() {
),
)
onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD"))
eventsRecorder.assertSingle(AccountDeactivationEvent.SetPassword("A$A_PASSWORD"))
}
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations 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.enterprise.api
import io.element.android.libraries.matrix.api.MatrixClientBuilder
import io.element.android.libraries.matrix.api.core.SessionId
/**
* A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
*/
fun interface ClientBuilderEnterpriseHook {
suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations 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.enterprise.api
import io.element.android.libraries.matrix.api.MatrixClient
/**
* A hook that can be used to customize the [MatrixClient] for enterprise features.
*/
fun interface ClientEnterpriseHook {
suspend operator fun invoke(client: MatrixClient)
}
@@ -10,7 +10,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
@@ -31,10 +31,9 @@ interface EnterpriseService {
* Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
*
* @param url the URL to rewrite.
* @param homeserver the homeserver the URL belongs to.
* @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
*/
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
/**
* Returns the list of homeservers the user is allowed to sign in to.
@@ -53,9 +52,9 @@ interface EnterpriseService {
/**
* Whether the given homeserver enforces the use of Element Pro or a derived app.
*
* @param homeserverUrl the homeserver to check.
* @param serverName the homeserver to check.
*/
suspend fun isElementProEnforced(homeserverUrl: String): Boolean
suspend fun isElementProEnforced(serverName: String): Boolean
/**
* Override the brand color.
@@ -99,14 +98,6 @@ interface EnterpriseService {
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
* Returns `null` when this build does not read its configuration from an ESS deployment.
*
* @param domain the server whose configuration endpoint is requested.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -32,13 +32,13 @@ sealed interface RemoteEnterpriseConfigResult<out T> {
is Success<T> -> data
is Outdated<T> -> data
is Error -> null
NotFound -> null
is NotFound -> null
}
fun upToDateDataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> null
is Error -> null
NotFound -> null
is NotFound -> null
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations 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.enterprise.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
import io.element.android.libraries.matrix.api.MatrixClientBuilder
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS.
*/
@ContributesBinding(AppScope::class)
class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
// No modification
return clientBuilder
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations 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.enterprise.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.ClientEnterpriseHook
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Default implementation of [ClientEnterpriseHook] that does nothing for FOSS.
*/
@ContributesBinding(AppScope::class)
class DefaultClientEnterpriseHook : ClientEnterpriseHook {
override suspend fun invoke(client: MatrixClient) {
// No-op
}
}
@@ -14,7 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -22,10 +22,10 @@ import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
class DefaultEnterpriseService : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = false
override suspend fun isElementProEnforced(serverName: String): Boolean = false
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -45,6 +45,4 @@ class DefaultEnterpriseService : EnterpriseService {
}
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -1,29 +0,0 @@
/*
* Copyright (c) 2026 Element Creations 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.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@@ -30,8 +30,7 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() },
private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
@@ -41,8 +40,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver, urlContentFetcher)
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, urlContentFetcher)
}
override fun homeserverAllowList(): List<String> {
@@ -53,8 +52,8 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = simulateLongTask {
isElementProEnforcedResult(homeserverUrl)
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
isElementProEnforcedResult(serverName)
}
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
@@ -85,8 +84,4 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId)
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
}
@@ -8,6 +8,6 @@
package io.element.android.features.forward.impl
sealed interface ForwardMessagesEvents {
data object ClearError : ForwardMessagesEvents
sealed interface ForwardMessagesEvent {
data object ClearError : ForwardMessagesEvent
}
@@ -48,9 +48,9 @@ class ForwardMessagesPresenter(
@Composable
override fun present(): ForwardMessagesState {
fun handleEvent(event: ForwardMessagesEvents) {
fun handleEvent(event: ForwardMessagesEvent) {
when (event) {
ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized
ForwardMessagesEvent.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized
}
}
@@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.core.RoomId
data class ForwardMessagesState(
val forwardAction: AsyncAction<List<RoomId>>,
val eventSink: (ForwardMessagesEvents) -> Unit
val eventSink: (ForwardMessagesEvent) -> Unit
)
@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
open class ForwardMessagesStatePreviewParam : PreviewParameterProvider<ForwardMessagesState> {
override val values: Sequence<ForwardMessagesState>
get() = sequenceOf(
aForwardMessagesState(),
@@ -32,7 +32,7 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
fun aForwardMessagesState(
forwardAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ForwardMessagesEvents) -> Unit = {}
eventSink: (ForwardMessagesEvent) -> Unit = {}
) = ForwardMessagesState(
forwardAction = forwardAction,
eventSink = eventSink
@@ -31,14 +31,14 @@ fun ForwardMessagesView(
stringResource(id = CommonStrings.error_unknown)
},
onErrorDismiss = {
state.eventSink(ForwardMessagesEvents.ClearError)
state.eventSink(ForwardMessagesEvent.ClearError)
},
)
}
@PreviewsDayNight
@Composable
internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview {
internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStatePreviewParam::class) state: ForwardMessagesState) = ElementPreview {
ForwardMessagesView(
state = state,
onForwardSuccess = {}
@@ -86,7 +86,7 @@ class ForwardMessagesPresenterTest {
val failedForwardState = awaitItem()
assertThat(failedForwardState.forwardAction.isFailure()).isTrue()
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce()
}
@@ -29,7 +29,7 @@ import org.junit.Test
class ForwardMessagesViewTest : RobolectricTest() {
@Test
fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
val eventsRecorder = EventsRecorder<ForwardMessagesEvent>()
setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION),
@@ -37,13 +37,13 @@ class ForwardMessagesViewTest : RobolectricTest() {
),
)
pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
eventsRecorder.assertSingle(ForwardMessagesEvent.ClearError)
}
@Test
fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest {
val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<ForwardMessagesEvent>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
setForwardMessagesView(
aForwardMessagesState(

Some files were not shown because too many files have changed in this diff Show More