Merge branch 'release/25.06.1'
This commit is contained in:
commit
c17dc19370
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 -->
|
<!-- Release notes generated using configuration in .github/release.yml at v25.05.4 -->
|
||||||
|
|
||||||
Changes in Element X v25.05.4
|
Changes in Element X v25.05.4
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@
|
||||||
android:theme="@style/Theme.ElementX"
|
android:theme="@style/Theme.ElementX"
|
||||||
tools:targetApi="33">
|
tools:targetApi="33">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.CacheDirectory
|
import io.element.android.libraries.di.CacheDirectory
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.x.BuildConfig
|
import io.element.android.x.BuildConfig
|
||||||
import io.element.android.x.R
|
import io.element.android.x.R
|
||||||
import kotlinx.coroutines.CoroutineName
|
import kotlinx.coroutines.CoroutineName
|
||||||
|
|
@ -56,6 +57,7 @@ object AppModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
@AppCoroutineScope
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
fun providesAppCoroutineScope(): CoroutineScope {
|
fun providesAppCoroutineScope(): CoroutineScope {
|
||||||
return MainScope() + CoroutineName("ElementX Scope")
|
return MainScope() + CoroutineName("ElementX Scope")
|
||||||
|
|
|
||||||
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.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
||||||
|
|
@ -104,7 +105,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||||
private val userProfileEntryPoint: UserProfileEntryPoint,
|
private val userProfileEntryPoint: UserProfileEntryPoint,
|
||||||
private val ftueEntryPoint: FtueEntryPoint,
|
private val ftueEntryPoint: FtueEntryPoint,
|
||||||
private val coroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val ftueService: FtueService,
|
private val ftueService: FtueService,
|
||||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||||
private val shareEntryPoint: ShareEntryPoint,
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
|
|
@ -175,7 +177,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
||||||
// TODO We do not support Space yet, so directly navigate to main space
|
// TODO We do not support Space yet, so directly navigate to main space
|
||||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||||
|
|
||||||
ftueService.state
|
ftueService.state
|
||||||
|
|
@ -313,7 +315,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||||
coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
|
sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
|
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
import im.vector.app.features.analytics.plan.UserProperties
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
|
@ -125,7 +126,7 @@ class LoggedInPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
|
// Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
|
||||||
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatching {
|
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatchingExceptions {
|
||||||
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
|
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
|
||||||
currentSlidingSyncVersion == SlidingSyncVersion.Proxy
|
currentSlidingSyncVersion == SlidingSyncVersion.Proxy
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
|
||||||
import io.element.android.libraries.matrix.api.getRoomInfoFlow
|
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||||
|
|
@ -124,7 +122,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
|
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
|
||||||
val roomInfoFlow = client.getRoomInfoFlow(roomIdOrAlias = roomId.toRoomIdOrAlias())
|
val roomInfoFlow = client.getRoomInfoFlow(roomId)
|
||||||
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
|
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
|
||||||
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
|
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
|
||||||
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
|
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.DaggerComponentOwner
|
import io.element.android.libraries.di.DaggerComponentOwner
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -50,7 +51,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||||
private val messagesEntryPoint: MessagesEntryPoint,
|
private val messagesEntryPoint: MessagesEntryPoint,
|
||||||
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
||||||
private val appNavigationStateService: AppNavigationStateService,
|
private val appNavigationStateService: AppNavigationStateService,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||||
roomComponentFactory: RoomComponentFactory,
|
roomComponentFactory: RoomComponentFactory,
|
||||||
|
|
@ -92,7 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||||
trackVisitedRoom()
|
trackVisitedRoom()
|
||||||
},
|
},
|
||||||
onResume = {
|
onResume = {
|
||||||
appCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
inputs.room.subscribeToSync()
|
inputs.room.subscribeToSync()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ class JoinedRoomLoadedFlowNodeTest {
|
||||||
messagesEntryPoint = messagesEntryPoint,
|
messagesEntryPoint = messagesEntryPoint,
|
||||||
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
||||||
appNavigationStateService = FakeAppNavigationStateService(),
|
appNavigationStateService = FakeAppNavigationStateService(),
|
||||||
appCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
roomComponentFactory = FakeRoomComponentFactory(),
|
roomComponentFactory = FakeRoomComponentFactory(),
|
||||||
matrixClient = FakeMatrixClient(),
|
matrixClient = FakeMatrixClient(),
|
||||||
activeRoomsHolder = activeRoomsHolder,
|
activeRoomsHolder = activeRoomsHolder,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ allprojects {
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
|
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
|
||||||
|
detektPlugins(project(":tests:detekt-rules"))
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
|
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
|
||||||
|
|
|
||||||
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 com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.cachecleaner.api.CacheCleaner
|
import io.element.android.features.cachecleaner.api.CacheCleaner
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.CacheDirectory
|
import io.element.android.libraries.di.CacheDirectory
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -23,7 +25,8 @@ import javax.inject.Inject
|
||||||
*/
|
*/
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultCacheCleaner @Inject constructor(
|
class DefaultCacheCleaner @Inject constructor(
|
||||||
private val scope: CoroutineScope,
|
@AppCoroutineScope
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
@CacheDirectory private val cacheDir: File,
|
@CacheDirectory private val cacheDir: File,
|
||||||
) : CacheCleaner {
|
) : CacheCleaner {
|
||||||
|
|
@ -32,8 +35,8 @@ class DefaultCacheCleaner @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearCache() {
|
override fun clearCache() {
|
||||||
scope.launch(dispatchers.io) {
|
coroutineScope.launch(dispatchers.io) {
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
SUBDIRS_TO_CLEANUP.forEach {
|
SUBDIRS_TO_CLEANUP.forEach {
|
||||||
File(cacheDir.path, it).apply {
|
File(cacheDir.path, it).apply {
|
||||||
if (exists()) {
|
if (exists()) {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ class DefaultCacheCleanerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
|
private fun TestScope.aCacheCleaner() = DefaultCacheCleaner(
|
||||||
scope = this,
|
coroutineScope = this,
|
||||||
dispatchers = this.testCoroutineDispatchers(true),
|
dispatchers = this.testCoroutineDispatchers(true),
|
||||||
cacheDir = temporaryFolder.root,
|
cacheDir = temporaryFolder.root,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,19 @@ import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
sealed interface CallType : NodeInputs, Parcelable {
|
sealed interface CallType : NodeInputs, Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ExternalUrl(val url: String) : CallType
|
data class ExternalUrl(val url: String) : CallType {
|
||||||
|
override fun toString(): String {
|
||||||
|
return "ExternalUrl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class RoomCall(
|
data class RoomCall(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val roomId: RoomId,
|
val roomId: RoomId,
|
||||||
) : CallType
|
) : CallType {
|
||||||
|
override fun toString(): String {
|
||||||
|
return "RoomCall(sessionId=$sessionId, roomId=$roomId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.features.call.impl.di.CallBindings
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -30,8 +31,8 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activeCallManager: ActiveCallManager
|
lateinit var activeCallManager: ActiveCallManager
|
||||||
|
|
||||||
@Inject
|
@AppCoroutineScope
|
||||||
lateinit var appCoroutineScope: CoroutineScope
|
@Inject lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import io.element.android.features.call.impl.R
|
import io.element.android.features.call.impl.R
|
||||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
|
@ -78,7 +79,7 @@ class CallForegroundService : Service() {
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
|
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it, "Failed to start ongoing call foreground service")
|
Timber.e(it, "Failed to start ongoing call foreground service")
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -49,6 +50,7 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class CallScreenPresenter @AssistedInject constructor(
|
class CallScreenPresenter @AssistedInject constructor(
|
||||||
|
|
@ -64,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
private val languageTagProvider: LanguageTagProvider,
|
private val languageTagProvider: LanguageTagProvider,
|
||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||||
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<CallScreenState> {
|
) : Presenter<CallScreenState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -211,6 +214,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
callWidgetDriver.value = result.driver
|
callWidgetDriver.value = result.driver
|
||||||
|
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
|
||||||
result.url
|
result.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +225,12 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
private fun HandleMatrixClientSyncState() {
|
private fun HandleMatrixClientSyncState() {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
|
||||||
matrixClientsProvider.getOrNull(it)
|
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
|
||||||
} ?: return@DisposableEffect onDispose { }
|
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
|
||||||
|
}
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
|
||||||
client.syncService().syncState
|
client.syncService().syncState
|
||||||
.collect { state ->
|
.collect { state ->
|
||||||
if (state == SyncState.Running) {
|
if (state == SyncState.Running) {
|
||||||
|
|
@ -235,6 +241,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
|
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
|
||||||
// Make sure we mark the call as ended in the app state
|
// Make sure we mark the call as ended in the app state
|
||||||
appForegroundStateService.updateIsInCallState(false)
|
appForegroundStateService.updateIsInCallState(false)
|
||||||
}
|
}
|
||||||
|
|
@ -242,12 +249,29 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
|
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
|
||||||
if (!notifiedCallStart) {
|
if (notifiedCallStart) return
|
||||||
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
|
|
||||||
val sendCallNotificationResult = activeRoomForSession?.sendCallNotificationIfNeeded()
|
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
|
||||||
?: getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() }
|
val sendCallNotificationResult = if (activeRoomForSession != null) {
|
||||||
sendCallNotificationResult?.onSuccess { notifiedCallStart = true }
|
Timber.d("Notifying call start for room $roomId. Has room call: ${activeRoomForSession.info().hasRoomCall}")
|
||||||
|
activeRoomForSession.sendCallNotificationIfNeeded()
|
||||||
|
} else {
|
||||||
|
// Instantiate the room from the session and roomId and send the notification
|
||||||
|
getJoinedRoom(roomId)?.use { room ->
|
||||||
|
Timber.d("Notifying call start for room $roomId. Has room call: ${room.info().hasRoomCall}")
|
||||||
|
room.sendCallNotificationIfNeeded()
|
||||||
|
} ?: run {
|
||||||
|
Timber.w("No room found for session $sessionId and room $roomId, skipping call notification.")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendCallNotificationResult.fold(
|
||||||
|
onSuccess = { notifiedCallStart = true },
|
||||||
|
onFailure = { error ->
|
||||||
|
Timber.e(error, "Failed to send call notification for room $roomId.")
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseMessage(message: String): WidgetMessage? {
|
private fun parseMessage(message: String): WidgetMessage? {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@
|
||||||
package io.element.android.features.call.impl.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioDeviceCallback
|
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.ConsoleMessage
|
import android.webkit.ConsoleMessage
|
||||||
|
|
@ -28,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -35,17 +32,15 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.call.impl.R
|
import io.element.android.features.call.impl.R
|
||||||
import io.element.android.features.call.impl.pip.PictureInPictureEvents
|
import io.element.android.features.call.impl.pip.PictureInPictureEvents
|
||||||
import io.element.android.features.call.impl.pip.PictureInPictureState
|
import io.element.android.features.call.impl.pip.PictureInPictureState
|
||||||
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
|
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
|
||||||
import io.element.android.features.call.impl.pip.aPictureInPictureState
|
import io.element.android.features.call.impl.pip.aPictureInPictureState
|
||||||
|
import io.element.android.features.call.impl.utils.WebViewAudioManager
|
||||||
import io.element.android.features.call.impl.utils.WebViewPipController
|
import io.element.android.features.call.impl.utils.WebViewPipController
|
||||||
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.androidutils.compat.disableExternalAudioDevice
|
|
||||||
import io.element.android.libraries.androidutils.compat.enableExternalAudioDevice
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
|
@ -108,6 +103,8 @@ internal fun CallScreenView(
|
||||||
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
|
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
CallWebView(
|
CallWebView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
|
|
@ -120,25 +117,40 @@ internal fun CallScreenView(
|
||||||
val callback: RequestPermissionCallback = { request.grant(it) }
|
val callback: RequestPermissionCallback = { request.grant(it) }
|
||||||
requestPermissions(androidPermissions.toTypedArray(), callback)
|
requestPermissions(androidPermissions.toTypedArray(), callback)
|
||||||
},
|
},
|
||||||
onWebViewCreate = { webView ->
|
onCreateWebView = { webView ->
|
||||||
val interceptor = WebViewWidgetMessageInterceptor(
|
val interceptor = WebViewWidgetMessageInterceptor(
|
||||||
webView = webView,
|
webView = webView,
|
||||||
|
onUrlLoaded = { url ->
|
||||||
|
if (webViewAudioManager?.isInCallMode?.get() == false) {
|
||||||
|
Timber.d("URL $url is loaded, starting in-call audio mode")
|
||||||
|
webViewAudioManager?.onCallStarted()
|
||||||
|
} else {
|
||||||
|
Timber.d("Can't start in-call audio mode since the app is already in it.")
|
||||||
|
}
|
||||||
|
},
|
||||||
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
|
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
|
||||||
)
|
)
|
||||||
|
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
|
||||||
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
||||||
val pipController = WebViewPipController(webView)
|
val pipController = WebViewPipController(webView)
|
||||||
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
|
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
|
||||||
|
},
|
||||||
|
onDestroyWebView = {
|
||||||
|
// Reset audio mode
|
||||||
|
webViewAudioManager?.onCallStopped()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
when (state.urlState) {
|
when (state.urlState) {
|
||||||
AsyncData.Uninitialized,
|
AsyncData.Uninitialized,
|
||||||
is AsyncData.Loading ->
|
is AsyncData.Loading ->
|
||||||
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
||||||
is AsyncData.Failure ->
|
is AsyncData.Failure -> {
|
||||||
|
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
content = state.urlState.error.message.orEmpty(),
|
content = state.urlState.error.message.orEmpty(),
|
||||||
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
|
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is AsyncData.Success -> Unit
|
is AsyncData.Success -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +162,8 @@ private fun CallWebView(
|
||||||
url: AsyncData<String>,
|
url: AsyncData<String>,
|
||||||
userAgent: String,
|
userAgent: String,
|
||||||
onPermissionsRequest: (PermissionRequest) -> Unit,
|
onPermissionsRequest: (PermissionRequest) -> Unit,
|
||||||
onWebViewCreate: (WebView) -> Unit,
|
onCreateWebView: (WebView) -> Unit,
|
||||||
|
onDestroyWebView: (WebView) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (LocalInspectionMode.current) {
|
if (LocalInspectionMode.current) {
|
||||||
|
|
@ -158,13 +171,11 @@ private fun CallWebView(
|
||||||
Text("WebView - can't be previewed")
|
Text("WebView - can't be previewed")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
audioDeviceCallback = context.setupAudioConfiguration()
|
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
onWebViewCreate(this)
|
onCreateWebView(this)
|
||||||
setup(userAgent, onPermissionsRequest)
|
setup(userAgent, onPermissionsRequest)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -174,41 +185,13 @@ private fun CallWebView(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRelease = { webView ->
|
onRelease = { webView ->
|
||||||
// Reset audio mode
|
onDestroyWebView(webView)
|
||||||
webView.context.releaseAudioConfiguration(audioDeviceCallback)
|
|
||||||
webView.destroy()
|
webView.destroy()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.setupAudioConfiguration(): AudioDeviceCallback? {
|
|
||||||
val audioManager = getSystemService<AudioManager>() ?: return null
|
|
||||||
// Set 'voice call' mode so volume keys actually control the call volume
|
|
||||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
||||||
audioManager.enableExternalAudioDevice()
|
|
||||||
return object : AudioDeviceCallback() {
|
|
||||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
|
||||||
Timber.d("Audio devices added")
|
|
||||||
audioManager.enableExternalAudioDevice()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
|
||||||
Timber.d("Audio devices removed")
|
|
||||||
audioManager.enableExternalAudioDevice()
|
|
||||||
}
|
|
||||||
}.also {
|
|
||||||
audioManager.registerAudioDeviceCallback(it, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Context.releaseAudioConfiguration(audioDeviceCallback: AudioDeviceCallback?) {
|
|
||||||
val audioManager = getSystemService<AudioManager>() ?: return
|
|
||||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
|
||||||
audioManager.disableExternalAudioDevice()
|
|
||||||
audioManager.mode = AudioManager.MODE_NORMAL
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private fun WebView.setup(
|
private fun WebView.setup(
|
||||||
userAgent: String,
|
userAgent: String,
|
||||||
|
|
@ -242,6 +225,20 @@ private fun WebView.setup(
|
||||||
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
|
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
|
||||||
else -> Log.DEBUG
|
else -> Log.DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val message = buildString {
|
||||||
|
append(consoleMessage.sourceId())
|
||||||
|
append(":")
|
||||||
|
append(consoleMessage.lineNumber())
|
||||||
|
append(" ")
|
||||||
|
append(consoleMessage.message())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.contains("password=")) {
|
||||||
|
// Avoid logging any messages that contain "password" to prevent leaking sensitive information
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("WebView").log(
|
Timber.tag("WebView").log(
|
||||||
priority = priority,
|
priority = priority,
|
||||||
message = buildString {
|
message = buildString {
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,14 @@ class ElementCallActivity :
|
||||||
|
|
||||||
applicationContext.bindings<CallBindings>().inject(this)
|
applicationContext.bindings<CallBindings>().inject(this)
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
window.addFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
|
setShowWhenLocked(true)
|
||||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
} else {
|
||||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
@Suppress("DEPRECATION")
|
||||||
)
|
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
|
||||||
|
}
|
||||||
|
|
||||||
setCallType(intent)
|
setCallType(intent)
|
||||||
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
|
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
|
||||||
|
|
@ -97,6 +98,8 @@ class ElementCallActivity :
|
||||||
|
|
||||||
pictureInPicturePresenter.setPipView(this)
|
pictureInPicturePresenter.setPipView(this)
|
||||||
|
|
||||||
|
Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}")
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val pipState = pictureInPicturePresenter.present()
|
val pipState = pictureInPicturePresenter.present()
|
||||||
ListenToAndroidEvents(pipState)
|
ListenToAndroidEvents(pipState)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
@ -57,8 +58,8 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var buildMeta: BuildMeta
|
lateinit var buildMeta: BuildMeta
|
||||||
|
|
||||||
@Inject
|
@AppCoroutineScope
|
||||||
lateinit var appCoroutineScope: CoroutineScope
|
@Inject lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.api.CurrentCall
|
import io.element.android.features.call.api.CurrentCall
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||||
|
|
@ -86,6 +88,7 @@ interface ActiveCallManager {
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultActiveCallManager @Inject constructor(
|
class DefaultActiveCallManager @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
||||||
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||||
|
|
@ -180,6 +183,9 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timber.tag(tag).d("Hung up call: $callType")
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
if (activeWakeLock?.isHeld == true) {
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after hang up")
|
Timber.tag(tag).d("Releasing partial wakelock after hang up")
|
||||||
|
|
@ -190,6 +196,8 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
|
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
|
||||||
|
Timber.tag(tag).d("Joined call: $callType")
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
if (activeWakeLock?.isHeld == true) {
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after joining call")
|
Timber.tag(tag).d("Releasing partial wakelock after joining call")
|
||||||
|
|
@ -218,7 +226,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
timestamp = notificationData.timestamp,
|
timestamp = notificationData.timestamp,
|
||||||
textContent = notificationData.textContent,
|
textContent = notificationData.textContent,
|
||||||
) ?: return
|
) ?: return
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
notificationManagerCompat.notify(
|
notificationManagerCompat.notify(
|
||||||
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
|
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
|
||||||
notification,
|
notification,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.call.impl.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -33,7 +34,7 @@ class DefaultCallWidgetProvider @Inject constructor(
|
||||||
clientId: String,
|
clientId: String,
|
||||||
languageTag: String?,
|
languageTag: String?,
|
||||||
theme: String?,
|
theme: String?,
|
||||||
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
|
): Result<CallWidgetProvider.GetWidgetResult> = runCatchingExceptions {
|
||||||
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
|
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
|
||||||
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
|
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
|
||||||
?: matrixClient.getJoinedRoom(roomId)
|
?: matrixClient.getJoinedRoom(roomId)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
class WebViewWidgetMessageInterceptor(
|
||||||
private val webView: WebView,
|
private val webView: WebView,
|
||||||
|
private val onUrlLoaded: (String) -> Unit,
|
||||||
private val onError: (String?) -> Unit,
|
private val onError: (String?) -> Unit,
|
||||||
) : WidgetMessageInterceptor {
|
) : WidgetMessageInterceptor {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -44,13 +45,13 @@ class WebViewWidgetMessageInterceptor(
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
webView.webViewClient = object : WebViewClient() {
|
webView.webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||||
super.onPageStarted(view, url, favicon)
|
super.onPageStarted(view, url, favicon)
|
||||||
|
|
||||||
// Due to https://github.com/element-hq/element-x-android/issues/4097
|
// Due to https://github.com/element-hq/element-x-android/issues/4097
|
||||||
// we need to supply a logging implementation that correctly includes
|
// we need to supply a logging implementation that correctly includes
|
||||||
// objects in log lines.
|
// objects in log lines.
|
||||||
view?.evaluateJavascript(
|
view.evaluateJavascript(
|
||||||
"""
|
"""
|
||||||
function logFn(consoleLogFn, ...args) {
|
function logFn(consoleLogFn, ...args) {
|
||||||
consoleLogFn(
|
consoleLogFn(
|
||||||
|
|
@ -72,7 +73,7 @@ class WebViewWidgetMessageInterceptor(
|
||||||
// This listener will receive both messages:
|
// This listener will receive both messages:
|
||||||
// - EC widget API -> Element X (message.data.api == "fromWidget")
|
// - EC widget API -> Element X (message.data.api == "fromWidget")
|
||||||
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
|
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
|
||||||
view?.evaluateJavascript(
|
view.evaluateJavascript(
|
||||||
"""
|
"""
|
||||||
window.addEventListener('message', function(event) {
|
window.addEventListener('message', function(event) {
|
||||||
let message = {data: event.data, origin: event.origin}
|
let message = {data: event.data, origin: event.origin}
|
||||||
|
|
@ -90,6 +91,10 @@ class WebViewWidgetMessageInterceptor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
|
onUrlLoaded(url)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
||||||
// No network for instance, transmit the error
|
// No network for instance, transmit the error
|
||||||
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
|
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,14 @@
|
||||||
package io.element.android.features.call.impl.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import io.element.android.features.call.impl.data.WidgetMessage
|
import io.element.android.features.call.impl.data.WidgetMessage
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
object WidgetMessageSerializer {
|
object WidgetMessageSerializer {
|
||||||
private val coder = Json { ignoreUnknownKeys = true }
|
private val coder = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun deserialize(message: String): Result<WidgetMessage> {
|
fun deserialize(message: String): Result<WidgetMessage> {
|
||||||
return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) }
|
return runCatchingExceptions { coder.decodeFromString(WidgetMessage.serializer(), message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun serialize(message: WidgetMessage): String {
|
fun serialize(message: WidgetMessage): String {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
aCreateRoomRootState(
|
aCreateRoomRootState(
|
||||||
startDmAction = AsyncAction.Failure(Throwable("error")),
|
startDmAction = AsyncAction.Failure(RuntimeException("error")),
|
||||||
userListState = aMatrixUser().let {
|
userListState = aMatrixUser().let {
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchQuery = it.userId.value,
|
searchQuery = it.userId.value,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUse
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -27,7 +27,7 @@ class DefaultStartDMActionTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when dm is found, assert state is updated with given room id`() = runTest {
|
fun `when dm is found, assert state is updated with given room id`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
givenFindDmResult(A_ROOM_ID)
|
givenFindDmResult(Result.success(A_ROOM_ID))
|
||||||
}
|
}
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val action = createStartDMAction(matrixClient, analyticsService)
|
val action = createStartDMAction(matrixClient, analyticsService)
|
||||||
|
|
@ -37,10 +37,23 @@ class DefaultStartDMActionTest {
|
||||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when finding the dm fails, assert state is updated with given error`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenFindDmResult(Result.failure(AN_EXCEPTION))
|
||||||
|
}
|
||||||
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
val action = createStartDMAction(matrixClient, analyticsService)
|
||||||
|
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||||
|
action.execute(aMatrixUser(), true, state)
|
||||||
|
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
|
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
|
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
givenFindDmResult(null)
|
givenFindDmResult(Result.success(null))
|
||||||
givenCreateDmResult(Result.success(A_ROOM_ID))
|
givenCreateDmResult(Result.success(A_ROOM_ID))
|
||||||
}
|
}
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
|
@ -54,7 +67,7 @@ class DefaultStartDMActionTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
|
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
givenFindDmResult(null)
|
givenFindDmResult(Result.success(null))
|
||||||
givenCreateDmResult(Result.success(A_ROOM_ID))
|
givenCreateDmResult(Result.success(A_ROOM_ID))
|
||||||
}
|
}
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
|
|
@ -69,14 +82,14 @@ class DefaultStartDMActionTest {
|
||||||
@Test
|
@Test
|
||||||
fun `when dm creation fails, assert state is updated with given error`() = runTest {
|
fun `when dm creation fails, assert state is updated with given error`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
givenFindDmResult(null)
|
givenFindDmResult(Result.success(null))
|
||||||
givenCreateDmResult(Result.failure(A_THROWABLE))
|
givenCreateDmResult(Result.failure(AN_EXCEPTION))
|
||||||
}
|
}
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val action = createStartDMAction(matrixClient, analyticsService)
|
val action = createStartDMAction(matrixClient, analyticsService)
|
||||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||||
action.execute(aMatrixUser(), true, state)
|
action.execute(aMatrixUser(), true, state)
|
||||||
assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||||
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
|
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
|
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
|
@ -274,7 +274,7 @@ class ConfigureBaseRoomPresenterTest {
|
||||||
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
|
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
|
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
|
||||||
matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
|
matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
|
||||||
|
|
||||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
|
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
|
@ -298,7 +298,7 @@ class ConfigureBaseRoomPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = initialState()
|
val initialState = initialState()
|
||||||
val createRoomResult = Result.failure<RoomId>(A_THROWABLE)
|
val createRoomResult = Result.failure<RoomId>(AN_EXCEPTION)
|
||||||
|
|
||||||
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
|
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.createroom.impl.R
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.setSafeContent
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -56,7 +57,7 @@ class JoinBaseRoomByAddressViewTest {
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
|
||||||
state: JoinRoomByAddressState,
|
state: JoinRoomByAddressState,
|
||||||
) {
|
) {
|
||||||
setContent {
|
setSafeContent {
|
||||||
JoinRoomByAddressView(state = state)
|
JoinRoomByAddressView(state = state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -42,7 +42,7 @@ class CreateBaseRoomRootPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - start DM action failure scenario`() = runTest {
|
fun `present - start DM action failure scenario`() = runTest {
|
||||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION)
|
||||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||||
actionState.value = startDMFailureResult
|
actionState.value = startDMFailureResult
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.autofill.AutofillType
|
import androidx.compose.ui.autofill.ContentType
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentType
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
|
@ -54,7 +56,6 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.components.list.SwitchListItem
|
import io.element.android.libraries.designsystem.components.list.SwitchListItem
|
||||||
import io.element.android.libraries.designsystem.modifiers.autofill
|
|
||||||
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
|
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -276,14 +277,9 @@ private fun Content(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onTabOrEnterKeyFocusNext(focusManager)
|
.onTabOrEnterKeyFocusNext(focusManager)
|
||||||
.testTag(TestTags.loginPassword)
|
.testTag(TestTags.loginPassword)
|
||||||
.autofill(
|
.semantics {
|
||||||
autofillTypes = listOf(AutofillType.Password),
|
contentType = ContentType.Password
|
||||||
onFill = {
|
},
|
||||||
val sanitized = it.sanitize()
|
|
||||||
passwordFieldState = sanitized
|
|
||||||
eventSink(AccountDeactivationEvents.SetPassword(sanitized))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
val sanitized = it.sanitize()
|
val sanitized = it.sanitize()
|
||||||
passwordFieldState = sanitized
|
passwordFieldState = sanitized
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -27,6 +28,7 @@ import kotlinx.coroutines.launch
|
||||||
class NotificationsOptInPresenter @AssistedInject constructor(
|
class NotificationsOptInPresenter @AssistedInject constructor(
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
@Assisted private val callback: NotificationsOptInNode.Callback,
|
@Assisted private val callback: NotificationsOptInNode.Callback,
|
||||||
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val permissionStateProvider: PermissionStateProvider,
|
private val permissionStateProvider: PermissionStateProvider,
|
||||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
anAcceptDeclineInviteState(
|
anAcceptDeclineInviteState(
|
||||||
acceptAction = AsyncAction.Failure(Throwable("Error while accepting invite")),
|
acceptAction = AsyncAction.Failure(RuntimeException("Error while accepting invite")),
|
||||||
),
|
),
|
||||||
anAcceptDeclineInviteState(
|
anAcceptDeclineInviteState(
|
||||||
declineAction = AsyncAction.Failure(Throwable("Error while declining invite")),
|
declineAction = AsyncAction.Failure(RuntimeException("Error while declining invite")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class DefaultAcceptInviteTest {
|
||||||
fun `accept invite failure scenario`() = runTest {
|
fun `accept invite failure scenario`() = runTest {
|
||||||
val joinRoomLambda =
|
val joinRoomLambda =
|
||||||
lambdaRecorder<RoomIdOrAlias, List<String>, JoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
|
lambdaRecorder<RoomIdOrAlias, List<String>, JoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
|
||||||
Result.failure(Throwable("Join room failed"))
|
Result.failure(RuntimeException("Join room failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val acceptInvite = DefaultAcceptInvite(
|
val acceptInvite = DefaultAcceptInvite(
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,8 @@ import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
|
||||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||||
import io.element.android.libraries.matrix.api.exception.ErrorKind
|
import io.element.android.libraries.matrix.api.exception.ErrorKind
|
||||||
import io.element.android.libraries.matrix.api.getRoomInfoFlow
|
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
@ -88,7 +86,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var retryCount by remember { mutableIntStateOf(0) }
|
var retryCount by remember { mutableIntStateOf(0) }
|
||||||
val roomInfo by remember {
|
val roomInfo by remember {
|
||||||
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
|
matrixClient.getRoomInfoFlow(roomId)
|
||||||
}.collectAsState(initial = Optional.empty())
|
}.collectAsState(initial = Optional.empty())
|
||||||
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomPreview
|
import io.element.android.libraries.matrix.test.room.aRoomPreview
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
|
import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
|
||||||
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
||||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||||
|
|
@ -88,12 +88,12 @@ class JoinRoomPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is joined then content state is filled with his data`() = runTest {
|
fun `present - when room is joined then content state is filled with his data`() = runTest {
|
||||||
val roomSummary = aRoomSummary()
|
val roomInfo = aRoomInfo()
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
@ -104,24 +104,24 @@ class JoinRoomPresenterTest {
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
val contentState = state.contentState as ContentState.Loaded
|
val contentState = state.contentState as ContentState.Loaded
|
||||||
assertThat(contentState.roomId).isEqualTo(A_ROOM_ID)
|
assertThat(contentState.roomId).isEqualTo(A_ROOM_ID)
|
||||||
assertThat(contentState.name).isEqualTo(roomSummary.info.name)
|
assertThat(contentState.name).isEqualTo(roomInfo.name)
|
||||||
assertThat(contentState.topic).isEqualTo(roomSummary.info.topic)
|
assertThat(contentState.topic).isEqualTo(roomInfo.topic)
|
||||||
assertThat(contentState.alias).isEqualTo(roomSummary.info.canonicalAlias)
|
assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias)
|
||||||
assertThat(contentState.numberOfMembers).isEqualTo(roomSummary.info.joinedMembersCount)
|
assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.joinedMembersCount)
|
||||||
assertThat(contentState.isDm).isEqualTo(roomSummary.info.isDirect)
|
assertThat(contentState.isDm).isEqualTo(roomInfo.isDirect)
|
||||||
assertThat(contentState.roomAvatarUrl).isEqualTo(roomSummary.info.avatarUrl)
|
assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is invited then join authorization is equal to invited`() = runTest {
|
fun `present - when room is invited then join authorization is equal to invited`() = runTest {
|
||||||
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED)
|
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val seenInvitesStore = InMemorySeenInvitesStore()
|
val seenInvitesStore = InMemorySeenInvitesStore()
|
||||||
|
|
@ -129,7 +129,7 @@ class JoinRoomPresenterTest {
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
seenInvitesStore = seenInvitesStore,
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
val inviteData = roomSummary.info.toInviteData()
|
val inviteData = roomInfo.toInviteData()
|
||||||
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
|
|
@ -137,7 +137,7 @@ class JoinRoomPresenterTest {
|
||||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null))
|
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null))
|
||||||
}
|
}
|
||||||
// Check that the roomId is stored in the seen invites store
|
// Check that the roomId is stored in the seen invites store
|
||||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomInfo.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,17 +145,17 @@ class JoinRoomPresenterTest {
|
||||||
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
|
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
|
||||||
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
|
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
|
||||||
val expectedInviteSender = inviter.toInviteSender()
|
val expectedInviteSender = inviter.toInviteSender()
|
||||||
val roomSummary = aRoomSummary(
|
val roomInfo = aRoomInfo(
|
||||||
currentUserMembership = CurrentUserMembership.INVITED,
|
currentUserMembership = CurrentUserMembership.INVITED,
|
||||||
joinedMembersCount = 5,
|
joinedMembersCount = 5,
|
||||||
inviter = inviter,
|
inviter = inviter,
|
||||||
)
|
)
|
||||||
val inviteData = roomSummary.info.toInviteData()
|
val inviteData = roomInfo.toInviteData()
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
@ -172,7 +172,7 @@ class JoinRoomPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is invited read the number of member from the room preview`() = runTest {
|
fun `present - when room is invited read the number of member from the room preview`() = runTest {
|
||||||
val roomSummary = aRoomSummary(
|
val roomInfo = aRoomInfo(
|
||||||
currentUserMembership = CurrentUserMembership.INVITED,
|
currentUserMembership = CurrentUserMembership.INVITED,
|
||||||
// It seems that the SDK does not provide this value.
|
// It seems that the SDK does not provide this value.
|
||||||
joinedMembersCount = 0,
|
joinedMembersCount = 0,
|
||||||
|
|
@ -188,8 +188,8 @@ class JoinRoomPresenterTest {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
@ -209,13 +209,13 @@ class JoinRoomPresenterTest {
|
||||||
val acceptDeclinePresenter = Presenter {
|
val acceptDeclinePresenter = Presenter {
|
||||||
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
|
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
|
||||||
}
|
}
|
||||||
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED)
|
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val inviteData = roomSummary.info.toInviteData()
|
val inviteData = roomInfo.toInviteData()
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
acceptDeclineInvitePresenter = acceptDeclinePresenter
|
acceptDeclineInvitePresenter = acceptDeclinePresenter
|
||||||
|
|
@ -324,7 +324,7 @@ class JoinRoomPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest {
|
fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest {
|
||||||
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public)
|
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public)
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ ->
|
getNotJoinedRoomResult = { _, _ ->
|
||||||
Result.success(
|
Result.success(
|
||||||
|
|
@ -346,8 +346,8 @@ class JoinRoomPresenterTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
@ -369,12 +369,12 @@ class JoinRoomPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest {
|
fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest {
|
||||||
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public)
|
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public)
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
@ -390,12 +390,12 @@ class JoinRoomPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest {
|
fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest {
|
||||||
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null)
|
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null)
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) },
|
||||||
).apply {
|
).apply {
|
||||||
getRoomSummaryFlowLambda = { _ ->
|
getRoomInfoFlowLambda = { _ ->
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomInfo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import io.element.android.features.knockrequests.impl.data.KnockRequestPresentab
|
||||||
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.mapState
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -28,7 +29,8 @@ private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
|
||||||
|
|
||||||
class KnockRequestsBannerPresenter @Inject constructor(
|
class KnockRequestsBannerPresenter @Inject constructor(
|
||||||
private val knockRequestsService: KnockRequestsService,
|
private val knockRequestsService: KnockRequestsService,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<KnockRequestsBannerState> {
|
) : Presenter<KnockRequestsBannerState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): KnockRequestsBannerState {
|
override fun present(): KnockRequestsBannerState {
|
||||||
|
|
@ -52,13 +54,13 @@ class KnockRequestsBannerPresenter @Inject constructor(
|
||||||
fun handleEvents(event: KnockRequestsBannerEvents) {
|
fun handleEvents(event: KnockRequestsBannerEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is KnockRequestsBannerEvents.AcceptSingleRequest -> {
|
is KnockRequestsBannerEvents.AcceptSingleRequest -> {
|
||||||
appCoroutineScope.acceptSingleKnockRequest(
|
sessionCoroutineScope.acceptSingleKnockRequest(
|
||||||
knockRequests = knockRequests,
|
knockRequests = knockRequests,
|
||||||
displayAcceptError = showAcceptError,
|
displayAcceptError = showAcceptError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is KnockRequestsBannerEvents.Dismiss -> {
|
is KnockRequestsBannerEvents.Dismiss -> {
|
||||||
appCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
knockRequestsService.markAllKnockRequestsAsSeen()
|
knockRequestsService.markAllKnockRequestsAsSeen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
|
||||||
canBan = true,
|
canBan = true,
|
||||||
),
|
),
|
||||||
currentAction = KnockRequestsAction.AcceptAll,
|
currentAction = KnockRequestsAction.AcceptAll,
|
||||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
|
||||||
),
|
),
|
||||||
aKnockRequestsListState(
|
aKnockRequestsListState(
|
||||||
knockRequests = AsyncData.Success(
|
knockRequests = AsyncData.Success(
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,6 @@ private fun TestScope.createKnockRequestsBannerPresenter(
|
||||||
)
|
)
|
||||||
return KnockRequestsBannerPresenter(
|
return KnockRequestsBannerPresenter(
|
||||||
knockRequestsService = knockRequestsService,
|
knockRequestsService = knockRequestsService,
|
||||||
appCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ class KnockRequestsListViewTest {
|
||||||
rule.setKnockRequestsListView(
|
rule.setKnockRequestsListView(
|
||||||
aKnockRequestsListState(
|
aKnockRequestsListState(
|
||||||
knockRequests = AsyncData.Success(knockRequests),
|
knockRequests = AsyncData.Success(knockRequests),
|
||||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
|
||||||
currentAction = KnockRequestsAction.AcceptAll,
|
currentAction = KnockRequestsAction.AcceptAll,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
|
|
@ -124,7 +124,7 @@ class KnockRequestsListViewTest {
|
||||||
rule.setKnockRequestsListView(
|
rule.setKnockRequestsListView(
|
||||||
aKnockRequestsListState(
|
aKnockRequestsListState(
|
||||||
knockRequests = AsyncData.Success(knockRequests),
|
knockRequests = AsyncData.Success(knockRequests),
|
||||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
|
||||||
currentAction = KnockRequestsAction.AcceptAll,
|
currentAction = KnockRequestsAction.AcceptAll,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.licenses.impl.LicensesProvider
|
||||||
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
|
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -34,7 +35,7 @@ class DependencyLicensesListPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
var filter by remember { mutableStateOf("") }
|
var filter by remember { mutableStateOf("") }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
|
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
licenses = AsyncData.Failure(it)
|
licenses = AsyncData.Failure(it)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.core.net.toUri
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -26,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : LocationActions {
|
) : LocationActions {
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
val uri = buildUrl(location, label).toUri()
|
val uri = buildUrl(location, label).toUri()
|
||||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -46,8 +48,6 @@ import io.element.android.libraries.designsystem.theme.components.FloatingAction
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
|
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
import io.element.android.libraries.maplibre.compose.CameraMode
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
|
|
@ -42,6 +43,7 @@ class DefaultLockScreenService @Inject constructor(
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val lockScreenStore: LockScreenStore,
|
private val lockScreenStore: LockScreenStore,
|
||||||
private val pinCodeManager: PinCodeManager,
|
private val pinCodeManager: PinCodeManager,
|
||||||
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val sessionObserver: SessionObserver,
|
private val sessionObserver: SessionObserver,
|
||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.biometric.BiometricPrompt.CryptoObject
|
||||||
import androidx.biometric.BiometricPrompt.PromptInfo
|
import androidx.biometric.BiometricPrompt.PromptInfo
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
@ -119,7 +120,7 @@ private class AuthenticationCallback(
|
||||||
|
|
||||||
private fun Cipher?.isValid(): Boolean {
|
private fun Cipher?.isValid(): Boolean {
|
||||||
if (this == null) return false
|
if (this == null) return false
|
||||||
return runCatching {
|
return runCatchingExceptions {
|
||||||
doFinal("biometric_challenge".toByteArray())
|
doFinal("biometric_challenge".toByteArray())
|
||||||
}.isSuccess
|
}.isSuccess
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -47,6 +48,7 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
|
||||||
private val lockScreenConfig: LockScreenConfig,
|
private val lockScreenConfig: LockScreenConfig,
|
||||||
private val encryptionDecryptionService: EncryptionDecryptionService,
|
private val encryptionDecryptionService: EncryptionDecryptionService,
|
||||||
private val secretKeyRepository: SecretKeyRepository,
|
private val secretKeyRepository: SecretKeyRepository,
|
||||||
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
) : BiometricAuthenticatorManager {
|
) : BiometricAuthenticatorManager {
|
||||||
private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>()
|
private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>()
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricAuthentica
|
||||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -29,6 +30,7 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||||
private val pinCodeManager: PinCodeManager,
|
private val pinCodeManager: PinCodeManager,
|
||||||
private val lockScreenStore: LockScreenStore,
|
private val lockScreenStore: LockScreenStore,
|
||||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||||
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
) : Presenter<LockScreenSettingsState> {
|
) : Presenter<LockScreenSettingsState> {
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -34,6 +35,7 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
private val pinCodeManager: PinCodeManager,
|
private val pinCodeManager: PinCodeManager,
|
||||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||||
private val logoutUseCase: LogoutUseCase,
|
private val logoutUseCase: LogoutUseCase,
|
||||||
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val pinUnlockHelper: PinUnlockHelper,
|
private val pinUnlockHelper: PinUnlockHelper,
|
||||||
) : Presenter<PinUnlockState> {
|
) : Presenter<PinUnlockState> {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import io.element.android.features.login.impl.screens.createaccount.CreateAccoun
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
|
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
|
||||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
||||||
|
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
@ -45,7 +46,6 @@ import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.oidc.api.OidcAction
|
import io.element.android.libraries.oidc.api.OidcAction
|
||||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||||
import io.element.android.libraries.oidc.api.OidcEntryPoint
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -57,7 +57,6 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
private val oidcEntryPoint: OidcEntryPoint,
|
|
||||||
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.OnBoarding,
|
initialElement = NavTarget.OnBoarding,
|
||||||
|
|
@ -74,15 +73,15 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
private var darkTheme: Boolean = false
|
private var darkTheme: Boolean = false
|
||||||
|
|
||||||
private var customChromeTabStarted = false
|
private var externalAppStarted = false
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
defaultLoginUserStory.setLoginFlowIsDone(false)
|
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onResume = {
|
onResume = {
|
||||||
if (customChromeTabStarted) {
|
if (externalAppStarted) {
|
||||||
customChromeTabStarted = false
|
externalAppStarted = false
|
||||||
// Workaround to detect that the Custom Chrome Tab has been closed
|
// Workaround to detect that the Custom Chrome Tab has been closed
|
||||||
// If there is no coming OidcAction (that would end this Node),
|
// If there is no coming OidcAction (that would end this Node),
|
||||||
// consider that the user has cancelled the login
|
// consider that the user has cancelled the login
|
||||||
|
|
@ -122,9 +121,6 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class CreateAccount(val url: String) : NavTarget
|
data class CreateAccount(val url: String) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -249,9 +245,6 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
NavTarget.LoginPassword -> {
|
NavTarget.LoginPassword -> {
|
||||||
createNode<LoginPasswordNode>(buildContext)
|
createNode<LoginPasswordNode>(buildContext)
|
||||||
}
|
}
|
||||||
is NavTarget.OidcView -> {
|
|
||||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
|
||||||
}
|
|
||||||
is NavTarget.CreateAccount -> {
|
is NavTarget.CreateAccount -> {
|
||||||
val inputs = CreateAccountNode.Inputs(
|
val inputs = CreateAccountNode.Inputs(
|
||||||
url = navTarget.url,
|
url = navTarget.url,
|
||||||
|
|
@ -262,15 +255,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMas(oidcDetails: OidcDetails) {
|
private fun navigateToMas(oidcDetails: OidcDetails) {
|
||||||
if (oidcEntryPoint.canUseCustomTab()) {
|
activity?.let {
|
||||||
// In this case open a Chrome Custom tab
|
externalAppStarted = true
|
||||||
activity?.let {
|
it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url)
|
||||||
customChromeTabStarted = true
|
|
||||||
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to WebView mode
|
|
||||||
backstack.push(NavTarget.OidcView(oidcDetails))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ fun LoginModeView(
|
||||||
LoginMode.PasswordLogin -> onNeedLoginPassword()
|
LoginMode.PasswordLogin -> onNeedLoginPassword()
|
||||||
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
|
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
|
||||||
}
|
}
|
||||||
|
// Also clear the data, to let the next screen be able to go back
|
||||||
|
onClearError()
|
||||||
}
|
}
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ fun ConfirmAccountProviderView(
|
||||||
TextButton(
|
TextButton(
|
||||||
text = stringResource(id = R.string.screen_account_provider_change),
|
text = stringResource(id = R.string.screen_account_provider_change),
|
||||||
onClick = onChange,
|
onClick = onChange,
|
||||||
enabled = true,
|
enabled = !isLoading,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag(TestTags.loginChangeServer)
|
.testTag(TestTags.loginChangeServer)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.core.extensions.flatMap
|
import io.element.android.libraries.core.extensions.flatMap
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -74,7 +75,7 @@ class CreateAccountPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
|
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
|
||||||
loggedInState.value = AsyncAction.Loading
|
loggedInState.value = AsyncAction.Loading
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
messageParser.parse(message)
|
messageParser.parse(message)
|
||||||
}.flatMap { externalSession ->
|
}.flatMap { externalSession ->
|
||||||
authenticationService.importCreatedSession(externalSession)
|
authenticationService.importCreatedSession(externalSession)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountSt
|
||||||
aCreateAccountState(),
|
aCreateAccountState(),
|
||||||
aCreateAccountState(pageProgress = 33),
|
aCreateAccountState(pageProgress = 33),
|
||||||
aCreateAccountState(createAction = AsyncAction.Loading),
|
aCreateAccountState(createAction = AsyncAction.Loading),
|
||||||
aCreateAccountState(createAction = AsyncAction.Failure(Throwable("Failed to create account"))),
|
aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.loginpassword
|
package io.element.android.features.login.impl.screens.loginpassword
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -30,10 +31,13 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.autofill.AutofillType
|
import androidx.compose.ui.autofill.ContentType
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.platform.LocalAutofillManager
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentType
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
|
@ -51,7 +55,6 @@ import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.modifiers.autofill
|
|
||||||
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
|
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -71,6 +74,13 @@ fun LoginPasswordView(
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val autofillManager = LocalAutofillManager.current
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
autofillManager?.cancel()
|
||||||
|
onBackClick()
|
||||||
|
}
|
||||||
|
|
||||||
val isLoading by remember(state.loginAction) {
|
val isLoading by remember(state.loginAction) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
state.loginAction is AsyncData.Loading
|
state.loginAction is AsyncData.Loading
|
||||||
|
|
@ -82,6 +92,8 @@ fun LoginPasswordView(
|
||||||
// Clear focus to prevent keyboard issues with textfields
|
// Clear focus to prevent keyboard issues with textfields
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
|
|
||||||
|
autofillManager?.commit()
|
||||||
|
|
||||||
state.eventSink(LoginPasswordEvents.Submit)
|
state.eventSink(LoginPasswordEvents.Submit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +102,12 @@ fun LoginPasswordView(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
navigationIcon = {
|
||||||
|
BackButton(onClick = {
|
||||||
|
autofillManager?.cancel()
|
||||||
|
onBackClick()
|
||||||
|
})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -175,14 +192,9 @@ private fun LoginForm(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onTabOrEnterKeyFocusNext(focusManager)
|
.onTabOrEnterKeyFocusNext(focusManager)
|
||||||
.testTag(TestTags.loginEmailUsername)
|
.testTag(TestTags.loginEmailUsername)
|
||||||
.autofill(
|
.semantics {
|
||||||
autofillTypes = listOf(AutofillType.Username),
|
contentType = ContentType.Username
|
||||||
onFill = {
|
},
|
||||||
val sanitized = it.sanitize()
|
|
||||||
loginFieldState = sanitized
|
|
||||||
eventSink(LoginPasswordEvents.SetLogin(sanitized))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
placeholder = stringResource(CommonStrings.common_username),
|
placeholder = stringResource(CommonStrings.common_username),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
val sanitized = it.sanitize()
|
val sanitized = it.sanitize()
|
||||||
|
|
@ -227,14 +239,9 @@ private fun LoginForm(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onTabOrEnterKeyFocusNext(focusManager)
|
.onTabOrEnterKeyFocusNext(focusManager)
|
||||||
.testTag(TestTags.loginPassword)
|
.testTag(TestTags.loginPassword)
|
||||||
.autofill(
|
.semantics {
|
||||||
autofillTypes = listOf(AutofillType.Password),
|
contentType = ContentType.Password
|
||||||
onFill = {
|
},
|
||||||
val sanitized = it.sanitize()
|
|
||||||
passwordFieldState = sanitized
|
|
||||||
eventSink(LoginPasswordEvents.SetPassword(sanitized))
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
val sanitized = it.sanitize()
|
val sanitized = it.sanitize()
|
||||||
passwordFieldState = sanitized
|
passwordFieldState = sanitized
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class ErrorFormatterTest {
|
||||||
// region loginError
|
// region loginError
|
||||||
@Test
|
@Test
|
||||||
fun `loginError - invalid unknown error returns unknown error message`() {
|
fun `loginError - invalid unknown error returns unknown error message`() {
|
||||||
val error = Throwable("Some unknown error")
|
val error = RuntimeException("Some unknown error")
|
||||||
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
|
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.uri.ensureProtocol
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
||||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
|
@ -113,7 +113,7 @@ class ChooseAccountProviderPresenterTest {
|
||||||
}
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
|
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
|
||||||
authenticationService.givenChangeServerError(A_THROWABLE)
|
authenticationService.givenChangeServerError(AN_EXCEPTION)
|
||||||
it.eventSink(ChooseAccountProviderEvents.Continue)
|
it.eventSink(ChooseAccountProviderEvents.Continue)
|
||||||
skipItems(1) // Loading
|
skipItems(1) // Loading
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticat
|
||||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.oidc.api.OidcAction
|
import io.element.android.libraries.oidc.api.OidcAction
|
||||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||||
|
|
@ -118,7 +118,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
assertThat(successState.submitEnabled).isFalse()
|
assertThat(successState.submitEnabled).isFalse()
|
||||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||||
authenticationService.givenOidcCancelError(A_THROWABLE)
|
authenticationService.givenOidcCancelError(AN_EXCEPTION)
|
||||||
defaultOidcActionFlow.post(OidcAction.GoBack)
|
defaultOidcActionFlow.post(OidcAction.GoBack)
|
||||||
val cancelFailureState = awaitItem()
|
val cancelFailureState = awaitItem()
|
||||||
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||||
|
|
@ -173,7 +173,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
assertThat(successState.submitEnabled).isFalse()
|
assertThat(successState.submitEnabled).isFalse()
|
||||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||||
authenticationService.givenLoginError(A_THROWABLE)
|
authenticationService.givenLoginError(AN_EXCEPTION)
|
||||||
defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
|
defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
|
||||||
val cancelLoadingState = awaitItem()
|
val cancelLoadingState = awaitItem()
|
||||||
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
|
|
@ -225,7 +225,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
authenticationService.givenChangeServerError(Throwable())
|
authenticationService.givenChangeServerError(RuntimeException())
|
||||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||||
skipItems(1) // Loading
|
skipItems(1) // Loading
|
||||||
val failureState = awaitItem()
|
val failureState = awaitItem()
|
||||||
|
|
@ -246,7 +246,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Submit will return an error
|
// Submit will return an error
|
||||||
authenticationService.givenChangeServerError(A_THROWABLE)
|
authenticationService.givenChangeServerError(AN_EXCEPTION)
|
||||||
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
|
||||||
skipItems(1) // Loading
|
skipItems(1) // Loading
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,14 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
|
@ -89,12 +92,16 @@ class CreateAccountPresenterTest {
|
||||||
defaultLoginUserStory.setLoginFlowIsDone(false)
|
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||||
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
|
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
|
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService()
|
||||||
|
val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService)
|
||||||
|
val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
authenticationService = FakeMatrixAuthenticationService(
|
authenticationService = FakeMatrixAuthenticationService(
|
||||||
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
|
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
|
||||||
),
|
),
|
||||||
messageParser = FakeMessageParser(lambda),
|
messageParser = FakeMessageParser(lambda),
|
||||||
defaultLoginUserStory = defaultLoginUserStory,
|
defaultLoginUserStory = defaultLoginUserStory,
|
||||||
|
clientProvider = clientProvider,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -102,6 +109,7 @@ class CreateAccountPresenterTest {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
|
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
|
||||||
assertThat(awaitItem().createAction.isLoading()).isTrue()
|
assertThat(awaitItem().createAction.isLoading()).isTrue()
|
||||||
|
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
|
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
|
||||||
}
|
}
|
||||||
lambda.assertions().isCalledOnce().with(value("aMessage"))
|
lambda.assertions().isCalledOnce().with(value("aMessage"))
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -96,12 +96,12 @@ class LoginPasswordPresenterTest {
|
||||||
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loginAndPasswordState = awaitItem()
|
val loginAndPasswordState = awaitItem()
|
||||||
authenticationService.givenLoginError(A_THROWABLE)
|
authenticationService.givenLoginError(AN_EXCEPTION)
|
||||||
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
|
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
|
||||||
val submitState = awaitItem()
|
val submitState = awaitItem()
|
||||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
val loggedInState = awaitItem()
|
val loggedInState = awaitItem()
|
||||||
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE))
|
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(AN_EXCEPTION))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,13 +117,13 @@ class LoginPasswordPresenterTest {
|
||||||
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loginAndPasswordState = awaitItem()
|
val loginAndPasswordState = awaitItem()
|
||||||
authenticationService.givenLoginError(A_THROWABLE)
|
authenticationService.givenLoginError(AN_EXCEPTION)
|
||||||
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
|
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
|
||||||
val submitState = awaitItem()
|
val submitState = awaitItem()
|
||||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
val loggedInState = awaitItem()
|
val loggedInState = awaitItem()
|
||||||
// Check an error was returned
|
// Check an error was returned
|
||||||
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE))
|
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(AN_EXCEPTION))
|
||||||
// Assert the error is then cleared
|
// Assert the error is then cleared
|
||||||
loggedInState.eventSink(LoginPasswordEvents.ClearError)
|
loggedInState.eventSink(LoginPasswordEvents.ClearError)
|
||||||
val clearedState = awaitItem()
|
val clearedState = awaitItem()
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,21 @@
|
||||||
package io.element.android.features.login.impl.screens.loginpassword
|
package io.element.android.features.login.impl.screens.loginpassword
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
|
@ -120,15 +124,15 @@ class LoginPasswordViewTest {
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
|
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
|
||||||
// Show password
|
// Show password
|
||||||
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
|
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
|
||||||
rule.onNodeWithContentDescription(a11yShowPassword).performClick()
|
rule.onNodeWithContentDescription(a11yShowPassword).performClick()
|
||||||
rule.onNodeWithText(A_PASSWORD).assertExists()
|
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
|
||||||
// Hide password
|
// Hide password
|
||||||
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
|
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
|
||||||
rule.onNodeWithContentDescription(a11yHidePassword).performClick()
|
rule.onNodeWithContentDescription(a11yHidePassword).performClick()
|
||||||
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
|
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
|
||||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
|
||||||
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
|
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
|
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||||
|
|
@ -192,7 +192,7 @@ class OnBoardingPresenterTest {
|
||||||
skipItems(3)
|
skipItems(3)
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
|
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
|
||||||
authenticationService.givenChangeServerError(A_THROWABLE)
|
authenticationService.givenChangeServerError(AN_EXCEPTION)
|
||||||
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
|
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
|
||||||
skipItems(1) // Loading
|
skipItems(1) // Loading
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.features.login.impl.login.LoginMode
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
|
|
@ -36,9 +37,13 @@ class OnboardingViewTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when can create account - clicking on create account calls the expected callback`() {
|
fun `when can create account - clicking on create account calls the expected callback`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(canCreateAccount = true),
|
state = anOnBoardingState(
|
||||||
|
canCreateAccount = true,
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
onCreateAccount = callback,
|
onCreateAccount = callback,
|
||||||
)
|
)
|
||||||
rule.clickOn(R.string.screen_onboarding_sign_up)
|
rule.clickOn(R.string.screen_onboarding_sign_up)
|
||||||
|
|
@ -47,9 +52,13 @@ class OnboardingViewTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
|
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(canLoginWithQrCode = true),
|
state = anOnBoardingState(
|
||||||
|
canLoginWithQrCode = true,
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
onSignInWithQrCode = callback,
|
onSignInWithQrCode = callback,
|
||||||
)
|
)
|
||||||
rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
|
rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
|
||||||
|
|
@ -73,11 +82,13 @@ class OnboardingViewTest {
|
||||||
private fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
|
private fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
|
||||||
mustChooseAccountProvider: Boolean,
|
mustChooseAccountProvider: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(
|
state = anOnBoardingState(
|
||||||
canLoginWithQrCode = true,
|
canLoginWithQrCode = true,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
eventSink = eventSink,
|
||||||
),
|
),
|
||||||
onSignIn = callback,
|
onSignIn = callback,
|
||||||
)
|
)
|
||||||
|
|
@ -102,12 +113,14 @@ class OnboardingViewTest {
|
||||||
private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
|
private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
|
||||||
mustChooseAccountProvider: Boolean,
|
mustChooseAccountProvider: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(
|
state = anOnBoardingState(
|
||||||
canLoginWithQrCode = false,
|
canLoginWithQrCode = false,
|
||||||
canCreateAccount = false,
|
canCreateAccount = false,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
eventSink = eventSink,
|
||||||
),
|
),
|
||||||
onSignIn = callback,
|
onSignIn = callback,
|
||||||
)
|
)
|
||||||
|
|
@ -145,10 +158,12 @@ class OnboardingViewTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on report a problem calls the sign in callback`() {
|
fun `clicking on report a problem calls the sign in callback`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(
|
state = anOnBoardingState(
|
||||||
canReportBug = true,
|
canReportBug = true,
|
||||||
|
eventSink = eventSink,
|
||||||
),
|
),
|
||||||
onReportProblem = callback,
|
onReportProblem = callback,
|
||||||
)
|
)
|
||||||
|
|
@ -160,15 +175,64 @@ class OnboardingViewTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cannot report a problem when the feature is disabled`() {
|
fun `cannot report a problem when the feature is disabled`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
rule.setOnboardingView(
|
rule.setOnboardingView(
|
||||||
state = anOnBoardingState(
|
state = anOnBoardingState(
|
||||||
canReportBug = false,
|
canReportBug = false,
|
||||||
|
eventSink = eventSink,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
|
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
|
||||||
rule.onNodeWithText(text).assertDoesNotExist()
|
rule.onNodeWithText(text).assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when success PasswordLogin - the expected callback is invoked and the event is received`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>()
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setOnboardingView(
|
||||||
|
state = anOnBoardingState(
|
||||||
|
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
|
onNeedLoginPassword = callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
eventSink.assertSingle(OnBoardingEvents.ClearError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when success Oidc - the expected callback is invoked and the event is received`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>()
|
||||||
|
val oidcDetails = OidcDetails("aUrl")
|
||||||
|
ensureCalledOnceWithParam(oidcDetails) { callback ->
|
||||||
|
rule.setOnboardingView(
|
||||||
|
state = anOnBoardingState(
|
||||||
|
loginMode = AsyncData.Success(LoginMode.Oidc(oidcDetails)),
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
|
onOidcDetails = callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
eventSink.assertSingle(OnBoardingEvents.ClearError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when success AccountCreation - the expected callback is invoked and the event is received`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>()
|
||||||
|
val oidcDetails = OidcDetails("aUrl")
|
||||||
|
ensureCalledOnceWithParam(oidcDetails.url) { callback ->
|
||||||
|
rule.setOnboardingView(
|
||||||
|
state = anOnBoardingState(
|
||||||
|
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
|
onCreateAccountContinue = callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
eventSink.assertSingle(OnBoardingEvents.ClearError)
|
||||||
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -165,7 +165,7 @@ class LogoutPresenterTest {
|
||||||
fun `present - logout with error then cancel`() = runTest {
|
fun `present - logout with error then cancel`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
logoutLambda = { _, _ ->
|
logoutLambda = { _, _ ->
|
||||||
throw A_THROWABLE
|
throw AN_EXCEPTION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createLogoutPresenter(
|
val presenter = createLogoutPresenter(
|
||||||
|
|
@ -182,7 +182,7 @@ class LogoutPresenterTest {
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
val errorState = awaitItem()
|
val errorState = awaitItem()
|
||||||
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
|
errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
|
@ -194,9 +194,7 @@ class LogoutPresenterTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
logoutLambda = { ignoreSdkError, _ ->
|
logoutLambda = { ignoreSdkError, _ ->
|
||||||
if (!ignoreSdkError) {
|
if (!ignoreSdkError) {
|
||||||
throw A_THROWABLE
|
throw AN_EXCEPTION
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +212,7 @@ class LogoutPresenterTest {
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
val errorState = awaitItem()
|
val errorState = awaitItem()
|
||||||
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true))
|
errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true))
|
||||||
val loadingState2 = awaitItem()
|
val loadingState2 = awaitItem()
|
||||||
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -117,7 +117,7 @@ class DirectLogoutPresenterTest {
|
||||||
fun `present - logout with error then cancel`() = runTest {
|
fun `present - logout with error then cancel`() = runTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
logoutLambda = { _, _ ->
|
logoutLambda = { _, _ ->
|
||||||
throw A_THROWABLE
|
throw AN_EXCEPTION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val presenter = createDirectLogoutPresenter(
|
val presenter = createDirectLogoutPresenter(
|
||||||
|
|
@ -134,7 +134,7 @@ class DirectLogoutPresenterTest {
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
val errorState = awaitItem()
|
val errorState = awaitItem()
|
||||||
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs)
|
errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
|
@ -146,9 +146,7 @@ class DirectLogoutPresenterTest {
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
logoutLambda = { ignoreSdkError, _ ->
|
logoutLambda = { ignoreSdkError, _ ->
|
||||||
if (!ignoreSdkError) {
|
if (!ignoreSdkError) {
|
||||||
throw A_THROWABLE
|
throw AN_EXCEPTION
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +164,7 @@ class DirectLogoutPresenterTest {
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
val errorState = awaitItem()
|
val errorState = awaitItem()
|
||||||
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true))
|
errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true))
|
||||||
val loadingState2 = awaitItem()
|
val loadingState2 = awaitItem()
|
||||||
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
|
@ -29,10 +31,8 @@ import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.min
|
import androidx.compose.ui.unit.min
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -139,8 +139,8 @@ internal fun ExpandableBottomSheetScaffold(
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
measurePolicy = { measurables, constraints ->
|
measurePolicy = { measurables, constraints ->
|
||||||
val constraintHeight = constraints.maxHeight
|
val constraintHeight = constraints.maxHeight
|
||||||
val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0
|
val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
|
||||||
val height = Integer.max(0, constraintHeight - offset)
|
val height = Integer.max(0, constraintHeight - offset.roundToInt())
|
||||||
val top = measurables[0].measure(
|
val top = measurables[0].measure(
|
||||||
constraints.copy(
|
constraints.copy(
|
||||||
minHeight = height,
|
minHeight = height,
|
||||||
|
|
@ -165,12 +165,6 @@ internal fun ExpandableBottomSheetScaffold(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CustomSheetState.getIntOffset(): Int? = try {
|
|
||||||
requireOffset().roundToInt()
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed interface Slot {
|
private sealed interface Slot {
|
||||||
data class SheetContent(val key: Int?) : Slot
|
data class SheetContent(val key: Int?) : Slot
|
||||||
data object DragHandle : Slot
|
data object DragHandle : Slot
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -69,7 +70,8 @@ import kotlinx.coroutines.launch
|
||||||
class MessagesNode @AssistedInject constructor(
|
class MessagesNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val coroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val room: BaseRoom,
|
private val room: BaseRoom,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
|
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
|
||||||
|
|
@ -115,7 +117,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
coroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
|
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
|
||||||
},
|
},
|
||||||
onDestroy = {
|
onDestroy = {
|
||||||
mediaPlayer.close()
|
mediaPlayer.close()
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -387,7 +388,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
||||||
inviteProgress.value = AsyncData.Loading()
|
inviteProgress.value = AsyncData.Loading()
|
||||||
runCatching {
|
runCatchingExceptions {
|
||||||
val memberList = when (val memberState = room.membersStateFlow.value) {
|
val memberList = when (val memberState = room.membersStateFlow.value) {
|
||||||
is RoomMembersState.Ready -> memberState.roomMembers
|
is RoomMembersState.Ready -> memberState.roomMembers
|
||||||
is RoomMembersState.Error -> memberState.prevRoomMembers.orEmpty()
|
is RoomMembersState.Error -> memberState.prevRoomMembers.orEmpty()
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.androidutils.file.safeDelete
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.coroutine.firstInstanceOf
|
import io.element.android.libraries.core.coroutine.firstInstanceOf
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
|
@ -240,7 +241,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
sendActionState: MutableState<SendActionState>,
|
sendActionState: MutableState<SendActionState>,
|
||||||
dismissAfterSend: Boolean,
|
dismissAfterSend: Boolean,
|
||||||
replyParameters: ReplyParameters?,
|
replyParameters: ReplyParameters?,
|
||||||
) = runCatching {
|
) = runCatchingExceptions {
|
||||||
val context = coroutineContext
|
val context = coroutineContext
|
||||||
val progressCallback = object : ProgressCallback {
|
val progressCallback = object : ProgressCallback {
|
||||||
override fun onProgress(current: Long, total: Long) {
|
override fun onProgress(current: Long, total: Long) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.ui.room.observeRoomMemberIdentityStateChange
|
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -30,7 +30,7 @@ class IdentityChangeStatePresenter @Inject constructor(
|
||||||
override fun present(): IdentityChangeState {
|
override fun present(): IdentityChangeState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
|
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
|
||||||
observeRoomMemberIdentityStateChange(room)
|
room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: IdentityChangeEvent) {
|
fun handleEvent(event: IdentityChangeEvent) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
|
|
@ -28,7 +29,8 @@ import kotlinx.coroutines.launch
|
||||||
class ForwardMessagesPresenter @AssistedInject constructor(
|
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
@Assisted eventId: String,
|
@Assisted eventId: String,
|
||||||
@Assisted private val timelineProvider: TimelineProvider,
|
@Assisted private val timelineProvider: TimelineProvider,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<ForwardMessagesState> {
|
) : Presenter<ForwardMessagesState> {
|
||||||
private val eventId: EventId = EventId(eventId)
|
private val eventId: EventId = EventId(eventId)
|
||||||
|
|
||||||
|
|
@ -40,7 +42,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
|
||||||
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||||
|
|
||||||
fun onRoomSelected(roomIds: List<RoomId>) {
|
fun onRoomSelected(roomIds: List<RoomId>) {
|
||||||
appCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
|
sessionCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessag
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
aForwardMessagesState(
|
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.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.media3.common.MimeTypes
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
|
@ -41,8 +40,11 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Sug
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -97,7 +99,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||||
|
|
||||||
class MessageComposerPresenter @AssistedInject constructor(
|
class MessageComposerPresenter @AssistedInject constructor(
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
|
@ -165,13 +168,13 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
handlePickedMedia(uri, mimeType)
|
handlePickedMedia(uri, mimeType)
|
||||||
}
|
}
|
||||||
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
|
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
|
||||||
handlePickedMedia(uri)
|
handlePickedMedia(uri, MimeTypes.OctetStream)
|
||||||
}
|
}
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||||
handlePickedMedia(uri, MimeTypes.IMAGE_JPEG)
|
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||||
}
|
}
|
||||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||||
handlePickedMedia(uri, MimeTypes.VIDEO_MP4)
|
handlePickedMedia(uri, MimeTypes.Mp4)
|
||||||
}
|
}
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
|
|
@ -199,7 +202,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
// Declare that the user is not typing anymore when the composer is disposed
|
// Declare that the user is not typing anymore when the composer is disposed
|
||||||
onDispose {
|
onDispose {
|
||||||
appCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
if (sendTypingNotifications) {
|
if (sendTypingNotifications) {
|
||||||
room.typingNotice(false)
|
room.typingNotice(false)
|
||||||
}
|
}
|
||||||
|
|
@ -235,12 +238,12 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MessageComposerEvents.SendMessage -> {
|
is MessageComposerEvents.SendMessage -> {
|
||||||
appCoroutineScope.sendMessage(
|
sessionCoroutineScope.sendMessage(
|
||||||
markdownTextEditorState = markdownTextEditorState,
|
markdownTextEditorState = markdownTextEditorState,
|
||||||
richTextEditorState = richTextEditorState,
|
richTextEditorState = richTextEditorState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment(
|
is MessageComposerEvents.SendUri -> sessionCoroutineScope.sendAttachment(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = localMediaFactory.createFromUri(
|
localMedia = localMediaFactory.createFromUri(
|
||||||
uri = event.uri,
|
uri = event.uri,
|
||||||
|
|
@ -337,7 +340,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
MessageComposerEvents.SaveDraft -> {
|
MessageComposerEvents.SaveDraft -> {
|
||||||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||||
appCoroutineScope.updateDraft(draft, isVolatile = false)
|
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -512,7 +515,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
private suspend fun sendMedia(
|
private suspend fun sendMedia(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
) = runCatching {
|
) = runCatchingExceptions {
|
||||||
mediaSender.sendMedia(
|
mediaSender.sendMedia(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||||
|
|
@ -67,7 +68,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
private val linkPresenter: Presenter<LinkState>,
|
private val linkPresenter: Presenter<LinkState>,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
) : Presenter<PinnedMessagesListState> {
|
) : Presenter<PinnedMessagesListState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -123,7 +125,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
fun handleEvents(event: PinnedMessagesListEvents) {
|
fun handleEvents(event: PinnedMessagesListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is PinnedMessagesListEvents.HandleAction -> appCoroutineScope.handleTimelineAction(event.action, event.event)
|
is PinnedMessagesListEvents.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageSt
|
||||||
aReportMessageState(reason = "This user is making the chat very toxic."),
|
aReportMessageState(reason = "This user is making the chat very toxic."),
|
||||||
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true),
|
||||||
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Loading),
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Loading),
|
||||||
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(Throwable("error"))),
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(RuntimeException("error"))),
|
||||||
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)),
|
aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)),
|
||||||
// Add other states here
|
// Add other states here
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -37,6 +38,7 @@ import io.element.android.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
|
@ -65,7 +67,8 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
|
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val appScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
||||||
private val sendPollResponseAction: SendPollResponseAction,
|
private val sendPollResponseAction: SendPollResponseAction,
|
||||||
|
|
@ -76,6 +79,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
||||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
|
private val markAsFullyRead: MarkAsFullyRead,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -133,7 +137,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
newEventState.value = NewEventState.None
|
newEventState.value = NewEventState.None
|
||||||
}
|
}
|
||||||
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||||
appScope.sendReadReceiptIfNeeded(
|
sessionCoroutineScope.sendReadReceiptIfNeeded(
|
||||||
firstVisibleIndex = event.firstIndex,
|
firstVisibleIndex = event.firstIndex,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
lastReadReceiptId = lastReadReceiptId,
|
lastReadReceiptId = lastReadReceiptId,
|
||||||
|
|
@ -143,13 +147,13 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
newEventState.value = NewEventState.None
|
newEventState.value = NewEventState.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is TimelineEvents.SelectPollAnswer -> appScope.launch {
|
is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
|
||||||
sendPollResponseAction.execute(
|
sendPollResponseAction.execute(
|
||||||
pollStartId = event.pollStartId,
|
pollStartId = event.pollStartId,
|
||||||
answerId = event.answerId
|
answerId = event.answerId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TimelineEvents.EndPoll -> appScope.launch {
|
is TimelineEvents.EndPoll -> sessionCoroutineScope.launch {
|
||||||
endPollAction.execute(
|
endPollAction.execute(
|
||||||
pollStartId = event.pollStartId,
|
pollStartId = event.pollStartId,
|
||||||
)
|
)
|
||||||
|
|
@ -177,6 +181,12 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
markAsFullyRead(room.roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
timelineItemsFactory.timelineItems
|
timelineItemsFactory.timelineItems
|
||||||
.onEach { newTimelineItems ->
|
.onEach { newTimelineItems ->
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
||||||
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
||||||
|
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
|
|
@ -112,7 +113,9 @@ fun MessageEventBubble(
|
||||||
state.isMine -> ElementTheme.colors.messageFromMeBackground
|
state.isMine -> ElementTheme.colors.messageFromMeBackground
|
||||||
else -> ElementTheme.colors.messageFromOtherBackground
|
else -> ElementTheme.colors.messageFromOtherBackground
|
||||||
}
|
}
|
||||||
val bubbleShape = bubbleShape()
|
// If we're running in UI test mode, we want to use a different shape to avoid
|
||||||
|
// this issue: https://issuetracker.google.com/issues/366255137
|
||||||
|
val bubbleShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else bubbleShape()
|
||||||
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
|
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
|
||||||
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
|
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -80,7 +80,7 @@ fun TimelineEventTimestampView(
|
||||||
.clickable(isVerifiedUserSendFailure) {
|
.clickable(isVerifiedUserSendFailure) {
|
||||||
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
||||||
}
|
}
|
||||||
.semantics { invisibleToUser() }
|
.semantics { hideFromAccessibility() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ fun TimelineEventTimestampView(
|
||||||
.clickable {
|
.clickable {
|
||||||
eventSink(TimelineEvents.ShowShieldDialog(shield))
|
eventSink(TimelineEvents.ShowShieldDialog(shield))
|
||||||
}
|
}
|
||||||
.semantics { invisibleToUser() },
|
.semantics { hideFromAccessibility() },
|
||||||
tint = shield.toIconColor(),
|
tint = shield.toIconColor(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.platform.ViewConfiguration
|
import androidx.compose.ui.platform.ViewConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.isTraversalGroup
|
import androidx.compose.ui.semantics.isTraversalGroup
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.traversalIndex
|
import androidx.compose.ui.semantics.traversalIndex
|
||||||
|
|
@ -439,7 +439,7 @@ private fun MessageSenderInformation(
|
||||||
// Add external clickable modifier with no indicator so the touch target is larger than just the display name
|
// Add external clickable modifier with no indicator so the touch target is larger than just the display name
|
||||||
.clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
|
.clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
|
||||||
.clearAndSetSemantics {
|
.clearAndSetSemantics {
|
||||||
invisibleToUser()
|
hideFromAccessibility()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -43,7 +43,7 @@ fun TimelineItemReactionsView(
|
||||||
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
modifier = modifier.semantics {
|
modifier = modifier.semantics {
|
||||||
invisibleToUser()
|
hideFromAccessibility()
|
||||||
},
|
},
|
||||||
reactions = reactionsState.reactions,
|
reactions = reactionsState.reactions,
|
||||||
userCanSendReaction = userCanSendReaction,
|
userCanSendReaction = userCanSendReaction,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ fun TimelineItemImageView(
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
model = content.thumbnailMediaRequestData,
|
model = content.thumbnailMediaRequestData,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Crop,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.Center,
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ fun TimelineItemStickerView(
|
||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Crop,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.Center,
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -122,7 +122,7 @@ fun TimelineItemVideoView(
|
||||||
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
|
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Crop,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.Center,
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||||
|
|
@ -136,7 +136,7 @@ fun TimelineItemVideoView(
|
||||||
imageVector = CompoundIcons.PlaySolid(),
|
imageVector = CompoundIcons.PlaySolid(),
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||||
colorFilter = ColorFilter.tint(Color.White),
|
colorFilter = ColorFilter.tint(Color.White),
|
||||||
modifier = Modifier.semantics { invisibleToUser() }
|
modifier = Modifier.semantics { hideFromAccessibility() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.invisibleToUser
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.testTag
|
import androidx.compose.ui.semantics.testTag
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -59,7 +59,7 @@ fun TimelineItemReadReceiptView(
|
||||||
if (renderReadReceipts) {
|
if (renderReadReceipts) {
|
||||||
ReadReceiptsRow(
|
ReadReceiptsRow(
|
||||||
modifier = modifier.clearAndSetSemantics {
|
modifier = modifier.clearAndSetSemantics {
|
||||||
invisibleToUser()
|
hideFromAccessibility()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
ReadReceiptsAvatars(
|
ReadReceiptsAvatars(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.composer
|
package io.element.android.features.messages.impl.voicemessages.composer
|
||||||
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -26,11 +27,12 @@ import javax.inject.Inject
|
||||||
* A media player for the voice message composer.
|
* A media player for the voice message composer.
|
||||||
*
|
*
|
||||||
* @param mediaPlayer The [MediaPlayer] to use.
|
* @param mediaPlayer The [MediaPlayer] to use.
|
||||||
* @param coroutineScope
|
* @param sessionCoroutineScope
|
||||||
*/
|
*/
|
||||||
class VoiceMessageComposerPlayer @Inject constructor(
|
class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
private val mediaPlayer: MediaPlayer,
|
private val mediaPlayer: MediaPlayer,
|
||||||
private val coroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MIME_TYPE = MimeTypes.Ogg
|
const val MIME_TYPE = MimeTypes.Ogg
|
||||||
|
|
@ -116,7 +118,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
|
|
||||||
seekJob?.cancelAndJoin()
|
seekJob?.cancelAndJoin()
|
||||||
seekingTo.value = position
|
seekingTo.value = position
|
||||||
seekJob = coroutineScope.launch {
|
seekJob = sessionCoroutineScope.launch {
|
||||||
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
|
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
|
||||||
val duration = mediaState.duration ?: return@launch
|
val duration = mediaState.duration ?: return@launch
|
||||||
val positionMs = (duration * position).toLong()
|
val positionMs = (duration * position).toLong()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.lifecycle.Lifecycle
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -44,7 +45,8 @@ import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class VoiceMessageComposerPresenter @Inject constructor(
|
class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
private val appCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val voiceRecorder: VoiceRecorder,
|
private val voiceRecorder: VoiceRecorder,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val mediaSender: MediaSender,
|
private val mediaSender: MediaSender,
|
||||||
|
|
@ -74,11 +76,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_PAUSE -> {
|
Lifecycle.Event.ON_PAUSE -> {
|
||||||
appCoroutineScope.finishRecording()
|
sessionCoroutineScope.finishRecording()
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_DESTROY -> {
|
Lifecycle.Event.ON_DESTROY -> {
|
||||||
appCoroutineScope.cancelRecording()
|
sessionCoroutineScope.cancelRecording()
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +147,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
isSending = true
|
isSending = true
|
||||||
player.pause()
|
player.pause()
|
||||||
analyticsService.captureComposerEvent()
|
analyticsService.captureComposerEvent()
|
||||||
appCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
val result = sendMessage(
|
val result = sendMessage(
|
||||||
file = finishedState.file,
|
file = finishedState.file,
|
||||||
mimeType = finishedState.mimeType,
|
mimeType = finishedState.mimeType,
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,11 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
|
@ -139,10 +139,10 @@ class MessagesPresenterTest {
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
markAsReadResult = { lambdaError() }
|
||||||
),
|
),
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
assertThat(room.baseRoom.markAsReadCalls).isEmpty()
|
|
||||||
val presenter = createMessagesPresenter(joinedRoom = room)
|
val presenter = createMessagesPresenter(joinedRoom = room)
|
||||||
presenter.testWithLifecycleOwner {
|
presenter.testWithLifecycleOwner {
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
@ -744,7 +744,7 @@ class MessagesPresenterTest {
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
inviteUserResult = { Result.failure(Throwable("Oops!")) },
|
inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
|
||||||
)
|
)
|
||||||
room.givenRoomMembersState(
|
room.givenRoomMembersState(
|
||||||
RoomMembersState.Ready(
|
RoomMembersState.Ready(
|
||||||
|
|
@ -848,12 +848,12 @@ class MessagesPresenterTest {
|
||||||
fun `present - permission to redact other`() = runTest {
|
fun `present - permission to redact other`() = runTest {
|
||||||
val joinedRoom = FakeJoinedRoom(
|
val joinedRoom = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
canRedactOwnResult = { Result.success(false) },
|
canRedactOwnResult = { Result.success(false) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val presenter = createMessagesPresenter(joinedRoom = joinedRoom)
|
val presenter = createMessagesPresenter(joinedRoom = joinedRoom)
|
||||||
|
|
@ -892,17 +892,17 @@ class MessagesPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action pin`() = runTest {
|
fun `present - handle action pin`() = runTest {
|
||||||
val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
|
val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
|
||||||
val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
|
val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(AN_EXCEPTION) }
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val timeline = FakeTimeline()
|
val timeline = FakeTimeline()
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
|
|
@ -932,17 +932,17 @@ class MessagesPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action unpin`() = runTest {
|
fun `present - handle action unpin`() = runTest {
|
||||||
val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
|
val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) }
|
||||||
val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(A_THROWABLE) }
|
val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure<Boolean>(AN_EXCEPTION) }
|
||||||
val timeline = FakeTimeline()
|
val timeline = FakeTimeline()
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
|
|
@ -1096,12 +1096,12 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
|
|
@ -1134,16 +1134,16 @@ class MessagesPresenterTest {
|
||||||
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
|
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
canRedactOtherResult = { Result.success(true) },
|
canRedactOtherResult = { Result.success(true) },
|
||||||
canUserJoinCallResult = { Result.success(true) },
|
canUserJoinCallResult = { Result.success(true) },
|
||||||
canUserPinUnpinResult = { Result.success(true) },
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
|
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
|
||||||
).apply {
|
).apply {
|
||||||
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
|
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
|
||||||
},
|
},
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })
|
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.ensureCalledOnce
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
import io.element.android.tests.testutils.pressBack
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import io.element.android.tests.testutils.setSafeContent
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -567,11 +568,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
||||||
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setSafeContent {
|
||||||
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||||
LocalInspectionMode provides true
|
|
||||||
) {
|
|
||||||
MessagesView(
|
MessagesView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.setSafeContent
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -54,7 +55,7 @@ class ResolveVerifiedUserSendFailureViewTest {
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
|
||||||
state: ResolveVerifiedUserSendFailureState,
|
state: ResolveVerifiedUserSendFailureState,
|
||||||
) {
|
) {
|
||||||
setContent {
|
setSafeContent {
|
||||||
ResolveVerifiedUserSendFailureView(state = state)
|
ResolveVerifiedUserSendFailureView(state = state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,6 @@ class ForwardMessagesPresenterTest {
|
||||||
) = ForwardMessagesPresenter(
|
) = ForwardMessagesPresenter(
|
||||||
eventId = eventId.value,
|
eventId = eventId.value,
|
||||||
timelineProvider = LiveTimelineProvider(fakeRoom),
|
timelineProvider = LiveTimelineProvider(fakeRoom),
|
||||||
appCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1539,7 +1539,7 @@ class MessageComposerPresenterTest {
|
||||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
appCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
room = room,
|
room = room,
|
||||||
mediaPickerProvider = pickerProvider,
|
mediaPickerProvider = pickerProvider,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
|
@ -157,7 +157,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - unpin event`() = runTest {
|
fun `present - unpin event`() = runTest {
|
||||||
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
|
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
|
||||||
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) }
|
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(AN_EXCEPTION) }
|
||||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
|
|
@ -337,7 +337,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
actionListPresenter = { anActionListState() },
|
actionListPresenter = { anActionListState() },
|
||||||
linkPresenter = { aLinkState() },
|
linkPresenter = { aLinkState() },
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
appCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
|
||||||
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
) {
|
) {
|
||||||
setSafeContent {
|
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||||
PinnedMessagesListView(
|
PinnedMessagesListView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
|
|
@ -35,7 +36,7 @@ class DefaultHtmlConverterProviderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `calling provide without calling Update first should throw an exception`() {
|
fun `calling provide without calling Update first should throw an exception`() {
|
||||||
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
val exception = runCatchingExceptions { provider.provide() }.exceptionOrNull()
|
||||||
|
|
||||||
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +48,7 @@ class DefaultHtmlConverterProviderTest {
|
||||||
provider.Update()
|
provider.Update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val htmlConverter = runCatching { provider.provide() }.getOrNull()
|
val htmlConverter = runCatchingExceptions { provider.provide() }.getOrNull()
|
||||||
|
|
||||||
assertThat(htmlConverter).isNotNull()
|
assertThat(htmlConverter).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.poll.test.actions.FakeSendPollResponseAction
|
||||||
import io.element.android.features.roomcall.api.aStandByCallState
|
import io.element.android.features.roomcall.api.aStandByCallState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
|
@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
|
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -58,6 +60,7 @@ import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -76,7 +79,9 @@ import java.util.Date
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
|
@Suppress("LargeClass")
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TimelinePresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
|
@ -119,9 +124,26 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
|
fun `present - on scroll finished mark a room as read if the first visible index is 0 - read private`() {
|
||||||
|
`present - on scroll finished mark a room as read if the first visible index is 0`(
|
||||||
|
isSendPublicReadReceiptsEnabled = false,
|
||||||
|
expectedReceiptType = ReceiptType.READ_PRIVATE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - on scroll finished mark a room as read if the first visible index is 0 - read`() {
|
||||||
|
`present - on scroll finished mark a room as read if the first visible index is 0`(
|
||||||
|
isSendPublicReadReceiptsEnabled = true,
|
||||||
|
expectedReceiptType = ReceiptType.READ,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun `present - on scroll finished mark a room as read if the first visible index is 0`(
|
||||||
|
isSendPublicReadReceiptsEnabled: Boolean,
|
||||||
|
expectedReceiptType: ReceiptType,
|
||||||
|
) = runTest(StandardTestDispatcher()) {
|
||||||
val timeline = FakeTimeline(
|
val timeline = FakeTimeline(
|
||||||
timelineItems = flowOf(
|
timelineItems = flowOf(
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -129,11 +151,15 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) })
|
baseRoom = FakeBaseRoom(
|
||||||
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
|
markAsReadResult = markAsReadResult,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
room = room,
|
room = room,
|
||||||
|
|
@ -145,11 +171,32 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||||
runCurrent()
|
runCurrent()
|
||||||
assertThat(room.baseRoom.markAsReadCalls).isNotEmpty()
|
assert(markAsReadResult)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(expectedReceiptType))
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - once presenter is disposed, room is marked as fully read`() = runTest {
|
||||||
|
val invokeResult = lambdaRecorder<RoomId, Unit> { }
|
||||||
|
val presenter = createTimelinePresenter(
|
||||||
|
room = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
markAsFullyRead = FakeMarkAsFullyRead(
|
||||||
|
invokeResult = invokeResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
}
|
||||||
|
invokeResult.assertions().isCalledOnce().with(value(A_ROOM_ID))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
|
|
@ -562,7 +609,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
liveTimeline = FakeTimeline(
|
liveTimeline = FakeTimeline(
|
||||||
timelineItems = flowOf(emptyList()),
|
timelineItems = flowOf(emptyList()),
|
||||||
),
|
),
|
||||||
createTimelineResult = { Result.failure(Throwable("An error")) },
|
createTimelineResult = { Result.failure(RuntimeException("An error")) },
|
||||||
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
|
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -674,12 +721,13 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
|
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
|
||||||
): TimelinePresenter {
|
): TimelinePresenter {
|
||||||
return TimelinePresenter(
|
return TimelinePresenter(
|
||||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||||
room = room,
|
room = room,
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
appScope = this,
|
sessionCoroutineScope = this,
|
||||||
navigator = messagesNavigator,
|
navigator = messagesNavigator,
|
||||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||||
endPollAction = endPollAction,
|
endPollAction = endPollAction,
|
||||||
|
|
@ -690,6 +738,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||||
typingNotificationPresenter = { aTypingNotificationState() },
|
typingNotificationPresenter = { aTypingNotificationState() },
|
||||||
roomCallStatePresenter = { aStandByCallState() },
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
|
markAsFullyRead = markAsFullyRead,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
|
||||||
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
||||||
forceJumpToBottomVisibility: Boolean = false,
|
forceJumpToBottomVisibility: Boolean = false,
|
||||||
) {
|
) {
|
||||||
setSafeContent {
|
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = state,
|
state = state,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.migration.impl.migrations
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.squareup.anvil.annotations.ContributesMultibinding
|
import com.squareup.anvil.annotations.ContributesMultibinding
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -26,6 +27,6 @@ class AppMigration04 @Inject constructor(
|
||||||
override val order: Int = 4
|
override val order: Int = 4
|
||||||
|
|
||||||
override suspend fun migrate() {
|
override suspend fun migrate() {
|
||||||
runCatching { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() }
|
runCatchingExceptions { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
|
@ -38,6 +39,7 @@ import javax.inject.Inject
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
class DefaultNetworkMonitor @Inject constructor(
|
class DefaultNetworkMonitor @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
|
@AppCoroutineScope
|
||||||
appCoroutineScope: CoroutineScope,
|
appCoroutineScope: CoroutineScope,
|
||||||
) : NetworkMonitor {
|
) : NetworkMonitor {
|
||||||
private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue