Merge branch 'release/25.06.1'

This commit is contained in:
Jorge Martín 2025-06-09 12:33:32 +02:00
commit bad30b8df5
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 -->
Changes in Element X v25.05.4

View file

@ -25,6 +25,10 @@
android:theme="@style/Theme.ElementX"
tools:targetApi="33">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<provider
android:name="androidx.startup.InitializationProvider"
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.CacheDirectory
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.R
import kotlinx.coroutines.CoroutineName
@ -56,6 +57,7 @@ object AppModule {
}
@Provides
@AppCoroutineScope
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
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.di.AppScope
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.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -104,7 +105,8 @@ class LoggedInFlowNode @AssistedInject constructor(
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
@ -175,7 +177,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener)
ftueService.state
@ -313,7 +315,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
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) {

View file

@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.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
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatching {
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatchingExceptions {
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
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.RoomId
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.RoomMembershipObserver
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>) {
val roomInfoFlow = client.getRoomInfoFlow(roomIdOrAlias = roomId.toRoomIdOrAlias())
val roomInfoFlow = client.getRoomInfoFlow(roomId)
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
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.di.DaggerComponentOwner
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.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -50,7 +51,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory,
@ -92,7 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
trackVisitedRoom()
},
onResume = {
appCoroutineScope.launch {
sessionCoroutineScope.launch {
inputs.room.subscribeToSync()
}
},

View file

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

View file

@ -50,6 +50,7 @@ allprojects {
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
detektPlugins(project(":tests:detekt-rules"))
}
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 io.element.android.features.cachecleaner.api.CacheCleaner
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.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ -23,7 +25,8 @@ import javax.inject.Inject
*/
@ContributesBinding(AppScope::class)
class DefaultCacheCleaner @Inject constructor(
private val scope: CoroutineScope,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
@CacheDirectory private val cacheDir: File,
) : CacheCleaner {
@ -32,8 +35,8 @@ class DefaultCacheCleaner @Inject constructor(
}
override fun clearCache() {
scope.launch(dispatchers.io) {
runCatching {
coroutineScope.launch(dispatchers.io) {
runCatchingExceptions {
SUBDIRS_TO_CLEANUP.forEach {
File(cacheDir.path, it).apply {
if (exists()) {

View file

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

View file

@ -15,11 +15,19 @@ import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable {
@Parcelize
data class ExternalUrl(val url: String) : CallType
data class ExternalUrl(val url: String) : CallType {
override fun toString(): String {
return "ExternalUrl"
}
}
@Parcelize
data class RoomCall(
val sessionId: SessionId,
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.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -30,8 +31,8 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var activeCallManager: ActiveCallManager
@Inject
lateinit var appCoroutineScope: CoroutineScope
@AppCoroutineScope
@Inject lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) {
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 io.element.android.features.call.impl.R
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.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@ -78,7 +79,7 @@ class CallForegroundService : Service() {
} else {
0
}
runCatching {
runCatchingExceptions {
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure {
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.runCatchingUpdatingState
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.MatrixClientProvider
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.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber
import java.util.UUID
class CallScreenPresenter @AssistedInject constructor(
@ -64,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
private val activeRoomsHolder: ActiveRoomsHolder,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> {
@AssistedFactory
@ -211,6 +214,7 @@ class CallScreenPresenter @AssistedInject constructor(
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
@ -221,10 +225,12 @@ class CallScreenPresenter @AssistedInject constructor(
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
matrixClientsProvider.getOrNull(it)
} ?: return@DisposableEffect onDispose { }
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
}
coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService().syncState
.collect { state ->
if (state == SyncState.Running) {
@ -235,6 +241,7 @@ class CallScreenPresenter @AssistedInject constructor(
}
}
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
appForegroundStateService.updateIsInCallState(false)
}
@ -242,12 +249,29 @@ class CallScreenPresenter @AssistedInject constructor(
}
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
if (!notifiedCallStart) {
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
val sendCallNotificationResult = activeRoomForSession?.sendCallNotificationIfNeeded()
?: getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() }
sendCallNotificationResult?.onSuccess { notifiedCallStart = true }
if (notifiedCallStart) return
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
val sendCallNotificationResult = if (activeRoomForSession != null) {
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? {

View file

@ -8,10 +8,6 @@
package io.element.android.features.call.impl.ui
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.view.ViewGroup
import android.webkit.ConsoleMessage
@ -28,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import io.element.android.compound.tokens.generated.CompoundIcons
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.PictureInPictureState
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.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
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.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
@ -108,6 +103,8 @@ internal fun CallScreenView(
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
CallWebView(
modifier = Modifier
.padding(padding)
@ -120,25 +117,40 @@ internal fun CallScreenView(
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onWebViewCreate = { webView ->
onCreateWebView = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(
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)) },
)
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
webViewAudioManager?.onCallStopped()
}
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
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(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
}
is AsyncData.Success -> Unit
}
}
@ -150,7 +162,8 @@ private fun CallWebView(
url: AsyncData<String>,
userAgent: String,
onPermissionsRequest: (PermissionRequest) -> Unit,
onWebViewCreate: (WebView) -> Unit,
onCreateWebView: (WebView) -> Unit,
onDestroyWebView: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
@ -158,13 +171,11 @@ private fun CallWebView(
Text("WebView - can't be previewed")
}
} else {
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
AndroidView(
modifier = modifier,
factory = { context ->
audioDeviceCallback = context.setupAudioConfiguration()
WebView(context).apply {
onWebViewCreate(this)
onCreateWebView(this)
setup(userAgent, onPermissionsRequest)
}
},
@ -174,41 +185,13 @@ private fun CallWebView(
}
},
onRelease = { webView ->
// Reset audio mode
webView.context.releaseAudioConfiguration(audioDeviceCallback)
onDestroyWebView(webView)
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")
private fun WebView.setup(
userAgent: String,
@ -242,6 +225,20 @@ private fun WebView.setup(
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
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(
priority = priority,
message = buildString {

View file

@ -81,13 +81,14 @@ class ElementCallActivity :
applicationContext.bindings<CallBindings>().inject(this)
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
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
@ -97,6 +98,8 @@ class ElementCallActivity :
pictureInPicturePresenter.setPipView(this)
Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}")
setContent {
val pipState = pictureInPicturePresenter.present()
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.core.meta.BuildMeta
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
@ -57,8 +58,8 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var buildMeta: BuildMeta
@Inject
lateinit var appCoroutineScope: CoroutineScope
@AppCoroutineScope
@Inject lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) {
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.impl.notifications.CallNotificationData
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.ApplicationContext
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.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
@ -86,6 +88,7 @@ interface ActiveCallManager {
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
@ApplicationContext context: Context,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
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")
return
}
Timber.tag(tag).d("Hung up call: $callType")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
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 {
Timber.tag(tag).d("Joined call: $callType")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -218,7 +226,7 @@ class DefaultActiveCallManager @Inject constructor(
timestamp = notificationData.timestamp,
textContent = notificationData.textContent,
) ?: return
runCatching {
runCatchingExceptions {
notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification,

View file

@ -8,6 +8,7 @@
package io.element.android.features.call.impl.utils
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.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
@ -33,7 +34,7 @@ class DefaultCallWidgetProvider @Inject constructor(
clientId: String,
languageTag: String?,
theme: String?,
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
): Result<CallWidgetProvider.GetWidgetResult> = runCatchingExceptions {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, 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(
private val webView: WebView,
private val onUrlLoaded: (String) -> Unit,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor {
companion object {
@ -44,13 +45,13 @@ class WebViewWidgetMessageInterceptor(
.build()
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)
// Due to https://github.com/element-hq/element-x-android/issues/4097
// we need to supply a logging implementation that correctly includes
// objects in log lines.
view?.evaluateJavascript(
view.evaluateJavascript(
"""
function logFn(consoleLogFn, ...args) {
consoleLogFn(
@ -72,7 +73,7 @@ class WebViewWidgetMessageInterceptor(
// This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
view?.evaluateJavascript(
view.evaluateJavascript(
"""
window.addEventListener('message', function(event) {
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?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")

View file

@ -8,13 +8,14 @@
package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.serialization.json.Json
object WidgetMessageSerializer {
private val coder = Json { ignoreUnknownKeys = true }
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 {

View file

@ -35,7 +35,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
}
),
aCreateRoomRootState(
startDmAction = AsyncAction.Failure(Throwable("error")),
startDmAction = AsyncAction.Failure(RuntimeException("error")),
userListState = aMatrixUser().let {
aUserListState().copy(
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.matrix.api.MatrixClient
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_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.services.analytics.api.AnalyticsService
@ -27,7 +27,7 @@ class DefaultStartDMActionTest {
@Test
fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(A_ROOM_ID)
givenFindDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
@ -37,10 +37,23 @@ class DefaultStartDMActionTest {
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
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 {
givenFindDmResult(null)
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
@ -54,7 +67,7 @@ class DefaultStartDMActionTest {
@Test
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 {
givenFindDmResult(null)
givenFindDmResult(Result.success(null))
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
@ -69,14 +82,14 @@ class DefaultStartDMActionTest {
@Test
fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null)
givenCreateDmResult(Result.failure(A_THROWABLE))
givenFindDmResult(Result.success(null))
givenCreateDmResult(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(A_THROWABLE))
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
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.RoomAliasHelper
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_ROOM_ID
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.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -274,7 +274,7 @@ class ConfigureBaseRoomPresenterTest {
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1)
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)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
@ -298,7 +298,7 @@ class ConfigureBaseRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.failure<RoomId>(A_THROWABLE)
val createRoomResult = Result.failure<RoomId>(AN_EXCEPTION)
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.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -56,7 +57,7 @@ class JoinBaseRoomByAddressViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
state: JoinRoomByAddressState,
) {
setContent {
setSafeContent {
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.UserId
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_THROWABLE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
@ -42,7 +42,7 @@ class CreateBaseRoomRootPresenterTest {
@Test
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 ->
actionState.value = startDMFailureResult
}

View file

@ -34,10 +34,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
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.platform.LocalFocusManager
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.KeyboardType
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.form.textFieldState
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -276,14 +277,9 @@ private fun Content(
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
.autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = {
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(AccountDeactivationEvents.SetPassword(sanitized))
}
),
.semantics {
contentType = ContentType.Password
},
onValueChange = {
val sanitized = it.sanitize()
passwordFieldState = sanitized

View file

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

View file

@ -29,10 +29,10 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
),
),
anAcceptDeclineInviteState(
acceptAction = AsyncAction.Failure(Throwable("Error while accepting invite")),
acceptAction = AsyncAction.Failure(RuntimeException("Error while accepting invite")),
),
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 {
val joinRoomLambda =
lambdaRecorder<RoomIdOrAlias, List<String>, JoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
Result.failure(Throwable("Join room failed"))
Result.failure(RuntimeException("Join room failed"))
}
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.core.RoomId
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.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.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
@ -88,7 +86,7 @@ class JoinRoomPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by remember {
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
matrixClient.getRoomInfoFlow(roomId)
}.collectAsState(initial = Optional.empty())
val joinAction: 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.FakeMatrixClient
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.aRoomPreview
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.ui.model.InviteSender
import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -88,12 +88,12 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is joined then content state is filled with his data`() = runTest {
val roomSummary = aRoomSummary()
val roomInfo = aRoomInfo()
val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
@ -104,24 +104,24 @@ class JoinRoomPresenterTest {
awaitItem().also { state ->
val contentState = state.contentState as ContentState.Loaded
assertThat(contentState.roomId).isEqualTo(A_ROOM_ID)
assertThat(contentState.name).isEqualTo(roomSummary.info.name)
assertThat(contentState.topic).isEqualTo(roomSummary.info.topic)
assertThat(contentState.alias).isEqualTo(roomSummary.info.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomSummary.info.joinedMembersCount)
assertThat(contentState.isDm).isEqualTo(roomSummary.info.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomSummary.info.avatarUrl)
assertThat(contentState.name).isEqualTo(roomInfo.name)
assertThat(contentState.topic).isEqualTo(roomInfo.topic)
assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.joinedMembersCount)
assertThat(contentState.isDm).isEqualTo(roomInfo.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
}
}
}
@Test
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(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val seenInvitesStore = InMemorySeenInvitesStore()
@ -129,7 +129,7 @@ class JoinRoomPresenterTest {
matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore,
)
val inviteData = roomSummary.info.toInviteData()
val inviteData = roomInfo.toInviteData()
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test {
skipItems(2)
@ -137,7 +137,7 @@ class JoinRoomPresenterTest {
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null))
}
// 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 {
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
val expectedInviteSender = inviter.toInviteSender()
val roomSummary = aRoomSummary(
val roomInfo = aRoomInfo(
currentUserMembership = CurrentUserMembership.INVITED,
joinedMembersCount = 5,
inviter = inviter,
)
val inviteData = roomSummary.info.toInviteData()
val inviteData = roomInfo.toInviteData()
val matrixClient = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
@ -172,7 +172,7 @@ class JoinRoomPresenterTest {
@Test
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,
// It seems that the SDK does not provide this value.
joinedMembersCount = 0,
@ -188,8 +188,8 @@ class JoinRoomPresenterTest {
)
},
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
@ -209,13 +209,13 @@ class JoinRoomPresenterTest {
val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
}
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED)
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
val matrixClient = FakeMatrixClient().apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val inviteData = roomSummary.info.toInviteData()
val inviteData = roomInfo.toInviteData()
val presenter = createJoinRoomPresenter(
matrixClient = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter
@ -324,7 +324,7 @@ class JoinRoomPresenterTest {
@Test
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(
getNotJoinedRoomResult = { _, _ ->
Result.success(
@ -346,8 +346,8 @@ class JoinRoomPresenterTest {
)
}
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
@ -369,12 +369,12 @@ class JoinRoomPresenterTest {
@Test
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(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
@ -390,12 +390,12 @@ class JoinRoomPresenterTest {
@Test
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(
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
).apply {
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
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.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@ -28,7 +29,8 @@ private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
class KnockRequestsBannerPresenter @Inject constructor(
private val knockRequestsService: KnockRequestsService,
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<KnockRequestsBannerState> {
@Composable
override fun present(): KnockRequestsBannerState {
@ -52,13 +54,13 @@ class KnockRequestsBannerPresenter @Inject constructor(
fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> {
appCoroutineScope.acceptSingleKnockRequest(
sessionCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests,
displayAcceptError = showAcceptError,
)
}
is KnockRequestsBannerEvents.Dismiss -> {
appCoroutineScope.launch {
sessionCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen()
}
}

View file

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

View file

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

View file

@ -108,7 +108,7 @@ class KnockRequestsListViewTest {
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll,
eventSink = eventsRecorder,
),
@ -124,7 +124,7 @@ class KnockRequestsListViewTest {
rule.setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
currentAction = KnockRequestsAction.AcceptAll,
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.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import javax.inject.Inject
@ -34,7 +35,7 @@ class DependencyLicensesListPresenter @Inject constructor(
}
var filter by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
runCatching {
runCatchingExceptions {
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
}.onFailure {
licenses = AsyncData.Failure(it)

View file

@ -15,6 +15,7 @@ import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
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.ApplicationContext
import timber.log.Timber
@ -26,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
@ApplicationContext private val context: Context
) : LocationActions {
override fun share(location: Location, label: String?) {
runCatching {
runCatchingExceptions {
val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
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.ListItem
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Text
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.maplibre.compose.CameraMode
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.libraries.di.AppScope
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.FeatureFlags
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
@ -42,6 +43,7 @@ class DefaultLockScreenService @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val lockScreenStore: LockScreenStore,
private val pinCodeManager: PinCodeManager,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,

View file

@ -13,6 +13,7 @@ import androidx.biometric.BiometricPrompt.CryptoObject
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.core.content.ContextCompat
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.SecretKeyRepository
import kotlinx.coroutines.CancellationException
@ -119,7 +120,7 @@ private class AuthenticationCallback(
private fun Cipher?.isValid(): Boolean {
if (this == null) return false
return runCatching {
return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray())
}.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.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -47,6 +48,7 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val secretKeyRepository: SecretKeyRepository,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
) : BiometricAuthenticatorManager {
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.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -29,6 +30,7 @@ class LockScreenSettingsPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> {
@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.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -34,6 +35,7 @@ class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
) : 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.onboarding.OnBoardingNode
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.BaseFlowNode
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.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.api.OidcEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -57,7 +57,6 @@ class LoginFlowNode @AssistedInject constructor(
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val oidcActionFlow: OidcActionFlow,
private val oidcEntryPoint: OidcEntryPoint,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
@ -74,15 +73,15 @@ class LoginFlowNode @AssistedInject constructor(
private var activity: Activity? = null
private var darkTheme: Boolean = false
private var customChromeTabStarted = false
private var externalAppStarted = false
override fun onBuilt() {
super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false)
lifecycle.subscribe(
onResume = {
if (customChromeTabStarted) {
customChromeTabStarted = false
if (externalAppStarted) {
externalAppStarted = false
// Workaround to detect that the Custom Chrome Tab has been closed
// If there is no coming OidcAction (that would end this Node),
// consider that the user has cancelled the login
@ -122,9 +121,6 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
data class CreateAccount(val url: String) : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -249,9 +245,6 @@ class LoginFlowNode @AssistedInject constructor(
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext)
}
is NavTarget.OidcView -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
url = navTarget.url,
@ -262,15 +255,9 @@ class LoginFlowNode @AssistedInject constructor(
}
private fun navigateToMas(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
activity?.let {
externalAppStarted = true
it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url)
}
}

View file

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

View file

@ -88,7 +88,7 @@ fun ConfirmAccountProviderView(
TextButton(
text = stringResource(id = R.string.screen_account_provider_change),
onClick = onChange,
enabled = true,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.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.core.data.tryOrNull
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.matrix.api.MatrixClientProvider
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 {
loggedInState.value = AsyncAction.Loading
runCatching {
runCatchingExceptions {
messageParser.parse(message)
}.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession)

View file

@ -17,7 +17,7 @@ open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountSt
aCreateAccountState(),
aCreateAccountState(pageProgress = 33),
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
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -30,10 +31,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
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.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager
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.KeyboardType
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.dialogs.ErrorDialog
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -71,6 +74,13 @@ fun LoginPasswordView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val autofillManager = LocalAutofillManager.current
BackHandler {
autofillManager?.cancel()
onBackClick()
}
val isLoading by remember(state.loginAction) {
derivedStateOf {
state.loginAction is AsyncData.Loading
@ -82,6 +92,8 @@ fun LoginPasswordView(
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
autofillManager?.commit()
state.eventSink(LoginPasswordEvents.Submit)
}
@ -90,7 +102,12 @@ fun LoginPasswordView(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) },
navigationIcon = {
BackButton(onClick = {
autofillManager?.cancel()
onBackClick()
})
},
)
}
) { padding ->
@ -175,14 +192,9 @@ private fun LoginForm(
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername)
.autofill(
autofillTypes = listOf(AutofillType.Username),
onFill = {
val sanitized = it.sanitize()
loginFieldState = sanitized
eventSink(LoginPasswordEvents.SetLogin(sanitized))
}
),
.semantics {
contentType = ContentType.Username
},
placeholder = stringResource(CommonStrings.common_username),
onValueChange = {
val sanitized = it.sanitize()
@ -227,14 +239,9 @@ private fun LoginForm(
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
.autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = {
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(LoginPasswordEvents.SetPassword(sanitized))
}
),
.semantics {
contentType = ContentType.Password
},
onValueChange = {
val sanitized = it.sanitize()
passwordFieldState = sanitized

View file

@ -17,7 +17,7 @@ class ErrorFormatterTest {
// region loginError
@Test
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)
}

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.matrix.test.AN_ACCOUNT_PROVIDER_2
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.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
@ -113,7 +113,7 @@ class ChooseAccountProviderPresenterTest {
}
awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
authenticationService.givenChangeServerError(A_THROWABLE)
authenticationService.givenChangeServerError(AN_EXCEPTION)
it.eventSink(ChooseAccountProviderEvents.Continue)
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.libraries.architecture.AsyncData
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_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
@ -118,7 +118,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenOidcCancelError(A_THROWABLE)
authenticationService.givenOidcCancelError(AN_EXCEPTION)
defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
@ -173,7 +173,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenLoginError(A_THROWABLE)
authenticationService.givenLoginError(AN_EXCEPTION)
defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
@ -225,7 +225,7 @@ class ConfirmAccountProviderPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
authenticationService.givenChangeServerError(Throwable())
authenticationService.givenChangeServerError(RuntimeException())
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val failureState = awaitItem()
@ -246,7 +246,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
// Submit will return an error
authenticationService.givenChangeServerError(A_THROWABLE)
authenticationService.givenChangeServerError(AN_EXCEPTION)
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
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.matrix.api.auth.MatrixAuthenticationService
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.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.auth.FakeMatrixAuthenticationService
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.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -89,12 +92,16 @@ class CreateAccountPresenterTest {
defaultLoginUserStory.setLoginFlowIsDone(false)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
val sessionVerificationService = FakeSessionVerificationService()
val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService)
val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
),
messageParser = FakeMessageParser(lambda),
defaultLoginUserStory = defaultLoginUserStory,
clientProvider = clientProvider,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -102,6 +109,7 @@ class CreateAccountPresenterTest {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
assertThat(awaitItem().createAction.isLoading()).isTrue()
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
}
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.libraries.architecture.AsyncData
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_PASSWORD
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.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
@ -96,12 +96,12 @@ class LoginPasswordPresenterTest {
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
skipItems(1)
val loginAndPasswordState = awaitItem()
authenticationService.givenLoginError(A_THROWABLE)
authenticationService.givenLoginError(AN_EXCEPTION)
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
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))
skipItems(1)
val loginAndPasswordState = awaitItem()
authenticationService.givenLoginError(A_THROWABLE)
authenticationService.givenLoginError(AN_EXCEPTION)
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
val submitState = awaitItem()
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
val loggedInState = awaitItem()
// 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
loggedInState.eventSink(LoginPasswordEvents.ClearError)
val clearedState = awaitItem()

View file

@ -8,17 +8,21 @@
package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsEnabled
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.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD
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.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@ -120,15 +124,15 @@ class LoginPasswordViewTest {
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
// Show password
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
rule.onNodeWithContentDescription(a11yShowPassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertExists()
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
// Hide password
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
rule.onNodeWithContentDescription(a11yHidePassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
}
@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_2
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_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.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow
@ -192,7 +192,7 @@ class OnBoardingPresenterTest {
skipItems(3)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE)
authenticationService.givenChangeServerError(AN_EXCEPTION)
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
skipItems(1) // Loading

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@ -36,9 +37,13 @@ class OnboardingViewTest {
@Test
fun `when can create account - clicking on create account calls the expected callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(canCreateAccount = true),
state = anOnBoardingState(
canCreateAccount = true,
eventSink = eventSink,
),
onCreateAccount = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_up)
@ -47,9 +52,13 @@ class OnboardingViewTest {
@Test
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 ->
rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true),
state = anOnBoardingState(
canLoginWithQrCode = true,
eventSink = eventSink,
),
onSignInWithQrCode = callback,
)
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`(
mustChooseAccountProvider: Boolean,
) {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider,
eventSink = eventSink,
),
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`(
mustChooseAccountProvider: Boolean,
) {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = false,
canCreateAccount = false,
mustChooseAccountProvider = mustChooseAccountProvider,
eventSink = eventSink,
),
onSignIn = callback,
)
@ -145,10 +158,12 @@ class OnboardingViewTest {
@Test
fun `clicking on report a problem calls the sign in callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
canReportBug = true,
eventSink = eventSink,
),
onReportProblem = callback,
)
@ -160,15 +175,64 @@ class OnboardingViewTest {
@Test
fun `cannot report a problem when the feature is disabled`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
rule.setOnboardingView(
state = anOnBoardingState(
canReportBug = false,
eventSink = eventSink,
),
)
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
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(
state: OnBoardingState,
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.EncryptionService
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.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule
@ -165,7 +165,7 @@ class LogoutPresenterTest {
fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _, _ ->
throw A_THROWABLE
throw AN_EXCEPTION
}
}
val presenter = createLogoutPresenter(
@ -182,7 +182,7 @@ class LogoutPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -194,9 +194,7 @@ class LogoutPresenterTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) {
throw A_THROWABLE
} else {
null
throw AN_EXCEPTION
}
}
}
@ -214,7 +212,7 @@ class LogoutPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
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))
val loadingState2 = awaitItem()
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.encryption.BackupUploadState
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.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule
@ -117,7 +117,7 @@ class DirectLogoutPresenterTest {
fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _, _ ->
throw A_THROWABLE
throw AN_EXCEPTION
}
}
val presenter = createDirectLogoutPresenter(
@ -134,7 +134,7 @@ class DirectLogoutPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs)
val finalState = awaitItem()
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
@ -146,9 +146,7 @@ class DirectLogoutPresenterTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) {
throw A_THROWABLE
} else {
null
throw AN_EXCEPTION
}
}
}
@ -166,7 +164,7 @@ class DirectLogoutPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
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))
val loadingState2 = awaitItem()
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.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.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
/**
@ -139,8 +139,8 @@ internal fun ExpandableBottomSheetScaffold(
modifier = Modifier.fillMaxHeight(),
measurePolicy = { measurables, constraints ->
val constraintHeight = constraints.maxHeight
val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0
val height = Integer.max(0, constraintHeight - offset)
val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
val height = Integer.max(0, constraintHeight - offset.roundToInt())
val top = measurables[0].measure(
constraints.copy(
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 {
data class SheetContent(val key: Int?) : 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.designsystem.utils.OnLifecycleEvent
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.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -69,7 +70,8 @@ import kotlinx.coroutines.launch
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val coroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: BaseRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
@ -115,7 +117,7 @@ class MessagesNode @AssistedInject constructor(
super.onBuilt()
lifecycle.subscribe(
onCreate = {
coroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onDestroy = {
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.Presenter
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.designsystem.components.avatar.AvatarData
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) {
inviteProgress.value = AsyncData.Loading()
runCatching {
runCatchingExceptions {
val memberList = when (val memberState = room.membersStateFlow.value) {
is RoomMembersState.Ready -> memberState.roomMembers
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.core.coroutine.CoroutineDispatchers
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.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@ -240,7 +241,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
replyParameters: ReplyParameters?,
) = runCatching {
) = runCatchingExceptions {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
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.encryption.EncryptionService
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.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -30,7 +30,7 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room)
room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it }
}
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.Presenter
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.RoomId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
@ -28,7 +29,8 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
@Assisted private val timelineProvider: TimelineProvider,
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@ -40,7 +42,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
appCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
sessionCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
}
@Composable

View file

@ -24,7 +24,7 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
)
),
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.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import dagger.assisted.Assisted
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.utils.TextPillificationHelper
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.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
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(
@Assisted private val navigator: MessagesNavigator,
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
@ -165,13 +168,13 @@ class MessageComposerPresenter @AssistedInject constructor(
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
handlePickedMedia(uri)
handlePickedMedia(uri, MimeTypes.OctetStream)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.IMAGE_JPEG)
handlePickedMedia(uri, MimeTypes.Jpeg)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(uri, MimeTypes.VIDEO_MP4)
handlePickedMedia(uri, MimeTypes.Mp4)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@ -199,7 +202,7 @@ class MessageComposerPresenter @AssistedInject constructor(
DisposableEffect(Unit) {
// Declare that the user is not typing anymore when the composer is disposed
onDispose {
appCoroutineScope.launch {
sessionCoroutineScope.launch {
if (sendTypingNotifications) {
room.typingNotice(false)
}
@ -235,12 +238,12 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
is MessageComposerEvents.SendMessage -> {
appCoroutineScope.sendMessage(
sessionCoroutineScope.sendMessage(
markdownTextEditorState = markdownTextEditorState,
richTextEditorState = richTextEditorState,
)
}
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment(
is MessageComposerEvents.SendUri -> sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
@ -337,7 +340,7 @@ class MessageComposerPresenter @AssistedInject constructor(
}
MessageComposerEvents.SaveDraft -> {
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(
uri: Uri,
mimeType: String,
) = runCatching {
) = runCatchingExceptions {
mediaSender.sendMedia(
uri = uri,
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.designsystem.utils.snackbar.SnackbarDispatcher
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.powerlevels.canPinUnpin
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 snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
@ -123,7 +125,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
fun handleEvents(event: PinnedMessagesListEvents) {
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.", 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.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)),
// 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.core.bool.orFalse
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.UniqueId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -65,7 +67,8 @@ class TimelinePresenter @AssistedInject constructor(
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val room: JoinedRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction,
@ -76,6 +79,7 @@ class TimelinePresenter @AssistedInject constructor(
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
@ -133,7 +137,7 @@ class TimelinePresenter @AssistedInject constructor(
newEventState.value = NewEventState.None
}
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
appScope.sendReadReceiptIfNeeded(
sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
@ -143,13 +147,13 @@ class TimelinePresenter @AssistedInject constructor(
newEventState.value = NewEventState.None
}
}
is TimelineEvents.SelectPollAnswer -> appScope.launch {
is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute(
pollStartId = event.pollStartId,
answerId = event.answerId
)
}
is TimelineEvents.EndPoll -> appScope.launch {
is TimelineEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(
pollStartId = event.pollStartId,
)
@ -177,6 +181,12 @@ class TimelinePresenter @AssistedInject constructor(
}
}
DisposableEffect(Unit) {
onDispose {
markAsFullyRead(room.roomId)
}
}
LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems
.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.messageFromMeBackground
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.testTag
import io.element.android.libraries.ui.utils.time.isTalkbackActive
@ -112,7 +113,9 @@ fun MessageEventBubble(
state.isMine -> ElementTheme.colors.messageFromMeBackground
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 yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
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.Modifier
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -80,7 +80,7 @@ fun TimelineEventTimestampView(
.clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}
.semantics { invisibleToUser() }
.semantics { hideFromAccessibility() }
)
}
@ -95,7 +95,7 @@ fun TimelineEventTimestampView(
.clickable {
eventSink(TimelineEvents.ShowShieldDialog(shield))
}
.semantics { invisibleToUser() },
.semantics { hideFromAccessibility() },
tint = shield.toIconColor(),
)
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.res.stringResource
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.semantics
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
.clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
.clearAndSetSemantics {
invisibleToUser()
hideFromAccessibility()
}
) {
Avatar(

View file

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

View file

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

View file

@ -76,7 +76,7 @@ fun TimelineItemStickerView(
mimeType = content.mimeType,
),
),
contentScale = ContentScale.Fit,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
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.platform.LocalInspectionMode
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -122,7 +122,7 @@ fun TimelineItemVideoView(
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
)
),
contentScale = ContentScale.Fit,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
@ -136,7 +136,7 @@ fun TimelineItemVideoView(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
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.semantics.clearAndSetSemantics
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -59,7 +59,7 @@ fun TimelineItemReadReceiptView(
if (renderReadReceipts) {
ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics {
invisibleToUser()
hideFromAccessibility()
}
) {
ReadReceiptsAvatars(

View file

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

View file

@ -22,6 +22,7 @@ import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
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.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -44,7 +45,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender,
@ -74,11 +76,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
appCoroutineScope.finishRecording()
sessionCoroutineScope.finishRecording()
player.pause()
}
Lifecycle.Event.ON_DESTROY -> {
appCoroutineScope.cancelRecording()
sessionCoroutineScope.cancelRecording()
}
else -> {}
}
@ -145,7 +147,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
isSending = true
player.pause()
analyticsService.captureComposerEvent()
appCoroutineScope.launch {
sessionCoroutineScope.launch {
val result = sendMessage(
file = finishedState.file,
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.test.AN_AVATAR_URL
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_ROOM_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_THROWABLE
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.core.aBuildMeta
@ -139,10 +139,10 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
markAsReadResult = { lambdaError() }
),
typingNoticeResult = { Result.success(Unit) },
)
assertThat(room.baseRoom.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(joinedRoom = room)
presenter.testWithLifecycleOwner {
runCurrent()
@ -744,7 +744,7 @@ class MessagesPresenterTest {
canUserPinUnpinResult = { Result.success(true) },
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(Throwable("Oops!")) },
inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
)
room.givenRoomMembersState(
RoomMembersState.Ready(
@ -848,12 +848,12 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(joinedRoom = joinedRoom)
@ -892,17 +892,17 @@ class MessagesPresenterTest {
@Test
fun `present - handle action pin`() = runTest {
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 timeline = FakeTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
@ -932,17 +932,17 @@ class MessagesPresenterTest {
@Test
fun `present - handle action unpin`() = runTest {
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 analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
@ -1096,12 +1096,12 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
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 {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
},
sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
},
typingNoticeResult = { Result.success(Unit) },
)
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.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
@ -567,11 +568,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
setSafeContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider(
LocalInspectionMode provides true
) {
CompositionLocalProvider(LocalInspectionMode provides true) {
MessagesView(
state = state,
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.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -54,7 +55,7 @@ class ResolveVerifiedUserSendFailureViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState,
) {
setContent {
setSafeContent {
ResolveVerifiedUserSendFailureView(state = state)
}
}

View file

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

View file

@ -1539,7 +1539,7 @@ class MessageComposerPresenterTest {
draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter(
navigator = navigator,
appCoroutineScope = this,
sessionCoroutineScope = this,
room = room,
mediaPickerProvider = pickerProvider,
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.item.TimelineItemDebugInfo
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.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -157,7 +157,7 @@ class PinnedMessagesListPresenterTest {
@Test
fun `present - unpin event`() = runTest {
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 analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
@ -337,7 +337,7 @@ class PinnedMessagesListPresenterTest {
actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() },
analyticsService = analyticsService,
appCoroutineScope = this,
sessionCoroutineScope = this,
)
}
}

View file

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

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
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.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
@ -35,7 +36,7 @@ class DefaultHtmlConverterProviderTest {
@Test
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)
}
@ -47,7 +48,7 @@ class DefaultHtmlConverterProviderTest {
provider.Update()
}
}
val htmlConverter = runCatching { provider.provide() }.getOrNull()
val htmlConverter = runCatchingExceptions { provider.provide() }.getOrNull()
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.roomcall.api.aStandByCallState
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.room.RoomMembersState
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.test.AN_EVENT_ID
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_2
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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -76,7 +79,9 @@ import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class)
class TimelinePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@ -119,9 +124,26 @@ import kotlin.time.Duration.Companion.seconds
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@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(
timelineItems = flowOf(
listOf(
@ -129,11 +151,15 @@ import kotlin.time.Duration.Companion.seconds
)
)
)
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
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(
timeline = timeline,
room = room,
@ -145,11 +171,32 @@ import kotlin.time.Duration.Companion.seconds
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
assertThat(room.baseRoom.markAsReadCalls).isNotEmpty()
assert(markAsReadResult)
.isCalledOnce()
.with(value(expectedReceiptType))
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
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
@ -562,7 +609,7 @@ import kotlin.time.Duration.Companion.seconds
liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList()),
),
createTimelineResult = { Result.failure(Throwable("An error")) },
createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
)
)
@ -674,12 +721,13 @@ import kotlin.time.Duration.Companion.seconds
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,
sessionCoroutineScope = this,
navigator = messagesNavigator,
redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction,
@ -690,6 +738,7 @@ import kotlin.time.Duration.Companion.seconds
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
markAsFullyRead = markAsFullyRead,
)
}
}

View file

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

View file

@ -9,6 +9,7 @@ package io.element.android.features.migration.impl.migrations
import android.content.Context
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.ApplicationContext
import javax.inject.Inject
@ -26,6 +27,6 @@ class AppMigration04 @Inject constructor(
override val order: Int = 4
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.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
@ -38,6 +39,7 @@ import javax.inject.Inject
@SingleIn(AppScope::class)
class DefaultNetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
@AppCoroutineScope
appCoroutineScope: CoroutineScope,
) : NetworkMonitor {
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