Merge branch 'release/25.06.1'
This commit is contained in:
commit
bad30b8df5
490 changed files with 3233 additions and 3172 deletions
54
CHANGES.md
54
CHANGES.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
9
app/src/main/res/xml/automotive_app_desc.xml
Normal file
9
app/src/main/res/xml/automotive_app_desc.xml
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ class JoinedRoomLoadedFlowNodeTest {
|
|||
messagesEntryPoint = messagesEntryPoint,
|
||||
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
||||
appNavigationStateService = FakeAppNavigationStateService(),
|
||||
appCoroutineScope = this,
|
||||
sessionCoroutineScope = this,
|
||||
roomComponentFactory = FakeRoomComponentFactory(),
|
||||
matrixClient = FakeMatrixClient(),
|
||||
activeRoomsHolder = activeRoomsHolder,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202506010.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202506010.txt
Normal 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
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class DefaultCacheCleanerTest {
|
|||
}
|
||||
|
||||
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
|
||||
scope = this,
|
||||
coroutineScope = this,
|
||||
dispatchers = this.testCoroutineDispatchers(true),
|
||||
cacheDir = temporaryFolder.root,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -238,6 +238,6 @@ private fun TestScope.createKnockRequestsBannerPresenter(
|
|||
)
|
||||
return KnockRequestsBannerPresenter(
|
||||
knockRequestsService = knockRequestsService,
|
||||
appCoroutineScope = this,
|
||||
sessionCoroutineScope = this,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
|
|||
)
|
||||
),
|
||||
aForwardMessagesState(
|
||||
forwardAction = AsyncAction.Failure(Throwable("error")),
|
||||
forwardAction = AsyncAction.Failure(RuntimeException("error")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,6 @@ class ForwardMessagesPresenterTest {
|
|||
) = ForwardMessagesPresenter(
|
||||
eventId = eventId.value,
|
||||
timelineProvider = LiveTimelineProvider(fakeRoom),
|
||||
appCoroutineScope = this,
|
||||
sessionCoroutineScope = this,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1539,7 +1539,7 @@ class MessageComposerPresenterTest {
|
|||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
appCoroutineScope = this,
|
||||
sessionCoroutineScope = this,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
featureFlagService = featureFlagService,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue