Merge branch 'release/25.06.1'

This commit is contained in:
Jorge Martín 2025-06-09 12:33:32 +02:00
commit c17dc19370
490 changed files with 3233 additions and 3172 deletions

View file

@ -1,3 +1,57 @@
Changes in Element X v25.06.0
=============================
Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250603
## What's Changed
### ✨ Features
* Add support for login link by @bmarty in https://github.com/element-hq/element-x-android/pull/4752
### 🙌 Improvements
* On boarding flow: add a screen to select account provider among a fixed list by @bmarty in https://github.com/element-hq/element-x-android/pull/4769
* Change : RoomMember moderation by @ganfra in https://github.com/element-hq/element-x-android/pull/4779
### 🐛 Bugfixes
* Fix left room membership change by @ganfra in https://github.com/element-hq/element-x-android/pull/4765
* fix: exclude more domains from being backed up by the system by @lucasmz-dev in https://github.com/element-hq/element-x-android/pull/4773
* Make sure HeaderFooterPage contents can be scrolled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4704
* Fix mobile link by @bmarty in https://github.com/element-hq/element-x-android/pull/4805
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4775
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4804
### 🧱 Build
* Maestro: fix MAS and EC breaking the tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4762
* Update Gradle Wrapper from 8.14 to 8.14.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4766
* Stronger lambda error by @bmarty in https://github.com/element-hq/element-x-android/pull/4771
* Use Localazy's `langAliases` for Indonesian language by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4801
### Dependency upgrades
* fix(deps): update datastore to v1.1.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4754
* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4721
* chore(deps): update plugin ktlint to v12.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4767
* fix(deps): update dependency com.google.firebase:firebase-bom to v33.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4755
* Update UnifiedPush library by @bmarty in https://github.com/element-hq/element-x-android/pull/4358
* fix(deps): update sqldelight to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4735
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.26 by @renovate in https://github.com/element-hq/element-x-android/pull/4781
* fix(deps): update dependency com.posthog:posthog-android to v3.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4787
* fix(deps): update dependency com.posthog:posthog-android to v3.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4789
* fix(deps): update dependency io.element.android:element-call-embedded to v0.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4743
* fix(deps): update dependencyanalysis to v2.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4796
* fix(deps): update android.gradle.plugin to v8.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4795
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.29 by @renovate in https://github.com/element-hq/element-x-android/pull/4799
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4810
### Others
* fix(deps): update media3 to v1.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4733
* fix: Ignore global proxy settings if system thinks there's none by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4744
* Add `ActiveRoomHolder` to manage the active room for a session by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4758
* Notification events resolving and rendering in batches by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4722
* Hide Element Call entry point if Element Call service is not available. by @bmarty in https://github.com/element-hq/element-x-android/pull/4783
* Fix dependencies on test by @bmarty in https://github.com/element-hq/element-x-android/pull/4790
* Update _developer_onboarding.md by @lex-neufeld in https://github.com/element-hq/element-x-android/pull/4570
## New Contributors
* @lucasmz-dev made their first contribution in https://github.com/element-hq/element-x-android/pull/4773
* @lex-neufeld made their first contribution in https://github.com/element-hq/element-x-android/pull/4570
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.4...v25.06.0
<!-- Release notes generated using configuration in .github/release.yml at v25.05.4 --> <!-- Release notes generated using configuration in .github/release.yml at v25.05.4 -->
Changes in Element X v25.05.4 Changes in Element X v25.05.4

View file

@ -25,6 +25,10 @@
android:theme="@style/Theme.ElementX" android:theme="@style/Theme.ElementX"
tools:targetApi="33"> tools:targetApi="33">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.x.BuildConfig import io.element.android.x.BuildConfig
import io.element.android.x.R import io.element.android.x.R
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
@ -56,6 +57,7 @@ object AppModule {
} }
@Provides @Provides
@AppCoroutineScope
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope { fun providesAppCoroutineScope(): CoroutineScope {
return MainScope() + CoroutineName("ElementX Scope") return MainScope() + CoroutineName("ElementX Scope")

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2025 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
~ Please see LICENSE files in the repository root for full details.
-->
<automotiveApp>
<uses name="notification" />
</automotiveApp>

View file

@ -65,6 +65,7 @@ import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -104,7 +105,8 @@ class LoggedInFlowNode @AssistedInject constructor(
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint, private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService, private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
@ -175,7 +177,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener) matrixClient.sessionVerificationService().setListener(verificationListener)
ftueService.state ftueService.state
@ -313,7 +315,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) } sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
} }
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {

View file

@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -125,7 +126,7 @@ class LoggedInPresenter @Inject constructor(
} }
// Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK // Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatching { private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatchingExceptions {
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow() val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
currentSlidingSyncVersion == SlidingSyncVersion.Proxy currentSlidingSyncVersion == SlidingSyncVersion.Proxy
} }

View file

@ -42,8 +42,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
@ -124,7 +122,7 @@ class RoomFlowNode @AssistedInject constructor(
} }
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) { private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val roomInfoFlow = client.getRoomInfoFlow(roomIdOrAlias = roomId.toRoomIdOrAlias()) val roomInfoFlow = client.getRoomInfoFlow(roomId)
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged() val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged() val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace -> combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -50,7 +51,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint, private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
@ -92,7 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
trackVisitedRoom() trackVisitedRoom()
}, },
onResume = { onResume = {
appCoroutineScope.launch { sessionCoroutineScope.launch {
inputs.room.subscribeToSync() inputs.room.subscribeToSync()
} }
}, },

View file

@ -109,7 +109,7 @@ class JoinedRoomLoadedFlowNodeTest {
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
appCoroutineScope = this, sessionCoroutineScope = this,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,

View file

@ -50,6 +50,7 @@ allprojects {
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22") detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
detektPlugins(project(":tests:detekt-rules"))
} }
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach { tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {

View file

@ -0,0 +1,2 @@
Main changes in this version: fix audio devices and volume selection in Element Call, improves moderation features.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -10,8 +10,10 @@ package io.element.android.features.cachecleaner.impl
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.cachecleaner.api.CacheCleaner import io.element.android.features.cachecleaner.api.CacheCleaner
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -23,7 +25,8 @@ import javax.inject.Inject
*/ */
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCacheCleaner @Inject constructor( class DefaultCacheCleaner @Inject constructor(
private val scope: CoroutineScope, @AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
@CacheDirectory private val cacheDir: File, @CacheDirectory private val cacheDir: File,
) : CacheCleaner { ) : CacheCleaner {
@ -32,8 +35,8 @@ class DefaultCacheCleaner @Inject constructor(
} }
override fun clearCache() { override fun clearCache() {
scope.launch(dispatchers.io) { coroutineScope.launch(dispatchers.io) {
runCatching { runCatchingExceptions {
SUBDIRS_TO_CLEANUP.forEach { SUBDIRS_TO_CLEANUP.forEach {
File(cacheDir.path, it).apply { File(cacheDir.path, it).apply {
if (exists()) { if (exists()) {

View file

@ -55,7 +55,7 @@ class DefaultCacheCleanerTest {
} }
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner( private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
scope = this, coroutineScope = this,
dispatchers = this.testCoroutineDispatchers(true), dispatchers = this.testCoroutineDispatchers(true),
cacheDir = temporaryFolder.root, cacheDir = temporaryFolder.root,
) )

View file

@ -15,11 +15,19 @@ import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable { sealed interface CallType : NodeInputs, Parcelable {
@Parcelize @Parcelize
data class ExternalUrl(val url: String) : CallType data class ExternalUrl(val url: String) : CallType {
override fun toString(): String {
return "ExternalUrl"
}
}
@Parcelize @Parcelize
data class RoomCall( data class RoomCall(
val sessionId: SessionId, val sessionId: SessionId,
val roomId: RoomId, val roomId: RoomId,
) : CallType ) : CallType {
override fun toString(): String {
return "RoomCall(sessionId=$sessionId, roomId=$roomId)"
}
}
} }

View file

@ -16,6 +16,7 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -30,8 +31,8 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var activeCallManager: ActiveCallManager lateinit var activeCallManager: ActiveCallManager
@Inject @AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope @Inject lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }

View file

@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import io.element.android.features.call.impl.R import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@ -78,7 +79,7 @@ class CallForegroundService : Service() {
} else { } else {
0 0
} }
runCatching { runCatchingExceptions {
ServiceCompat.startForeground(this, notificationId, notification, serviceType) ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure { }.onFailure {
Timber.e(it, "Failed to start ongoing call foreground service") Timber.e(it, "Failed to start ongoing call foreground service")

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -49,6 +50,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber
import java.util.UUID import java.util.UUID
class CallScreenPresenter @AssistedInject constructor( class CallScreenPresenter @AssistedInject constructor(
@ -64,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val languageTagProvider: LanguageTagProvider, private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
@ -211,6 +214,7 @@ class CallScreenPresenter @AssistedInject constructor(
theme = theme, theme = theme,
).getOrThrow() ).getOrThrow()
callWidgetDriver.value = result.driver callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url result.url
} }
} }
@ -221,10 +225,12 @@ class CallScreenPresenter @AssistedInject constructor(
private fun HandleMatrixClientSyncState() { private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) { DisposableEffect(Unit) {
val client = (callType as? CallType.RoomCall)?.sessionId?.let { val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
matrixClientsProvider.getOrNull(it) val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
} ?: return@DisposableEffect onDispose { } Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
}
coroutineScope.launch { coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService().syncState client.syncService().syncState
.collect { state -> .collect { state ->
if (state == SyncState.Running) { if (state == SyncState.Running) {
@ -235,6 +241,7 @@ class CallScreenPresenter @AssistedInject constructor(
} }
} }
onDispose { onDispose {
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
// Make sure we mark the call as ended in the app state // Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false) appForegroundStateService.updateIsInCallState(false)
} }
@ -242,12 +249,29 @@ class CallScreenPresenter @AssistedInject constructor(
} }
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) { private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
if (!notifiedCallStart) { if (notifiedCallStart) return
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
val sendCallNotificationResult = activeRoomForSession?.sendCallNotificationIfNeeded() val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
?: getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() } val sendCallNotificationResult = if (activeRoomForSession != null) {
sendCallNotificationResult?.onSuccess { notifiedCallStart = true } Timber.d("Notifying call start for room $roomId. Has room call: ${activeRoomForSession.info().hasRoomCall}")
activeRoomForSession.sendCallNotificationIfNeeded()
} else {
// Instantiate the room from the session and roomId and send the notification
getJoinedRoom(roomId)?.use { room ->
Timber.d("Notifying call start for room $roomId. Has room call: ${room.info().hasRoomCall}")
room.sendCallNotificationIfNeeded()
} ?: run {
Timber.w("No room found for session $sessionId and room $roomId, skipping call notification.")
return
}
} }
sendCallNotificationResult.fold(
onSuccess = { notifiedCallStart = true },
onFailure = { error ->
Timber.e(error, "Failed to send call notification for room $roomId.")
}
)
} }
private fun parseMessage(message: String): WidgetMessage? { private fun parseMessage(message: String): WidgetMessage? {

View file

@ -8,10 +8,6 @@
package io.element.android.features.call.impl.ui package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
@ -28,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -35,17 +32,15 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.androidutils.compat.disableExternalAudioDevice
import io.element.android.libraries.androidutils.compat.enableExternalAudioDevice
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@ -108,6 +103,8 @@ internal fun CallScreenView(
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
) )
} else { } else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
CallWebView( CallWebView(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
@ -120,25 +117,40 @@ internal fun CallScreenView(
val callback: RequestPermissionCallback = { request.grant(it) } val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback) requestPermissions(androidPermissions.toTypedArray(), callback)
}, },
onWebViewCreate = { webView -> onCreateWebView = { webView ->
val interceptor = WebViewWidgetMessageInterceptor( val interceptor = WebViewWidgetMessageInterceptor(
webView = webView, webView = webView,
onUrlLoaded = { url ->
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
) )
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView) val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
webViewAudioManager?.onCallStopped()
} }
) )
when (state.urlState) { when (state.urlState) {
AsyncData.Uninitialized, AsyncData.Uninitialized,
is AsyncData.Loading -> is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure -> is AsyncData.Failure -> {
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog( ErrorDialog(
content = state.urlState.error.message.orEmpty(), content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
) )
}
is AsyncData.Success -> Unit is AsyncData.Success -> Unit
} }
} }
@ -150,7 +162,8 @@ private fun CallWebView(
url: AsyncData<String>, url: AsyncData<String>,
userAgent: String, userAgent: String,
onPermissionsRequest: (PermissionRequest) -> Unit, onPermissionsRequest: (PermissionRequest) -> Unit,
onWebViewCreate: (WebView) -> Unit, onCreateWebView: (WebView) -> Unit,
onDestroyWebView: (WebView) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@ -158,13 +171,11 @@ private fun CallWebView(
Text("WebView - can't be previewed") Text("WebView - can't be previewed")
} }
} else { } else {
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
AndroidView( AndroidView(
modifier = modifier, modifier = modifier,
factory = { context -> factory = { context ->
audioDeviceCallback = context.setupAudioConfiguration()
WebView(context).apply { WebView(context).apply {
onWebViewCreate(this) onCreateWebView(this)
setup(userAgent, onPermissionsRequest) setup(userAgent, onPermissionsRequest)
} }
}, },
@ -174,41 +185,13 @@ private fun CallWebView(
} }
}, },
onRelease = { webView -> onRelease = { webView ->
// Reset audio mode onDestroyWebView(webView)
webView.context.releaseAudioConfiguration(audioDeviceCallback)
webView.destroy() webView.destroy()
} }
) )
} }
} }
private fun Context.setupAudioConfiguration(): AudioDeviceCallback? {
val audioManager = getSystemService<AudioManager>() ?: return null
// Set 'voice call' mode so volume keys actually control the call volume
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.enableExternalAudioDevice()
return object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
Timber.d("Audio devices added")
audioManager.enableExternalAudioDevice()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
Timber.d("Audio devices removed")
audioManager.enableExternalAudioDevice()
}
}.also {
audioManager.registerAudioDeviceCallback(it, null)
}
}
private fun Context.releaseAudioConfiguration(audioDeviceCallback: AudioDeviceCallback?) {
val audioManager = getSystemService<AudioManager>() ?: return
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
audioManager.disableExternalAudioDevice()
audioManager.mode = AudioManager.MODE_NORMAL
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
private fun WebView.setup( private fun WebView.setup(
userAgent: String, userAgent: String,
@ -242,6 +225,20 @@ private fun WebView.setup(
ConsoleMessage.MessageLevel.WARNING -> Log.WARN ConsoleMessage.MessageLevel.WARNING -> Log.WARN
else -> Log.DEBUG else -> Log.DEBUG
} }
val message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
}
if (message.contains("password=")) {
// Avoid logging any messages that contain "password" to prevent leaking sensitive information
return true
}
Timber.tag("WebView").log( Timber.tag("WebView").log(
priority = priority, priority = priority,
message = buildString { message = buildString {

View file

@ -81,13 +81,14 @@ class ElementCallActivity :
applicationContext.bindings<CallBindings>().inject(this) applicationContext.bindings<CallBindings>().inject(this)
@Suppress("DEPRECATION") window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or setShowWhenLocked(true)
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or } else {
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON @Suppress("DEPRECATION")
) window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
setCallType(intent) setCallType(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early // If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
@ -97,6 +98,8 @@ class ElementCallActivity :
pictureInPicturePresenter.setPipView(this) pictureInPicturePresenter.setPipView(this)
Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}")
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState) ListenToAndroidEvents(pipState)

View file

@ -23,6 +23,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@ -57,8 +58,8 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject @Inject
lateinit var buildMeta: BuildMeta lateinit var buildMeta: BuildMeta
@Inject @AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope @Inject lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View file

@ -21,9 +21,11 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
@ -86,6 +88,7 @@ interface ActiveCallManager {
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor( class DefaultActiveCallManager @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
@ -180,6 +183,9 @@ class DefaultActiveCallManager @Inject constructor(
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return return
} }
Timber.tag(tag).d("Hung up call: $callType")
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after hang up") Timber.tag(tag).d("Releasing partial wakelock after hang up")
@ -190,6 +196,8 @@ class DefaultActiveCallManager @Inject constructor(
} }
override suspend fun joinedCall(callType: CallType) = mutex.withLock { override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType")
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call") Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -218,7 +226,7 @@ class DefaultActiveCallManager @Inject constructor(
timestamp = notificationData.timestamp, timestamp = notificationData.timestamp,
textContent = notificationData.textContent, textContent = notificationData.textContent,
) ?: return ) ?: return
runCatching { runCatchingExceptions {
notificationManagerCompat.notify( notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL), NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification, notification,

View file

@ -8,6 +8,7 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -33,7 +34,7 @@ class DefaultCallWidgetProvider @Inject constructor(
clientId: String, clientId: String,
languageTag: String?, languageTag: String?,
theme: String?, theme: String?,
): Result<CallWidgetProvider.GetWidgetResult> = runCatching { ): Result<CallWidgetProvider.GetWidgetResult> = runCatchingExceptions {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
?: matrixClient.getJoinedRoom(roomId) ?: matrixClient.getJoinedRoom(roomId)

View file

@ -0,0 +1,450 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.PowerManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.content.getSystemService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* This class manages the audio devices for a WebView.
*
* It listens for audio device changes and updates the WebView with the available devices.
* It also handles the selection of the audio device by the user in the WebView and the default audio device based on the device type.
*
* See also: [Element Call controls docs.](https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#audio-devices)
*/
class WebViewAudioManager(
private val webView: WebView,
private val coroutineScope: CoroutineScope,
) {
// The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
private val wantedDeviceTypes = listOf(
// Paired bluetooth device with microphone
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
// USB devices which can play or record audio
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
// Wired audio devices
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
// The built-in speaker of the device
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
// The built-in earpiece of the device
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
)
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val proximitySensorWakeLock by lazy {
webView.context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
}
/**
* This listener tracks the current communication device and updates the WebView when it changes.
*/
private val commsDeviceChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device ->
if (device != null && device.id == expectedNewCommunicationDeviceId) {
expectedNewCommunicationDeviceId = null
Timber.d("Audio device changed, type: ${device.type}")
updateSelectedAudioDeviceInWebView(device.id.toString())
} else if (device != null && device.id != expectedNewCommunicationDeviceId) {
// We were expecting a device change but it didn't happen, so we should retry
val expectedDeviceId = expectedNewCommunicationDeviceId
if (expectedDeviceId != null) {
// Remove the expected id so we only retry once
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(expectedDeviceId.toString())
}
} else {
Timber.d("Audio device cleared")
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(null)
}
}
/**
* This callback is used to listen for audio device changes coming from the OS.
*/
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink }
if (validNewDevices.isEmpty()) return
// We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
// This should automatically switch to a new device if it has a higher priority than the current one
selectDefaultAudioDevice(audioDevices)
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
// Update the available devices
setAvailableAudioDevices()
// Unless the removed device is the current one, we don't need to do anything else
val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
if (!removedCurrentDevice) return
val previousDevice = previousSelectedDevice
if (previousDevice != null) {
previousSelectedDevice = null
// If we have a previous device, we should select it again
audioManager.selectAudioDevice(previousDevice.id.toString())
} else {
// If we don't have a previous device, we should select the default one
selectDefaultAudioDevice()
}
}
}
/**
* The currently used audio device id.
*/
private var currentDeviceId: Int? = null
/**
* When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one.
*/
private var expectedNewCommunicationDeviceId: Int? = null
/**
* Previously selected device, used to restore the selection when the selected device is removed.
*/
private var previousSelectedDevice: AudioDeviceInfo? = null
private var hasRegisteredCallbacks = false
/**
* Marks if the WebView audio is in call mode or not.
*/
val isInCallMode = AtomicBoolean(false)
init {
// Apparently, registering the javascript interface takes a while, so registering and immediately using it doesn't work
// We register it ahead of time to avoid this issue
registerWebViewDeviceSelectedCallback()
}
/**
* Call this method when the call starts to enable in-call audio mode.
*
* It'll set the audio mode to [AudioManager.MODE_IN_COMMUNICATION] if possible, register the audio device callback and set the available audio devices.
*/
fun onCallStarted() {
if (!isInCallMode.compareAndSet(false, true)) {
Timber.w("Audio: tried to enable webview in-call audio mode while already in it")
return
}
Timber.d("Audio: enabling webview in-call audio mode")
audioManager.mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Set 'voice call' mode so volume keys actually control the call volume
AudioManager.MODE_IN_COMMUNICATION
} else {
// Workaround for Android 12 and lower, otherwise changing the audio device doesn't work
AudioManager.MODE_NORMAL
}
setWebViewAndroidNativeBridge()
}
/**
* Call this method when the call stops to disable in-call audio mode.
*
* It's the counterpart of [onCallStarted], and should be called as a pair with it once the call has ended.
*/
fun onCallStopped() {
if (!isInCallMode.compareAndSet(true, false)) {
Timber.w("Audio: tried to disable webview in-call audio mode while already disabled")
return
}
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
audioManager.mode = AudioManager.MODE_NORMAL
if (!hasRegisteredCallbacks) {
Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener)
}
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
/**
* Registers the WebView audio device selected callback.
*
* This should be called when the WebView is created to ensure that the callback is set before any audio device selection is made.
*/
private fun registerWebViewDeviceSelectedCallback() {
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
onAudioDeviceSelected = { selectedDeviceId ->
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
audioManager.selectAudioDevice(selectedDeviceId)
},
onAudioPlaybackStarted = {
coroutineScope.launch(Dispatchers.Main) {
// Calling this ahead of time makes the default audio device to not use the right audio stream
setAvailableAudioDevices()
// Registering the audio devices changed callback will also set the default audio device
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener)
}
hasRegisteredCallbacks = true
}
}
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
}
/**
* Assigns the callback in the WebView to be called when the user selects an audio device.
*
* It should be called with some delay after [registerWebViewDeviceSelectedCallback].
*/
private fun setWebViewAndroidNativeBridge() {
Timber.d("Adding callback in controls.onAudioPlaybackStarted")
webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null)
Timber.d("Adding callback in controls.onOutputDeviceSelect")
webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null)
}
/**
* Returns the list of available audio devices.
*
* On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback.
*/
private fun listAudioDevices(): List<AudioDeviceInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.availableCommunicationDevices
} else {
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
}
}
/**
* Sets the available audio devices in the WebView.
*
* @param devices The list of audio devices to set. If not provided, it will use the current list of audio devices.
*/
private fun setAvailableAudioDevices(
devices: List<SerializableAudioDevice> = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo),
) {
Timber.d("Updating available audio devices")
val jsonSerializer = Json {
encodeDefaults = true
explicitNulls = false
}
val deviceList = jsonSerializer.encodeToString(devices)
webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
Timber.d("Audio: setAvailableOutputDevices result: $it")
})
}
/**
* Selects the default audio device based on the available devices.
*
* @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
*/
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
}
}
expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice)
selectedDevice?.let {
updateSelectedAudioDeviceInWebView(it.id.toString())
} ?: run {
Timber.w("Audio: unable to select default audio device")
}
}
/**
* Updates the WebView's UI to reflect the selected audio device.
*
* @param deviceId The id of the selected audio device.
*/
private fun updateSelectedAudioDeviceInWebView(deviceId: String) {
coroutineScope.launch(Dispatchers.Main) {
webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null)
}
}
/**
* Selects the audio device on the OS based on the provided device id.
*
* It will select the device only if it is available in the list of audio devices.
*
* @param device The id of the audio device to select.
*/
private fun AudioManager.selectAudioDevice(device: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
} else {
val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val audioDevice = rawAudioDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
}
}
/**
* Selects the audio device on the OS based on the provided device info.
*
* @param device The info of the audio device to select, or none to clear the selected device.
*/
@Suppress("DEPRECATION")
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
currentDeviceId = device?.id
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (device != null) {
Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}")
setCommunicationDevice(device)
} else {
audioManager.clearCommunicationDevice()
}
} else {
// On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type
if (device != null) {
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
isSpeakerphoneOn = false
isBluetoothScoOn = false
}
}
expectedNewCommunicationDeviceId = null
@Suppress("WakeLock", "WakeLockTimeout")
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
proximitySensorWakeLock?.acquire()
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
}
}
}
/**
* This class is used to handle the audio device selection in the WebView.
* It listens for the audio device selection event and calls the callback with the selected device ID.
*/
private class AndroidWebViewAudioBridge(
private val onAudioDeviceSelected: (String) -> Unit,
private val onAudioPlaybackStarted: () -> Unit,
) {
@JavascriptInterface
fun setOutputDevice(id: String) {
Timber.d("Audio device selected in webview, id: $id")
onAudioDeviceSelected(id)
}
@JavascriptInterface
fun onTrackReady() {
// This method can be used to notify the WebView that the audio track is ready
// It can be used to start playing audio or to update the UI
Timber.d("Audio track is ready")
onAudioPlaybackStarted()
}
}
private fun deviceName(type: Int, name: String): String {
// TODO maybe translate these?
val typePart = when (type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth"
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory"
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device"
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece"
else -> "Unknown"
}
return if (isBuiltIn(type)) {
typePart
} else {
"$typePart - $name"
}
}
private fun isBuiltIn(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_BUILTIN_MIC,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> true
else -> false
}
/**
* This class is used to serialize the audio device information to JSON.
*/
@Suppress("unused")
@Serializable
internal data class SerializableAudioDevice(
val id: String,
val name: String,
@Transient val type: Int = 0,
// These have to be part of the constructor for the JSON serializer to pick them up
val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
) {
companion object {
fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice {
return SerializableAudioDevice(
id = audioDeviceInfo.id.toString(),
name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()),
type = audioDeviceInfo.type,
)
}
}
}

View file

@ -26,6 +26,7 @@ import timber.log.Timber
class WebViewWidgetMessageInterceptor( class WebViewWidgetMessageInterceptor(
private val webView: WebView, private val webView: WebView,
private val onUrlLoaded: (String) -> Unit,
private val onError: (String?) -> Unit, private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor { ) : WidgetMessageInterceptor {
companion object { companion object {
@ -44,13 +45,13 @@ class WebViewWidgetMessageInterceptor(
.build() .build()
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
// Due to https://github.com/element-hq/element-x-android/issues/4097 // Due to https://github.com/element-hq/element-x-android/issues/4097
// we need to supply a logging implementation that correctly includes // we need to supply a logging implementation that correctly includes
// objects in log lines. // objects in log lines.
view?.evaluateJavascript( view.evaluateJavascript(
""" """
function logFn(consoleLogFn, ...args) { function logFn(consoleLogFn, ...args) {
consoleLogFn( consoleLogFn(
@ -72,7 +73,7 @@ class WebViewWidgetMessageInterceptor(
// This listener will receive both messages: // This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget") // - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these // - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
view?.evaluateJavascript( view.evaluateJavascript(
""" """
window.addEventListener('message', function(event) { window.addEventListener('message', function(event) {
let message = {data: event.data, origin: event.origin} let message = {data: event.data, origin: event.origin}
@ -90,6 +91,10 @@ class WebViewWidgetMessageInterceptor(
) )
} }
override fun onPageFinished(view: WebView, url: String) {
onUrlLoaded(url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error // No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}") Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")

View file

@ -8,13 +8,14 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.data.WidgetMessage import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
object WidgetMessageSerializer { object WidgetMessageSerializer {
private val coder = Json { ignoreUnknownKeys = true } private val coder = Json { ignoreUnknownKeys = true }
fun deserialize(message: String): Result<WidgetMessage> { fun deserialize(message: String): Result<WidgetMessage> {
return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) } return runCatchingExceptions { coder.decodeFromString(WidgetMessage.serializer(), message) }
} }
fun serialize(message: WidgetMessage): String { fun serialize(message: WidgetMessage): String {

View file

@ -35,7 +35,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
} }
), ),
aCreateRoomRootState( aCreateRoomRootState(
startDmAction = AsyncAction.Failure(Throwable("error")), startDmAction = AsyncAction.Failure(RuntimeException("error")),
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
searchQuery = it.userId.value, searchQuery = it.userId.value,

View file

@ -14,8 +14,8 @@ import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUse
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -27,7 +27,7 @@ class DefaultStartDMActionTest {
@Test @Test
fun `when dm is found, assert state is updated with given room id`() = runTest { fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(A_ROOM_ID) givenFindDmResult(Result.success(A_ROOM_ID))
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService) val action = createStartDMAction(matrixClient, analyticsService)
@ -37,10 +37,23 @@ class DefaultStartDMActionTest {
assertThat(analyticsService.capturedEvents).isEmpty() assertThat(analyticsService.capturedEvents).isEmpty()
} }
@Test
fun `when finding the dm fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(Result.failure(AN_EXCEPTION))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
assertThat(analyticsService.capturedEvents).isEmpty()
}
@Test @Test
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest { fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null) givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID)) givenCreateDmResult(Result.success(A_ROOM_ID))
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
@ -54,7 +67,7 @@ class DefaultStartDMActionTest {
@Test @Test
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest { fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null) givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID)) givenCreateDmResult(Result.success(A_ROOM_ID))
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
@ -69,14 +82,14 @@ class DefaultStartDMActionTest {
@Test @Test
fun `when dm creation fails, assert state is updated with given error`() = runTest { fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null) givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.failure(A_THROWABLE)) givenCreateDmResult(Result.failure(AN_EXCEPTION))
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService) val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
action.execute(aMatrixUser(), true, state) action.execute(aMatrixUser(), true, state)
assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE)) assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
assertThat(analyticsService.capturedEvents).isEmpty() assertThat(analyticsService.capturedEvents).isEmpty()
} }

View file

@ -22,10 +22,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -274,7 +274,7 @@ class ConfigureBaseRoomPresenterTest {
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1) skipItems(1)
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE)) matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -298,7 +298,7 @@ class ConfigureBaseRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
val createRoomResult = Result.failure<RoomId>(A_THROWABLE) val createRoomResult = Result.failure<RoomId>(AN_EXCEPTION)
fakeMatrixClient.givenCreateRoomResult(createRoomResult) fakeMatrixClient.givenCreateRoomResult(createRoomResult)

View file

@ -17,6 +17,7 @@ import io.element.android.features.createroom.impl.R
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -56,7 +57,7 @@ class JoinBaseRoomByAddressViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
state: JoinRoomByAddressState, state: JoinRoomByAddressState,
) { ) {
setContent { setSafeContent {
JoinRoomByAddressView(state = state) JoinRoomByAddressView(state = state)
} }
} }

View file

@ -24,8 +24,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -42,7 +42,7 @@ class CreateBaseRoomRootPresenterTest {
@Test @Test
fun `present - start DM action failure scenario`() = runTest { fun `present - start DM action failure scenario`() = runTest {
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState -> val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMFailureResult actionState.value = startDMFailureResult
} }

View file

@ -34,10 +34,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -54,7 +56,6 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.list.SwitchListItem import io.element.android.libraries.designsystem.components.list.SwitchListItem
import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -276,14 +277,9 @@ private fun Content(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Password), contentType = ContentType.Password
onFill = { },
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(AccountDeactivationEvents.SetPassword(sanitized))
}
),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
passwordFieldState = sanitized passwordFieldState = sanitized

View file

@ -16,6 +16,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -27,6 +28,7 @@ import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor( class NotificationsOptInPresenter @AssistedInject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback, @Assisted private val callback: NotificationsOptInNode.Callback,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,

View file

@ -29,10 +29,10 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
), ),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
acceptAction = AsyncAction.Failure(Throwable("Error while accepting invite")), acceptAction = AsyncAction.Failure(RuntimeException("Error while accepting invite")),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
declineAction = AsyncAction.Failure(Throwable("Error while declining invite")), declineAction = AsyncAction.Failure(RuntimeException("Error while declining invite")),
), ),
) )
} }

View file

@ -69,7 +69,7 @@ class DefaultAcceptInviteTest {
fun `accept invite failure scenario`() = runTest { fun `accept invite failure scenario`() = runTest {
val joinRoomLambda = val joinRoomLambda =
lambdaRecorder<RoomIdOrAlias, List<String>, JoinedRoom.Trigger, Result<Unit>> { _, _, _ -> lambdaRecorder<RoomIdOrAlias, List<String>, JoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
Result.failure(Throwable("Join room failed")) Result.failure(RuntimeException("Join room failed"))
} }
val acceptInvite = DefaultAcceptInvite( val acceptInvite = DefaultAcceptInvite(

View file

@ -39,10 +39,8 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ErrorKind import io.element.android.libraries.matrix.api.exception.ErrorKind
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -88,7 +86,7 @@ class JoinRoomPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) } var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by remember { val roomInfo by remember {
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()) matrixClient.getRoomInfoFlow(roomId)
}.collectAsState(initial = Optional.empty()) }.collectAsState(initial = Optional.empty())
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }

View file

@ -45,10 +45,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomPreview import io.element.android.libraries.matrix.test.room.aRoomPreview
import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -88,12 +88,12 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is joined then content state is filled with his data`() = runTest { fun `present - when room is joined then content state is filled with his data`() = runTest {
val roomSummary = aRoomSummary() val roomInfo = aRoomInfo()
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
@ -104,24 +104,24 @@ class JoinRoomPresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
val contentState = state.contentState as ContentState.Loaded val contentState = state.contentState as ContentState.Loaded
assertThat(contentState.roomId).isEqualTo(A_ROOM_ID) assertThat(contentState.roomId).isEqualTo(A_ROOM_ID)
assertThat(contentState.name).isEqualTo(roomSummary.info.name) assertThat(contentState.name).isEqualTo(roomInfo.name)
assertThat(contentState.topic).isEqualTo(roomSummary.info.topic) assertThat(contentState.topic).isEqualTo(roomInfo.topic)
assertThat(contentState.alias).isEqualTo(roomSummary.info.canonicalAlias) assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomSummary.info.joinedMembersCount) assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.joinedMembersCount)
assertThat(contentState.isDm).isEqualTo(roomSummary.info.isDirect) assertThat(contentState.isDm).isEqualTo(roomInfo.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomSummary.info.avatarUrl) assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
} }
} }
} }
@Test @Test
fun `present - when room is invited then join authorization is equal to invited`() = runTest { fun `present - when room is invited then join authorization is equal to invited`() = runTest {
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED) val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val seenInvitesStore = InMemorySeenInvitesStore() val seenInvitesStore = InMemorySeenInvitesStore()
@ -129,7 +129,7 @@ class JoinRoomPresenterTest {
matrixClient = matrixClient, matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
) )
val inviteData = roomSummary.info.toInviteData() val inviteData = roomInfo.toInviteData()
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test { presenter.test {
skipItems(2) skipItems(2)
@ -137,7 +137,7 @@ class JoinRoomPresenterTest {
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null)) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null))
} }
// Check that the roomId is stored in the seen invites store // Check that the roomId is stored in the seen invites store
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId) assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomInfo.id)
} }
} }
@ -145,17 +145,17 @@ class JoinRoomPresenterTest {
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest { fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob") val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
val expectedInviteSender = inviter.toInviteSender() val expectedInviteSender = inviter.toInviteSender()
val roomSummary = aRoomSummary( val roomInfo = aRoomInfo(
currentUserMembership = CurrentUserMembership.INVITED, currentUserMembership = CurrentUserMembership.INVITED,
joinedMembersCount = 5, joinedMembersCount = 5,
inviter = inviter, inviter = inviter,
) )
val inviteData = roomSummary.info.toInviteData() val inviteData = roomInfo.toInviteData()
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
@ -172,7 +172,7 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is invited read the number of member from the room preview`() = runTest { fun `present - when room is invited read the number of member from the room preview`() = runTest {
val roomSummary = aRoomSummary( val roomInfo = aRoomInfo(
currentUserMembership = CurrentUserMembership.INVITED, currentUserMembership = CurrentUserMembership.INVITED,
// It seems that the SDK does not provide this value. // It seems that the SDK does not provide this value.
joinedMembersCount = 0, joinedMembersCount = 0,
@ -188,8 +188,8 @@ class JoinRoomPresenterTest {
) )
}, },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
@ -209,13 +209,13 @@ class JoinRoomPresenterTest {
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED) val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val inviteData = roomSummary.info.toInviteData() val inviteData = roomInfo.toInviteData()
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter acceptDeclineInvitePresenter = acceptDeclinePresenter
@ -324,7 +324,7 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest { fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest {
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public) val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> getNotJoinedRoomResult = { _, _ ->
Result.success( Result.success(
@ -346,8 +346,8 @@ class JoinRoomPresenterTest {
) )
} }
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
@ -369,12 +369,12 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest { fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest {
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public) val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
@ -390,12 +390,12 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest { fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest {
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null) val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply { ).apply {
getRoomSummaryFlowLambda = { _ -> getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomInfo))
} }
} }
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(

View file

@ -18,6 +18,7 @@ import io.element.android.features.knockrequests.impl.data.KnockRequestPresentab
import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -28,7 +29,8 @@ private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
class KnockRequestsBannerPresenter @Inject constructor( class KnockRequestsBannerPresenter @Inject constructor(
private val knockRequestsService: KnockRequestsService, private val knockRequestsService: KnockRequestsService,
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<KnockRequestsBannerState> { ) : Presenter<KnockRequestsBannerState> {
@Composable @Composable
override fun present(): KnockRequestsBannerState { override fun present(): KnockRequestsBannerState {
@ -52,13 +54,13 @@ class KnockRequestsBannerPresenter @Inject constructor(
fun handleEvents(event: KnockRequestsBannerEvents) { fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) { when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> { is KnockRequestsBannerEvents.AcceptSingleRequest -> {
appCoroutineScope.acceptSingleKnockRequest( sessionCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests, knockRequests = knockRequests,
displayAcceptError = showAcceptError, displayAcceptError = showAcceptError,
) )
} }
is KnockRequestsBannerEvents.Dismiss -> { is KnockRequestsBannerEvents.Dismiss -> {
appCoroutineScope.launch { sessionCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen() knockRequestsService.markAllKnockRequestsAsSeen()
} }
} }

View file

@ -89,7 +89,7 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
canBan = true, canBan = true,
), ),
currentAction = KnockRequestsAction.AcceptAll, currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(

View file

@ -238,6 +238,6 @@ private fun TestScope.createKnockRequestsBannerPresenter(
) )
return KnockRequestsBannerPresenter( return KnockRequestsBannerPresenter(
knockRequestsService = knockRequestsService, knockRequestsService = knockRequestsService,
appCoroutineScope = this, sessionCoroutineScope = this,
) )
} }

View file

@ -108,7 +108,7 @@ class KnockRequestsListViewTest {
rule.setKnockRequestsListView( rule.setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll, currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
@ -124,7 +124,7 @@ class KnockRequestsListViewTest {
rule.setKnockRequestsListView( rule.setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll, currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),

View file

@ -17,6 +17,7 @@ import io.element.android.features.licenses.impl.LicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import javax.inject.Inject import javax.inject.Inject
@ -34,7 +35,7 @@ class DependencyLicensesListPresenter @Inject constructor(
} }
var filter by remember { mutableStateOf("") } var filter by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { runCatchingExceptions {
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList()) licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
}.onFailure { }.onFailure {
licenses = AsyncData.Failure(it) licenses = AsyncData.Failure(it)

View file

@ -15,6 +15,7 @@ import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber import timber.log.Timber
@ -26,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : LocationActions { ) : LocationActions {
override fun share(location: Location, label: String?) { override fun share(location: Location, label: String?) {
runCatching { runCatchingExceptions {
val uri = buildUrl(location, label).toUri() val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri) val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null) val chooserIntent = Intent.createChooser(showMapsIntent, null)

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -46,8 +48,6 @@ import io.element.android.libraries.designsystem.theme.components.FloatingAction
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason

View file

@ -17,6 +17,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionListener
@ -42,6 +43,7 @@ class DefaultLockScreenService @Inject constructor(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val lockScreenStore: LockScreenStore, private val lockScreenStore: LockScreenStore,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
@AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,

View file

@ -13,6 +13,7 @@ import androidx.biometric.BiometricPrompt.CryptoObject
import androidx.biometric.BiometricPrompt.PromptInfo import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -119,7 +120,7 @@ private class AuthenticationCallback(
private fun Cipher?.isValid(): Boolean { private fun Cipher?.isValid(): Boolean {
if (this == null) return false if (this == null) return false
return runCatching { return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray()) doFinal("biometric_challenge".toByteArray())
}.isSuccess }.isSuccess
} }

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -47,6 +48,7 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
private val encryptionDecryptionService: EncryptionDecryptionService, private val encryptionDecryptionService: EncryptionDecryptionService,
private val secretKeyRepository: SecretKeyRepository, private val secretKeyRepository: SecretKeyRepository,
@AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
) : BiometricAuthenticatorManager { ) : BiometricAuthenticatorManager {
private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>() private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>()

View file

@ -20,6 +20,7 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricAuthentica
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -29,6 +30,7 @@ class LockScreenSettingsPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore, private val lockScreenStore: LockScreenStore,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager, private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
@AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> { ) : Presenter<LockScreenSettingsState> {
@Composable @Composable

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -34,6 +35,7 @@ class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager, private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase, private val logoutUseCase: LogoutUseCase,
@AppCoroutineScope
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper, private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {

View file

@ -36,6 +36,7 @@ import io.element.android.features.login.impl.screens.createaccount.CreateAccoun
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -45,7 +46,6 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.api.OidcEntryPoint
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -57,7 +57,6 @@ class LoginFlowNode @AssistedInject constructor(
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory, private val defaultLoginUserStory: DefaultLoginUserStory,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val oidcEntryPoint: OidcEntryPoint,
) : BaseFlowNode<LoginFlowNode.NavTarget>( ) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.OnBoarding, initialElement = NavTarget.OnBoarding,
@ -74,15 +73,15 @@ class LoginFlowNode @AssistedInject constructor(
private var activity: Activity? = null private var activity: Activity? = null
private var darkTheme: Boolean = false private var darkTheme: Boolean = false
private var customChromeTabStarted = false private var externalAppStarted = false
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false) defaultLoginUserStory.setLoginFlowIsDone(false)
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
if (customChromeTabStarted) { if (externalAppStarted) {
customChromeTabStarted = false externalAppStarted = false
// Workaround to detect that the Custom Chrome Tab has been closed // Workaround to detect that the Custom Chrome Tab has been closed
// If there is no coming OidcAction (that would end this Node), // If there is no coming OidcAction (that would end this Node),
// consider that the user has cancelled the login // consider that the user has cancelled the login
@ -122,9 +121,6 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class CreateAccount(val url: String) : NavTarget data class CreateAccount(val url: String) : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -249,9 +245,6 @@ class LoginFlowNode @AssistedInject constructor(
NavTarget.LoginPassword -> { NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext) createNode<LoginPasswordNode>(buildContext)
} }
is NavTarget.OidcView -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.CreateAccount -> { is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs( val inputs = CreateAccountNode.Inputs(
url = navTarget.url, url = navTarget.url,
@ -262,15 +255,9 @@ class LoginFlowNode @AssistedInject constructor(
} }
private fun navigateToMas(oidcDetails: OidcDetails) { private fun navigateToMas(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) { activity?.let {
// In this case open a Chrome Custom tab externalAppStarted = true
activity?.let { it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url)
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
} }
} }

View file

@ -81,6 +81,8 @@ fun LoginModeView(
LoginMode.PasswordLogin -> onNeedLoginPassword() LoginMode.PasswordLogin -> onNeedLoginPassword()
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url) is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
} }
// Also clear the data, to let the next screen be able to go back
onClearError()
} }
AsyncData.Uninitialized -> Unit AsyncData.Uninitialized -> Unit
} }

View file

@ -88,7 +88,7 @@ fun ConfirmAccountProviderView(
TextButton( TextButton(
text = stringResource(id = R.string.screen_account_provider_change), text = stringResource(id = R.string.screen_account_provider_change),
onClick = onChange, onClick = onChange,
enabled = true, enabled = !isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.loginChangeServer) .testTag(TestTags.loginChangeServer)

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@ -74,7 +75,7 @@ class CreateAccountPresenter @AssistedInject constructor(
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch { private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
loggedInState.value = AsyncAction.Loading loggedInState.value = AsyncAction.Loading
runCatching { runCatchingExceptions {
messageParser.parse(message) messageParser.parse(message)
}.flatMap { externalSession -> }.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession) authenticationService.importCreatedSession(externalSession)

View file

@ -17,7 +17,7 @@ open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountSt
aCreateAccountState(), aCreateAccountState(),
aCreateAccountState(pageProgress = 33), aCreateAccountState(pageProgress = 33),
aCreateAccountState(createAction = AsyncAction.Loading), aCreateAccountState(createAction = AsyncAction.Loading),
aCreateAccountState(createAction = AsyncAction.Failure(Throwable("Failed to create account"))), aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))),
) )
} }

View file

@ -7,6 +7,7 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,10 +31,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -51,7 +55,6 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -71,6 +74,13 @@ fun LoginPasswordView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val autofillManager = LocalAutofillManager.current
BackHandler {
autofillManager?.cancel()
onBackClick()
}
val isLoading by remember(state.loginAction) { val isLoading by remember(state.loginAction) {
derivedStateOf { derivedStateOf {
state.loginAction is AsyncData.Loading state.loginAction is AsyncData.Loading
@ -82,6 +92,8 @@ fun LoginPasswordView(
// Clear focus to prevent keyboard issues with textfields // Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
autofillManager?.commit()
state.eventSink(LoginPasswordEvents.Submit) state.eventSink(LoginPasswordEvents.Submit)
} }
@ -90,7 +102,12 @@ fun LoginPasswordView(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = {
BackButton(onClick = {
autofillManager?.cancel()
onBackClick()
})
},
) )
} }
) { padding -> ) { padding ->
@ -175,14 +192,9 @@ private fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername) .testTag(TestTags.loginEmailUsername)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Username), contentType = ContentType.Username
onFill = { },
val sanitized = it.sanitize()
loginFieldState = sanitized
eventSink(LoginPasswordEvents.SetLogin(sanitized))
}
),
placeholder = stringResource(CommonStrings.common_username), placeholder = stringResource(CommonStrings.common_username),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
@ -227,14 +239,9 @@ private fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Password), contentType = ContentType.Password
onFill = { },
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(LoginPasswordEvents.SetPassword(sanitized))
}
),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
passwordFieldState = sanitized passwordFieldState = sanitized

View file

@ -17,7 +17,7 @@ class ErrorFormatterTest {
// region loginError // region loginError
@Test @Test
fun `loginError - invalid unknown error returns unknown error message`() { fun `loginError - invalid unknown error returns unknown error message`() {
val error = Throwable("Some unknown error") val error = RuntimeException("Some unknown error")
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown) assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
} }

View file

@ -17,7 +17,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -113,7 +113,7 @@ class ChooseAccountProviderPresenterTest {
} }
awaitItem().also { awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1) assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
authenticationService.givenChangeServerError(A_THROWABLE) authenticationService.givenChangeServerError(AN_EXCEPTION)
it.eventSink(ChooseAccountProviderEvents.Continue) it.eventSink(ChooseAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading

View file

@ -22,9 +22,9 @@ import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticat
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
@ -118,7 +118,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenOidcCancelError(A_THROWABLE) authenticationService.givenOidcCancelError(AN_EXCEPTION)
defaultOidcActionFlow.post(OidcAction.GoBack) defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFailureState = awaitItem() val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java) assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
@ -173,7 +173,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(successState.submitEnabled).isFalse() assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenLoginError(A_THROWABLE) authenticationService.givenLoginError(AN_EXCEPTION)
defaultOidcActionFlow.post(OidcAction.Success("aUrl")) defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val cancelLoadingState = awaitItem() val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
@ -225,7 +225,7 @@ class ConfirmAccountProviderPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
authenticationService.givenChangeServerError(Throwable()) authenticationService.givenChangeServerError(RuntimeException())
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading
val failureState = awaitItem() val failureState = awaitItem()
@ -246,7 +246,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
// Submit will return an error // Submit will return an error
authenticationService.givenChangeServerError(A_THROWABLE) authenticationService.givenChangeServerError(AN_EXCEPTION)
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading

View file

@ -16,11 +16,14 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID 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.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -89,12 +92,16 @@ class CreateAccountPresenterTest {
defaultLoginUserStory.setLoginFlowIsDone(false) defaultLoginUserStory.setLoginFlowIsDone(false)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse() assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() } val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
val sessionVerificationService = FakeSessionVerificationService()
val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService)
val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val presenter = createPresenter( val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService( authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.success(A_SESSION_ID) } importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
), ),
messageParser = FakeMessageParser(lambda), messageParser = FakeMessageParser(lambda),
defaultLoginUserStory = defaultLoginUserStory, defaultLoginUserStory = defaultLoginUserStory,
clientProvider = clientProvider,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -102,6 +109,7 @@ class CreateAccountPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage")) initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
assertThat(awaitItem().createAction.isLoading()).isTrue() assertThat(awaitItem().createAction.isLoading()).isTrue()
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID) assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
} }
lambda.assertions().isCalledOnce().with(value("aMessage")) lambda.assertions().isCalledOnce().with(value("aMessage"))

View file

@ -14,10 +14,10 @@ import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -96,12 +96,12 @@ class LoginPasswordPresenterTest {
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
skipItems(1) skipItems(1)
val loginAndPasswordState = awaitItem() val loginAndPasswordState = awaitItem()
authenticationService.givenLoginError(A_THROWABLE) authenticationService.givenLoginError(AN_EXCEPTION)
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
val submitState = awaitItem() val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
val loggedInState = awaitItem() val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE)) assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(AN_EXCEPTION))
} }
} }
@ -117,13 +117,13 @@ class LoginPasswordPresenterTest {
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
skipItems(1) skipItems(1)
val loginAndPasswordState = awaitItem() val loginAndPasswordState = awaitItem()
authenticationService.givenLoginError(A_THROWABLE) authenticationService.givenLoginError(AN_EXCEPTION)
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
val submitState = awaitItem() val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
val loggedInState = awaitItem() val loggedInState = awaitItem()
// Check an error was returned // Check an error was returned
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE)) assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(AN_EXCEPTION))
// Assert the error is then cleared // Assert the error is then cleared
loggedInState.eventSink(LoginPasswordEvents.ClearError) loggedInState.eventSink(LoginPasswordEvents.ClearError)
val clearedState = awaitItem() val clearedState = awaitItem()

View file

@ -8,17 +8,21 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@ -120,15 +124,15 @@ class LoginPasswordViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
// Show password // Show password
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password) val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
rule.onNodeWithContentDescription(a11yShowPassword).performClick() rule.onNodeWithContentDescription(a11yShowPassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertExists() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
// Hide password // Hide password
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password) val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
rule.onNodeWithContentDescription(a11yHidePassword).performClick() rule.onNodeWithContentDescription(a11yHidePassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
} }
@Test @Test

View file

@ -24,9 +24,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
@ -192,7 +192,7 @@ class OnBoardingPresenterTest {
skipItems(3) skipItems(3)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE) authenticationService.givenChangeServerError(AN_EXCEPTION)
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL)) it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
skipItems(1) // Loading skipItems(1) // Loading

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@ -36,9 +37,13 @@ class OnboardingViewTest {
@Test @Test
fun `when can create account - clicking on create account calls the expected callback`() { fun `when can create account - clicking on create account calls the expected callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState(canCreateAccount = true), state = anOnBoardingState(
canCreateAccount = true,
eventSink = eventSink,
),
onCreateAccount = callback, onCreateAccount = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_up) rule.clickOn(R.string.screen_onboarding_sign_up)
@ -47,9 +52,13 @@ class OnboardingViewTest {
@Test @Test
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() { fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true), state = anOnBoardingState(
canLoginWithQrCode = true,
eventSink = eventSink,
),
onSignInWithQrCode = callback, onSignInWithQrCode = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code) rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
@ -73,11 +82,13 @@ class OnboardingViewTest {
private fun `when can login with QR code - clicking on sign in manually calls the expected callback`( private fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider: Boolean, mustChooseAccountProvider: Boolean,
) { ) {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = true, canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,
eventSink = eventSink,
), ),
onSignIn = callback, onSignIn = callback,
) )
@ -102,12 +113,14 @@ class OnboardingViewTest {
private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider: Boolean, mustChooseAccountProvider: Boolean,
) { ) {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = false, canLoginWithQrCode = false,
canCreateAccount = false, canCreateAccount = false,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,
eventSink = eventSink,
), ),
onSignIn = callback, onSignIn = callback,
) )
@ -145,10 +158,12 @@ class OnboardingViewTest {
@Test @Test
fun `clicking on report a problem calls the sign in callback`() { fun `clicking on report a problem calls the sign in callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canReportBug = true, canReportBug = true,
eventSink = eventSink,
), ),
onReportProblem = callback, onReportProblem = callback,
) )
@ -160,15 +175,64 @@ class OnboardingViewTest {
@Test @Test
fun `cannot report a problem when the feature is disabled`() { fun `cannot report a problem when the feature is disabled`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canReportBug = false, canReportBug = false,
eventSink = eventSink,
), ),
) )
val text = rule.activity.getString(CommonStrings.common_report_a_problem) val text = rule.activity.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertDoesNotExist() rule.onNodeWithText(text).assertDoesNotExist()
} }
@Test
fun `when success PasswordLogin - the expected callback is invoked and the event is received`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
eventSink = eventSink,
),
onNeedLoginPassword = callback,
)
}
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test
fun `when success Oidc - the expected callback is invoked and the event is received`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
val oidcDetails = OidcDetails("aUrl")
ensureCalledOnceWithParam(oidcDetails) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.Oidc(oidcDetails)),
eventSink = eventSink,
),
onOidcDetails = callback,
)
}
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test
fun `when success AccountCreation - the expected callback is invoked and the event is received`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
val oidcDetails = OidcDetails("aUrl")
ensureCalledOnceWithParam(oidcDetails.url) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
eventSink = eventSink,
),
onCreateAccountContinue = callback,
)
}
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState, state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),

View file

@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -165,7 +165,7 @@ class LogoutPresenterTest {
fun `present - logout with error then cancel`() = runTest { fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _, _ -> logoutLambda = { _, _ ->
throw A_THROWABLE throw AN_EXCEPTION
} }
} }
val presenter = createLogoutPresenter( val presenter = createLogoutPresenter(
@ -182,7 +182,7 @@ class LogoutPresenterTest {
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(LogoutEvents.CloseDialogs) errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -194,9 +194,7 @@ class LogoutPresenterTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError, _ -> logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) { if (!ignoreSdkError) {
throw A_THROWABLE throw AN_EXCEPTION
} else {
null
} }
} }
} }
@ -214,7 +212,7 @@ class LogoutPresenterTest {
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true)) errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true))
val loadingState2 = awaitItem() val loadingState2 = awaitItem()
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)

View file

@ -17,7 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -117,7 +117,7 @@ class DirectLogoutPresenterTest {
fun `present - logout with error then cancel`() = runTest { fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _, _ -> logoutLambda = { _, _ ->
throw A_THROWABLE throw AN_EXCEPTION
} }
} }
val presenter = createDirectLogoutPresenter( val presenter = createDirectLogoutPresenter(
@ -134,7 +134,7 @@ class DirectLogoutPresenterTest {
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -146,9 +146,7 @@ class DirectLogoutPresenterTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError, _ -> logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) { if (!ignoreSdkError) {
throw A_THROWABLE throw AN_EXCEPTION
} else {
null
} }
} }
} }
@ -166,7 +164,7 @@ class DirectLogoutPresenterTest {
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true)) errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true))
val loadingState2 = awaitItem() val loadingState2 = awaitItem()
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)

View file

@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -29,10 +31,8 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min import androidx.compose.ui.unit.min
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@ -139,8 +139,8 @@ internal fun ExpandableBottomSheetScaffold(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
measurePolicy = { measurables, constraints -> measurePolicy = { measurables, constraints ->
val constraintHeight = constraints.maxHeight val constraintHeight = constraints.maxHeight
val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0 val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
val height = Integer.max(0, constraintHeight - offset) val height = Integer.max(0, constraintHeight - offset.roundToInt())
val top = measurables[0].measure( val top = measurables[0].measure(
constraints.copy( constraints.copy(
minHeight = height, minHeight = height,
@ -165,12 +165,6 @@ internal fun ExpandableBottomSheetScaffold(
) )
} }
private fun CustomSheetState.getIntOffset(): Int? = try {
requireOffset().roundToInt()
} catch (e: IllegalStateException) {
null
}
private sealed interface Slot { private sealed interface Slot {
data class SheetContent(val key: Int?) : Slot data class SheetContent(val key: Int?) : Slot
data object DragHandle : Slot data object DragHandle : Slot

View file

@ -50,6 +50,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -69,7 +70,8 @@ import kotlinx.coroutines.launch
class MessagesNode @AssistedInject constructor( class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val coroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: BaseRoom, private val room: BaseRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory, messageComposerPresenterFactory: MessageComposerPresenter.Factory,
@ -115,7 +117,7 @@ class MessagesNode @AssistedInject constructor(
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
coroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
}, },
onDestroy = { onDestroy = {
mediaPlayer.close() mediaPlayer.close()

View file

@ -56,6 +56,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -387,7 +388,7 @@ class MessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) { private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
inviteProgress.value = AsyncData.Loading() inviteProgress.value = AsyncData.Loading()
runCatching { runCatchingExceptions {
val memberList = when (val memberState = room.membersStateFlow.value) { val memberList = when (val memberState = room.membersStateFlow.value) {
is RoomMembersState.Ready -> memberState.roomMembers is RoomMembersState.Ready -> memberState.roomMembers
is RoomMembersState.Error -> memberState.prevRoomMembers.orEmpty() is RoomMembersState.Error -> memberState.prevRoomMembers.orEmpty()

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.firstInstanceOf import io.element.android.libraries.core.coroutine.firstInstanceOf
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
@ -240,7 +241,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean, dismissAfterSend: Boolean,
replyParameters: ReplyParameters?, replyParameters: ReplyParameters?,
) = runCatching { ) = runCatchingExceptions {
val context = coroutineContext val context = coroutineContext
val progressCallback = object : ProgressCallback { val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) { override fun onProgress(current: Long, total: Long) {

View file

@ -15,7 +15,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.observeRoomMemberIdentityStateChange import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -30,7 +30,7 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState { override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) { val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room) room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it }
} }
fun handleEvent(event: IdentityChangeEvent) { fun handleEvent(event: IdentityChangeEvent) {

View file

@ -16,6 +16,7 @@ import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
@ -28,7 +29,8 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor( class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String, @Assisted eventId: String,
@Assisted private val timelineProvider: TimelineProvider, @Assisted private val timelineProvider: TimelineProvider,
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> { ) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId) private val eventId: EventId = EventId(eventId)
@ -40,7 +42,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
appCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState) sessionCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
} }
@Composable @Composable

View file

@ -24,7 +24,7 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
) )
), ),
aForwardMessagesState( aForwardMessagesState(
forwardAction = AsyncAction.Failure(Throwable("error")), forwardAction = AsyncAction.Failure(RuntimeException("error")),
), ),
) )
} }

View file

@ -24,7 +24,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -41,8 +40,11 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Sug
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -97,7 +99,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter @AssistedInject constructor( class MessageComposerPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom, private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
@ -165,13 +168,13 @@ class MessageComposerPresenter @AssistedInject constructor(
handlePickedMedia(uri, mimeType) handlePickedMedia(uri, mimeType)
} }
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri -> val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
handlePickedMedia(uri) handlePickedMedia(uri, MimeTypes.OctetStream)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.IMAGE_JPEG) handlePickedMedia(uri, MimeTypes.Jpeg)
} }
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(uri, MimeTypes.VIDEO_MP4) handlePickedMedia(uri, MimeTypes.Mp4)
} }
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -199,7 +202,7 @@ class MessageComposerPresenter @AssistedInject constructor(
DisposableEffect(Unit) { DisposableEffect(Unit) {
// Declare that the user is not typing anymore when the composer is disposed // Declare that the user is not typing anymore when the composer is disposed
onDispose { onDispose {
appCoroutineScope.launch { sessionCoroutineScope.launch {
if (sendTypingNotifications) { if (sendTypingNotifications) {
room.typingNotice(false) room.typingNotice(false)
} }
@ -235,12 +238,12 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
} }
is MessageComposerEvents.SendMessage -> { is MessageComposerEvents.SendMessage -> {
appCoroutineScope.sendMessage( sessionCoroutineScope.sendMessage(
markdownTextEditorState = markdownTextEditorState, markdownTextEditorState = markdownTextEditorState,
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
) )
} }
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment( is MessageComposerEvents.SendUri -> sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri( localMedia = localMediaFactory.createFromUri(
uri = event.uri, uri = event.uri,
@ -337,7 +340,7 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
MessageComposerEvents.SaveDraft -> { MessageComposerEvents.SaveDraft -> {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
appCoroutineScope.updateDraft(draft, isVolatile = false) sessionCoroutineScope.updateDraft(draft, isVolatile = false)
} }
} }
} }
@ -512,7 +515,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private suspend fun sendMedia( private suspend fun sendMedia(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
) = runCatching { ) = runCatchingExceptions {
mediaSender.sendMedia( mediaSender.sendMedia(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
@ -67,7 +68,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val linkPresenter: Presenter<LinkState>, private val linkPresenter: Presenter<LinkState>,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter<ActionListState>, @Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<PinnedMessagesListState> { ) : Presenter<PinnedMessagesListState> {
@AssistedFactory @AssistedFactory
@ -123,7 +125,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
fun handleEvents(event: PinnedMessagesListEvents) { fun handleEvents(event: PinnedMessagesListEvents) {
when (event) { when (event) {
is PinnedMessagesListEvents.HandleAction -> appCoroutineScope.handleTimelineAction(event.action, event.event) is PinnedMessagesListEvents.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
} }
} }

View file

@ -17,7 +17,7 @@ open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageSt
aReportMessageState(reason = "This user is making the chat very toxic."), aReportMessageState(reason = "This user is making the chat very toxic."),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true), aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Loading), aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Loading),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(Throwable("error"))), aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(RuntimeException("error"))),
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)), aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)),
// Add other states here // Add other states here
) )

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
interface MarkAsFullyRead {
operator fun invoke(roomId: RoomId)
}
@ContributesBinding(SessionScope::class)
class DefaultMarkAsFullyRead @Inject constructor(
private val matrixClient: MatrixClient,
) : MarkAsFullyRead {
override fun invoke(roomId: RoomId) {
matrixClient.sessionCoroutineScope.launch {
matrixClient.getRoom(roomId)?.use { room ->
room.markAsRead(receiptType = ReceiptType.FULLY_READ)
.onFailure {
Timber.e("Failed to mark room $roomId as fully read", it)
}
}
}
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -37,6 +38,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -65,7 +67,8 @@ class TimelinePresenter @AssistedInject constructor(
timelineItemsFactoryCreator: TimelineItemsFactory.Creator, timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val room: JoinedRoom, private val room: JoinedRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
@ -76,6 +79,7 @@ class TimelinePresenter @AssistedInject constructor(
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>, private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -133,7 +137,7 @@ class TimelinePresenter @AssistedInject constructor(
newEventState.value = NewEventState.None newEventState.value = NewEventState.None
} }
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}") Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
appScope.sendReadReceiptIfNeeded( sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems, timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId, lastReadReceiptId = lastReadReceiptId,
@ -143,13 +147,13 @@ class TimelinePresenter @AssistedInject constructor(
newEventState.value = NewEventState.None newEventState.value = NewEventState.None
} }
} }
is TimelineEvents.SelectPollAnswer -> appScope.launch { is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute( sendPollResponseAction.execute(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answerId = event.answerId answerId = event.answerId
) )
} }
is TimelineEvents.EndPoll -> appScope.launch { is TimelineEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute( endPollAction.execute(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
) )
@ -177,6 +181,12 @@ class TimelinePresenter @AssistedInject constructor(
} }
} }
DisposableEffect(Unit) {
onDispose {
markAsFullyRead(room.roomId)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems timelineItemsFactory.timelineItems
.onEach { newTimelineItems -> .onEach { newTimelineItems ->

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
@ -112,7 +113,9 @@ fun MessageEventBubble(
state.isMine -> ElementTheme.colors.messageFromMeBackground state.isMine -> ElementTheme.colors.messageFromMeBackground
else -> ElementTheme.colors.messageFromOtherBackground else -> ElementTheme.colors.messageFromOtherBackground
} }
val bubbleShape = bubbleShape() // If we're running in UI test mode, we want to use a different shape to avoid
// this issue: https://issuetracker.google.com/issues/366255137
val bubbleShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -80,7 +80,7 @@ fun TimelineEventTimestampView(
.clickable(isVerifiedUserSendFailure) { .clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
} }
.semantics { invisibleToUser() } .semantics { hideFromAccessibility() }
) )
} }
@ -95,7 +95,7 @@ fun TimelineEventTimestampView(
.clickable { .clickable {
eventSink(TimelineEvents.ShowShieldDialog(shield)) eventSink(TimelineEvents.ShowShieldDialog(shield))
} }
.semantics { invisibleToUser() }, .semantics { hideFromAccessibility() },
tint = shield.toIconColor(), tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View file

@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.semantics.traversalIndex
@ -439,7 +439,7 @@ private fun MessageSenderInformation(
// Add external clickable modifier with no indicator so the touch target is larger than just the display name // Add external clickable modifier with no indicator so the touch target is larger than just the display name
.clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null) .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
.clearAndSetSemantics { .clearAndSetSemantics {
invisibleToUser() hideFromAccessibility()
} }
) { ) {
Avatar( Avatar(

View file

@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -43,7 +43,7 @@ fun TimelineItemReactionsView(
var expanded: Boolean by rememberSaveable { mutableStateOf(false) } var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
TimelineItemReactionsView( TimelineItemReactionsView(
modifier = modifier.semantics { modifier = modifier.semantics {
invisibleToUser() hideFromAccessibility()
}, },
reactions = reactionsState.reactions, reactions = reactionsState.reactions,
userCanSendReaction = userCanSendReaction, userCanSendReaction = userCanSendReaction,

View file

@ -102,7 +102,7 @@ fun TimelineItemImageView(
} }
), ),
model = content.thumbnailMediaRequestData, model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Fit, contentScale = ContentScale.Crop,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { isLoaded = it is AsyncImagePainter.State.Success },

View file

@ -76,7 +76,7 @@ fun TimelineItemStickerView(
mimeType = content.mimeType, mimeType = content.mimeType,
), ),
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Crop,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { isLoaded = it is AsyncImagePainter.State.Success },

View file

@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -122,7 +122,7 @@ fun TimelineItemVideoView(
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
) )
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Crop,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { isLoaded = it is AsyncImagePainter.State.Success },
@ -136,7 +136,7 @@ fun TimelineItemVideoView(
imageVector = CompoundIcons.PlaySolid(), imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play), contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White), colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { invisibleToUser() } modifier = Modifier.semantics { hideFromAccessibility() }
) )
} }
} }

View file

@ -26,7 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -59,7 +59,7 @@ fun TimelineItemReadReceiptView(
if (renderReadReceipts) { if (renderReadReceipts) {
ReadReceiptsRow( ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics { modifier = modifier.clearAndSetSemantics {
invisibleToUser() hideFromAccessibility()
} }
) { ) {
ReadReceiptsAvatars( ReadReceiptsAvatars(

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.impl.voicemessages.composer
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -26,11 +27,12 @@ import javax.inject.Inject
* A media player for the voice message composer. * A media player for the voice message composer.
* *
* @param mediaPlayer The [MediaPlayer] to use. * @param mediaPlayer The [MediaPlayer] to use.
* @param coroutineScope * @param sessionCoroutineScope
*/ */
class VoiceMessageComposerPlayer @Inject constructor( class VoiceMessageComposerPlayer @Inject constructor(
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val coroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) { ) {
companion object { companion object {
const val MIME_TYPE = MimeTypes.Ogg const val MIME_TYPE = MimeTypes.Ogg
@ -116,7 +118,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
seekJob?.cancelAndJoin() seekJob?.cancelAndJoin()
seekingTo.value = position seekingTo.value = position
seekJob = coroutineScope.launch { seekJob = sessionCoroutineScope.launch {
val mediaState = mediaPlayer.ensureMediaReady(mediaPath) val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
val duration = mediaState.duration ?: return@launch val duration = mediaState.duration ?: return@launch
val positionMs = (duration * position).toLong() val positionMs = (duration * position).toLong()

View file

@ -22,6 +22,7 @@ import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -44,7 +45,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class VoiceMessageComposerPresenter @Inject constructor( class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope, @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
@ -74,11 +76,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
val onLifecycleEvent = { event: Lifecycle.Event -> val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) { when (event) {
Lifecycle.Event.ON_PAUSE -> { Lifecycle.Event.ON_PAUSE -> {
appCoroutineScope.finishRecording() sessionCoroutineScope.finishRecording()
player.pause() player.pause()
} }
Lifecycle.Event.ON_DESTROY -> { Lifecycle.Event.ON_DESTROY -> {
appCoroutineScope.cancelRecording() sessionCoroutineScope.cancelRecording()
} }
else -> {} else -> {}
} }
@ -145,7 +147,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
isSending = true isSending = true
player.pause() player.pause()
analyticsService.captureComposerEvent() analyticsService.captureComposerEvent()
appCoroutineScope.launch { sessionCoroutineScope.launch {
val result = sendMessage( val result = sendMessage(
file = finishedState.file, file = finishedState.file,
mimeType = finishedState.mimeType, mimeType = finishedState.mimeType,

View file

@ -62,11 +62,11 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
@ -139,10 +139,10 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
markAsReadResult = { lambdaError() }
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
assertThat(room.baseRoom.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(joinedRoom = room) val presenter = createMessagesPresenter(joinedRoom = room)
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
runCurrent() runCurrent()
@ -744,7 +744,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(Throwable("Oops!")) }, inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
) )
room.givenRoomMembersState( room.givenRoomMembersState(
RoomMembersState.Ready( RoomMembersState.Ready(
@ -848,12 +848,12 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest { fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) }, canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
val presenter = createMessagesPresenter(joinedRoom = joinedRoom) val presenter = createMessagesPresenter(joinedRoom = joinedRoom)
@ -892,17 +892,17 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle action pin`() = runTest { fun `present - handle action pin`() = runTest {
val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) } val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(AN_EXCEPTION) }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val timeline = FakeTimeline() val timeline = FakeTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -932,17 +932,17 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle action unpin`() = runTest { fun `present - handle action unpin`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) } val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(AN_EXCEPTION) }
val timeline = FakeTimeline() val timeline = FakeTimeline()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -1096,12 +1096,12 @@ class MessagesPresenterTest {
} }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -1134,16 +1134,16 @@ class MessagesPresenterTest {
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest { fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true) initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply { ).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2)))) givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
}, },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) }) val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })

View file

@ -68,6 +68,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -567,11 +568,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onJoinCallClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider( CompositionLocalProvider(LocalInspectionMode provides true) {
LocalInspectionMode provides true
) {
MessagesView( MessagesView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -54,7 +55,7 @@ class ResolveVerifiedUserSendFailureViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState, state: ResolveVerifiedUserSendFailureState,
) { ) {
setContent { setSafeContent {
ResolveVerifiedUserSendFailureView(state = state) ResolveVerifiedUserSendFailureView(state = state)
} }
} }

View file

@ -97,6 +97,6 @@ class ForwardMessagesPresenterTest {
) = ForwardMessagesPresenter( ) = ForwardMessagesPresenter(
eventId = eventId.value, eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeRoom), timelineProvider = LiveTimelineProvider(fakeRoom),
appCoroutineScope = this, sessionCoroutineScope = this,
) )
} }

View file

@ -1539,7 +1539,7 @@ class MessageComposerPresenterTest {
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
appCoroutineScope = this, sessionCoroutineScope = this,
room = room, room = room,
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -157,7 +157,7 @@ class PinnedMessagesListPresenterTest {
@Test @Test
fun `present - unpin event`() = runTest { fun `present - unpin event`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) } val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(AN_EXCEPTION) }
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
@ -337,7 +337,7 @@ class PinnedMessagesListPresenterTest {
actionListPresenter = { anActionListState() }, actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() }, linkPresenter = { aLinkState() },
analyticsService = analyticsService, analyticsService = analyticsService,
appCoroutineScope = this, sessionCoroutineScope = this,
) )
} }
} }

View file

@ -103,7 +103,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setSafeContent { setSafeContent(clearAndroidUiDispatcher = true) {
PinnedMessagesListView( PinnedMessagesListView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
@ -35,7 +36,7 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { fun `calling provide without calling Update first should throw an exception`() {
val exception = runCatching { provider.provide() }.exceptionOrNull() val exception = runCatchingExceptions { provider.provide() }.exceptionOrNull()
assertThat(exception).isInstanceOf(IllegalStateException::class.java) assertThat(exception).isInstanceOf(IllegalStateException::class.java)
} }
@ -47,7 +48,7 @@ class DefaultHtmlConverterProviderTest {
provider.Update() provider.Update()
} }
} }
val htmlConverter = runCatching { provider.provide() }.getOrNull() val htmlConverter = runCatchingExceptions { provider.provide() }.getOrNull()
assertThat(htmlConverter).isNotNull() assertThat(htmlConverter).isNotNull()
} }

View file

@ -0,0 +1,55 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultMarkAsFullyReadTest {
@Test
fun `When room is not found, then no exception is thrown`() = runTest {
val markAsFullyRead = DefaultMarkAsFullyRead(
FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, null)
}
)
markAsFullyRead.invoke(A_ROOM_ID)
runCurrent()
}
@Test
fun `When room is found, the expected method is invoked`() = runTest {
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val baseRoom = FakeBaseRoom(
markAsReadResult = markAsReadResult
)
val markAsFullyRead = DefaultMarkAsFullyRead(
FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, baseRoom)
}
)
markAsFullyRead.invoke(A_ROOM_ID)
runCurrent()
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.FULLY_READ))
baseRoom.assertDestroyed()
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMarkAsFullyRead(
private val invokeResult: (RoomId) -> Unit = { lambdaError() }
) : MarkAsFullyRead {
override fun invoke(roomId: RoomId) {
invokeResult(roomId)
}
}

View file

@ -29,6 +29,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -58,6 +60,7 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -76,7 +79,9 @@ import java.util.Date
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest { @Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class)
class TimelinePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -119,9 +124,26 @@ import kotlin.time.Duration.Companion.seconds
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) { fun `present - on scroll finished mark a room as read if the first visible index is 0 - read private`() {
`present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled = false,
expectedReceiptType = ReceiptType.READ_PRIVATE,
)
}
@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0 - read`() {
`present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled = true,
expectedReceiptType = ReceiptType.READ,
)
}
private fun `present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled: Boolean,
expectedReceiptType: ReceiptType,
) = runTest(StandardTestDispatcher()) {
val timeline = FakeTimeline( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
@ -129,11 +151,15 @@ import kotlin.time.Duration.Companion.seconds
) )
) )
) )
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
liveTimeline = timeline, liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }) baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
markAsReadResult = markAsReadResult,
)
) )
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
timeline = timeline, timeline = timeline,
room = room, room = room,
@ -145,11 +171,32 @@ import kotlin.time.Duration.Companion.seconds
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent() runCurrent()
assertThat(room.baseRoom.markAsReadCalls).isNotEmpty() assert(markAsReadResult)
.isCalledOnce()
.with(value(expectedReceiptType))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - once presenter is disposed, room is marked as fully read`() = runTest {
val invokeResult = lambdaRecorder<RoomId, Unit> { }
val presenter = createTimelinePresenter(
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
)
),
markAsFullyRead = FakeMarkAsFullyRead(
invokeResult = invokeResult,
)
)
presenter.test {
awaitFirstItem()
}
invokeResult.assertions().isCalledOnce().with(value(A_ROOM_ID))
}
@Test @Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
@ -562,7 +609,7 @@ import kotlin.time.Duration.Companion.seconds
liveTimeline = FakeTimeline( liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList()), timelineItems = flowOf(emptyList()),
), ),
createTimelineResult = { Result.failure(Throwable("An error")) }, createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
) )
) )
@ -674,12 +721,13 @@ import kotlin.time.Duration.Companion.seconds
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = room, room = room,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this, sessionCoroutineScope = this,
navigator = messagesNavigator, navigator = messagesNavigator,
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction, endPollAction = endPollAction,
@ -690,6 +738,7 @@ import kotlin.time.Duration.Companion.seconds
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
markAsFullyRead = markAsFullyRead,
) )
} }
} }

View file

@ -187,7 +187,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onJoinCallClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
) { ) {
setSafeContent { setSafeContent(clearAndroidUiDispatcher = true) {
TimelineView( TimelineView(
state = state, state = state,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,

View file

@ -9,6 +9,7 @@ package io.element.android.features.migration.impl.migrations
import android.content.Context import android.content.Context
import com.squareup.anvil.annotations.ContributesMultibinding import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@ -26,6 +27,6 @@ class AppMigration04 @Inject constructor(
override val order: Int = 4 override val order: Int = 4
override suspend fun migrate() { override suspend fun migrate() {
runCatching { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() } runCatchingExceptions { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() }
} }
} }

View file

@ -19,6 +19,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -38,6 +39,7 @@ import javax.inject.Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DefaultNetworkMonitor @Inject constructor( class DefaultNetworkMonitor @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@AppCoroutineScope
appCoroutineScope: CoroutineScope, appCoroutineScope: CoroutineScope,
) : NetworkMonitor { ) : NetworkMonitor {
private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java)

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