diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa9be929ea..e997e1cbdd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APKs diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index b1d7204168..294b94a872 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -61,7 +61,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug Gplay Enterprise APK diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 763936e3d6..a180afb07e 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -24,7 +24,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 1195c7b53d..89413a4d2d 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -53,7 +53,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK @@ -124,6 +124,19 @@ jobs: disk-size: 3G script: | .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk + - name: Set up Python + if: always() + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: 3.14 + - name: End all the remaining sessions of the Maestro account + if: always() + continue-on-error: true + env: + MAS_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} + run: | + pip install requests + python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index fe38c0138f..88b267acc4 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -43,7 +43,7 @@ jobs: java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: false @@ -85,7 +85,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 59a5d63b4d..006b0101f6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -74,7 +74,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 @@ -113,7 +113,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Konsist tests @@ -154,7 +154,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run compose tests @@ -188,7 +188,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build Gplay Debug @@ -233,7 +233,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt @@ -274,7 +274,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index efac08a99f..e6580d2f7b 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -60,7 +60,7 @@ jobs: java-version: '21' # Add gradle cache, this should speed up the process - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Record screenshots diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f264aa6de..cd1bba77f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,13 +37,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check Matrix Rust SDK is not a nightly version + run: ./tools/release/check_no_nightly_sdk.sh - name: Use JDK 21 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create app bundle env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -87,13 +89,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check Matrix Rust SDK is not a nightly version + run: ./tools/release/check_no_nightly_sdk.sh - name: Use JDK 21 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create APKs env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 4febf272a9..d2b57a771e 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -50,7 +50,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build debug code and test fixtures diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 9065e6d4e9..8d93e958c6 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -25,7 +25,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe09a37ae2..b54741c566 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} diff --git a/AGENTS.md b/AGENTS.md index a4a73ca965..dc34ebd571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PRs must meet these rules. - Create previews for **all main states** of a Composable. - Use `@PreviewsDayNight` for consistency. -- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. +- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence. - Wrap previews in `ElementPreview { ... }`. - When writing tests, never try to record the screenshots, the CI will do it. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b58f8520b..706a82f0d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -280,7 +280,6 @@ dependencies { implementation(projects.features.migration.api) implementation(projects.appnav) implementation(projects.appconfig) - implementation(projects.libraries.uiStrings) implementation(projects.services.analytics.compose) if (ModulesConfig.pushProvidersConfig.includeFirebase) { @@ -298,18 +297,13 @@ dependencies { implementation(libs.androidx.splash) implementation(libs.androidx.core) implementation(libs.androidx.corektx) - implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.androidx.preference) - implementation(libs.coil) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp.logging) - implementation(libs.serialization.json) - - implementation(libs.matrix.emojibase.bindings) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index abdb6b1394..e5411e739c 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -11,12 +11,12 @@ package io.element.android.x.di import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Multibinds -import io.element.android.features.api.MigrationEntryPoint import io.element.android.features.callnative.api.NativeCallPip import io.element.android.features.enterprise.api.AppStartupHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.migration.api.MigrationEntryPoint import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts index 64b9b76a14..e355499350 100644 --- a/appconfig/build.gradle.kts +++ b/appconfig/build.gradle.kts @@ -48,7 +48,6 @@ android { } dependencies { - implementation(libs.coroutines.core) implementation(libs.androidx.annotationjvm) implementation(libs.androidx.corektx) implementation(projects.libraries.matrix.api) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index f65692feb4..cc11f5c033 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -44,7 +44,6 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.wellknown.api) implementation(projects.features.login.api) implementation(libs.coil) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index 0dbcb0f3f1..32e1723d99 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,7 +33,9 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize /** @@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) } private val callback: Callback = callback() @@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) { + callback.switchAccountAndOpenRoom(sessionId, roomId) + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index cee2296d43..ac08795f67 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -8,28 +8,51 @@ package io.element.android.appnav -import dev.zacsweers.metro.Inject +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -@Inject +@AssistedInject class LoggedInEventProcessor( - private val snackbarDispatcher: SnackbarDispatcher, + @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, + private val sessionId: SessionId, + private val roomListService: RoomListService, + private val notificationCleaner: NotificationCleaner, + private val dispatchers: CoroutineDispatchers ) { - private var observingJob: Job? = null + @AssistedFactory + interface Factory { + fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var currentChildScope: CoroutineScope? = null fun observeEvents(coroutineScope: CoroutineScope) { - observingJob = roomMembershipObserver.updates + if (currentChildScope != null) return + + val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor") + .also { currentChildScope = it } + + roomMembershipObserver.updates .filter { !it.isUserInRoom } .distinctUntilChanged() .onEach { roomMemberShipUpdate -> @@ -48,12 +71,20 @@ class LoggedInEventProcessor( else -> Unit } } - .launchIn(coroutineScope) + .launchIn(childScope) + + // Use the room list service state as a 'heartbeat' update to check existing notifications + roomListService.state + .filter { it == RoomListService.State.Running } + .onEach { + notificationCleaner.clearReadRoomsNotifications(sessionId) + } + .launchIn(childScope) } fun stopObserving() { - observingJob?.cancel() - observingJob = null + currentChildScope?.cancel() + currentChildScope = null } private fun displayMessage(message: Int) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 731c26f684..a361576050 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -88,6 +88,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -157,6 +158,7 @@ class LoggedInFlowNode( private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -172,13 +174,11 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) } private val callback: Callback = callback() - private val loggedInFlowProcessor = LoggedInEventProcessor( - snackbarDispatcher = snackbarDispatcher, - roomMembershipObserver = matrixClient.roomMembershipObserver, - ) + private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher) /** * Display the incoming verification request, the app is expected to be in foreground. @@ -361,6 +361,10 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } } homeEntryPoint.createNode( parentNode = this, @@ -572,10 +576,16 @@ class LoggedInFlowNode( buildContext = buildContext, params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) { + override fun onDone(sessionId: SessionId, roomIds: List) { // Remove the incoming share screen backstack.pop() + if (sessionId != matrixClient.sessionId) { + // The data has been shared using another session, switch to it + callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull()) + return + } + // Navigate to the room if the text/media was shared to a single one roomIds.singleOrNull()?.let { roomId -> lifecycleScope.launch { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 080f3c1d77..c66ac97d83 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -13,6 +13,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import com.bumble.appyx.core.lifecycle.subscribe @@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ScreenOrientation -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -45,6 +51,8 @@ class NotLoggedInFlowNode( private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -68,6 +76,13 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() analyticsColdStartWatcher.whenLoggingIn() + lifecycleScope.launch { + // Users who enter the login flow while the feature is available, either for the first time or to add + // another account, do not need to be informed about the multi-account feature. + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index d8103c18c0..934964e447 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.accountprovider.AccountProvider 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.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.asEventId @@ -349,8 +350,7 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val shareIntentData: ShareIntentData?, - val permalinkData: PermalinkData?, + val permalinkData: PermalinkData, ) : NavTarget @Parcelize data class NotLoggedInFlow( @@ -389,6 +389,15 @@ class RootFlowNode( override fun navigateToAddAccount() { backstack.push(NavTarget.NotLoggedInFlow(null)) } + + override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) { + lifecycleScope.launch { + val loggedInFlowNode = attachSession(sessionId) + roomId?.let { + loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false) + } + } + } } val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val buildContext = if (savedNavState != null) { @@ -463,13 +472,7 @@ class RootFlowNode( // Do not pop when the account is changed to avoid a UI flicker. backstack.pop() } - attachSession(sessionId).apply { - if (navTarget.shareIntentData != null) { - attachIncomingShare(navTarget.shareIntentData) - } else if (navTarget.permalinkData != null) { - attachPermalinkData(navTarget.permalinkData) - } - } + attachSession(sessionId).attachPermalinkData(navTarget.permalinkData) } } @@ -539,21 +542,9 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - // wait for the current session to be restored - val loggedInFlowNode = attachSession(latestSessionId) - if (sessionStore.numberOfSessions() > 1) { - // Several accounts, let the user choose which one to use - backstack.push( - NavTarget.AccountSelect( - currentSessionId = latestSessionId, - shareIntentData = shareIntentData, - permalinkData = null, - ) - ) - } else { - // Only one account, directly attach the incoming share node. - loggedInFlowNode.attachIncomingShare(shareIntentData) - } + // wait for the current session to be restored, then attach the incoming share node. + // In case of multiple accounts, the user can switch account from the room select screen. + attachSession(latestSessionId).attachIncomingShare(shareIntentData) } } @@ -576,7 +567,6 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - shareIntentData = null, permalinkData = permalinkData, ) ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 7080b34f8e..201a4adab0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -28,8 +28,8 @@ import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.features.joinroom.api.JoinRoomEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 8b02752a64..17d6826a6c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt new file mode 100644 index 0000000000..4d5cfe06f3 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.SessionId +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.roomlist.RoomListService +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.roomlist.FakeRoomListService +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoggedInEventProcessorTest { + @Test + fun `test observeEvents with left membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with left membership change for space`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = true, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with invitation rejected membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.INVITED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with knock retracted membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.KNOCKED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test stopObserving cancels the child scope`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + assertThat(loggedInEventProcessor.currentChildScope?.isActive).isTrue() + + testScheduler.runCurrent() + + // Stop observing events + loggedInEventProcessor.stopObserving() + + // Verify that the child scope is cancelled (the scope is null) + assertThat(loggedInEventProcessor.currentChildScope).isNull() + } + + @Test + fun `test notifications are cleared when room list service state is running`() = runTest { + val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } + val snackbarDispatcher = SnackbarDispatcher() + val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) + val roomListService = FakeRoomListService() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + notificationCleaner = notificationCleaner, + roomListService = roomListService, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + // Simulate the room list service state changing to Running + roomListService.postState(RoomListService.State.Running) + + testScheduler.runCurrent() + + // Verify that the notifications were cleared + clearReadNotificationsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + private fun TestScope.createLoggedInEventProcessor( + snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + sessionId: SessionId = A_SESSION_ID, + roomListService: FakeRoomListService = FakeRoomListService(), + notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), + ) = LoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + sessionId = sessionId, + roomListService = roomListService, + notificationCleaner = notificationCleaner, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index cc23aea626..4a20341212 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -26,6 +26,7 @@ import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.verification.IncomingVerificationRequestObserver +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginEntryPoint @@ -33,7 +34,6 @@ import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint @@ -42,7 +42,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory @@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() { loginEntryPoint = loginEntryPoint, imageLoaderHolder = FakeImageLoaderHolder(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), ) } ) diff --git a/enterprise b/enterprise index 073abe3b44..819746a734 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 073abe3b44598a072a87a9c92d15c6e5bc4a88af +Subproject commit 819746a734fbbeb4ffcbf3e842f7cc694cccc8a4 diff --git a/features/analytics/api/build.gradle.kts b/features/analytics/api/build.gradle.kts index 5c7f0b223a..f861a375b3 100644 --- a/features/analytics/api/build.gradle.kts +++ b/features/analytics/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) - implementation(projects.libraries.androidutils) - implementation(projects.libraries.uiStrings) } diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index d743ae4cd6..69def559a1 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -17,4 +17,6 @@ sealed interface Announcement { } data object NewNotificationSound : Announcement + + data object MultiAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index adb81db61a..3c36989571 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -29,10 +29,11 @@ class DefaultAnnouncementService( ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) + is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } + Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -44,16 +45,22 @@ class DefaultAnnouncementService( return combine( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { _, newNotificationSoundStatus -> + announcementStore.announcementStatusFlow(Announcement.MultiAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } + // The multi-account announcement is pending until it is dismissed. Users who were already + // logged in when the feature became available still have the default NeverShown status. + if (multiAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.MultiAccount) + } } } } - private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) { val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2093baa8b3..2235285bcf 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") +private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -36,11 +37,15 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // Default-status convention: Shown for one-shot intro/feature announcements that should - // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted - // in by AppMigration08). + // NOT replay on first launch. + // - Fullscreen.Space is disabled + // - NewNotificationSound is opted in by AppMigration08 + // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters + // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -56,4 +61,5 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey + Announcement.MultiAccount -> multiAccountKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index c72d147c1d..18442b362d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) } + @Test + fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.MultiAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - assertThat(awaitItem()).isEmpty() + // MultiAccount is pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + // Still pending + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index ed6dfec850..9b5e762eb6 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -31,10 +33,12 @@ class InMemoryAnnouncementStore( override suspend fun reset() { fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + multiAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + Announcement.MultiAccount -> multiAccountAnnouncement } } diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt similarity index 96% rename from features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt rename to features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt index fefb61d722..f59ac9bf6c 100644 --- a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.rageshake.test.logs +package io.element.android.features.announcement.test import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/call/api/build.gradle.kts b/features/call/api/build.gradle.kts index 1480a31fd3..572e3fdee4 100644 --- a/features/call/api/build.gradle.kts +++ b/features/call/api/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index e872678b63..e80622fa74 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -90,7 +90,6 @@ dependencies { api(projects.features.call.api) testCommonDependencies(libs, true) - testImplementation(projects.features.call.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index 78b08e4468..82a4ec0bb5 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -29,7 +29,7 @@ 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.SessionId import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import kotlin.time.Duration.Companion.seconds diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index c0f01fe0ac..74a6b4873b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder 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.OnMissedCallNotificationHandler diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt similarity index 95% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt index 82c28209f7..76527b1165 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt @@ -6,17 +6,16 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.content.Intent import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.ui.ElementCallActivity -import io.element.android.features.call.utils.FakeActiveCallManager -import io.element.android.features.call.utils.FakeNativeCallEntryPoint +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint import io.element.android.features.callnative.api.NativeCallEntryPoint import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt index e356358437..da76f088ef 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.Manifest import android.webkit.PermissionRequest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt index 3c072c5958..7b37c5dcd6 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt @@ -6,13 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.notifications +package io.element.android.features.call.impl.notifications import androidx.core.graphics.drawable.IconCompat import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt index f0cdd44082..3193a73343 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt index f07f7039d3..c75d14a599 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt @@ -5,11 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.ui.CallScreenBackPressAction -import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy import org.junit.Test class CallScreenBackPressPolicyTest { diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt index 276e6670f1..2889869565 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -14,13 +14,10 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenNavigator -import io.element.android.features.call.impl.ui.CallScreenPresenter +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeCallWidgetProvider +import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor import io.element.android.features.call.impl.utils.WidgetMessageSerializer -import io.element.android.features.call.utils.FakeActiveCallManager -import io.element.android.features.call.utils.FakeCallWidgetProvider -import io.element.android.features.call.utils.FakeWidgetMessageInterceptor import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt index 76ce8e8ede..4107a0826a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import android.view.KeyEvent import android.webkit.WebView @@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.call.impl.pip.PictureInPictureEvent import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenState -import io.element.android.features.call.impl.ui.CallScreenView -import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge -import io.element.android.features.call.impl.ui.aCallScreenState import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.robolectric.RobolectricTest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt similarity index 78% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt index 431b746362..f4a0b7e16a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui - -import io.element.android.features.call.impl.ui.CallScreenNavigator +package io.element.android.features.call.impl.ui class FakeCallScreenNavigator : CallScreenNavigator { var closeCalled = false diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt similarity index 79% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt index f68842813b..4329174636 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import androidx.compose.runtime.Composable -import io.element.android.features.call.impl.ui.LanguageTagProvider class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { @Composable diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt index c95f8c5088..6820985825 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import android.os.PowerManager import androidx.core.app.NotificationManagerCompat @@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.aCallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.CallState -import io.element.android.features.call.impl.utils.DefaultActiveCallManager -import io.element.android.features.call.impl.utils.DefaultCurrentCallService 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.SessionId @@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider 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.aRoomInfo -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt index 093abfcd32..69d95ac12c 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.test.A_ROOM_ID diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt similarity index 88% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt index c2c38284a9..96fb0cb5cb 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt @@ -6,12 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.CallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt similarity index 90% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt index ebc00b0e8f..98789c1d71 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.CallWidgetProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt similarity index 90% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt index 742ba55bdc..dcabb72507 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeNativeCallEntryPoint.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import io.element.android.features.call.api.CallData import io.element.android.features.callnative.api.NativeCallEntryPoint diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt similarity index 84% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt index 6b68b1519c..8fff92faf3 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.WidgetMessageInterceptor import kotlinx.coroutines.flow.MutableSharedFlow class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { diff --git a/features/call/test/build.gradle.kts b/features/call/test/build.gradle.kts index f06c5ed16e..920670287e 100644 --- a/features/call/test/build.gradle.kts +++ b/features/call/test/build.gradle.kts @@ -17,7 +17,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) api(projects.features.call.api) implementation(projects.libraries.matrix.api) diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts index a135f46ad3..a7fdd18252 100644 --- a/features/callnative/impl/build.gradle.kts +++ b/features/callnative/impl/build.gradle.kts @@ -47,8 +47,6 @@ dependencies { testCommonDependencies(libs) testImplementation(libs.element.call.test) testImplementation(projects.features.call.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.services.appnavstate.test) } diff --git a/features/contentscanner/api/build.gradle.kts b/features/contentscanner/api/build.gradle.kts index 0e3d592878..b90b0c277f 100644 --- a/features/contentscanner/api/build.gradle.kts +++ b/features/contentscanner/api/build.gradle.kts @@ -8,16 +8,8 @@ android { } dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) - testImplementation(libs.molecule.runtime) - testImplementation(libs.test.truth) - testImplementation(libs.test.turbine) - testImplementation(projects.libraries.matrix.test) } diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts index 34558a182e..1f17a0d991 100644 --- a/features/contentscanner/impl/build.gradle.kts +++ b/features/contentscanner/impl/build.gradle.kts @@ -24,7 +24,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 2cd3961ead..ce0584828b 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) @@ -52,7 +51,6 @@ dependencies { testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml index b9934dfb63..8b0f251bdb 100644 --- a/features/createroom/impl/src/main/res/values-zh/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -8,7 +8,7 @@ "新房间" "新空间" "仅限受邀人员加入。" - "私密" + "私有" "任何人都能找到此房间。 你可以随时在房间设置中更改。" "任何人都可以加入,包括你的组织之外的人员。" @@ -18,7 +18,7 @@ "%1$s 中的任何人都可以加入,但其他人必须申请访问。" "申请加入" "仅限受邀人员加入。" - "私密" + "私有" "任何人都可以加入,包括你的组织之外的人员。" "公共" "%1$s 中的任何人都可以加入。" diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt similarity index 88% rename from features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt rename to features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt index a28e47e5a0..430386f0f4 100644 --- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt +++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt @@ -6,10 +6,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.createroom.api +package io.element.android.features.createroom.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/deactivation/impl/build.gradle.kts b/features/deactivation/impl/build.gradle.kts index bca144071b..c0a38cabab 100644 --- a/features/deactivation/impl/build.gradle.kts +++ b/features/deactivation/impl/build.gradle.kts @@ -28,7 +28,6 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt similarity index 91% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt index cc1f5093fd..16bed4f04a 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl sealed interface AccountDeactivationEvent { data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt similarity index 95% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt index 44be7b5d53..a3b648a03d 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt similarity index 98% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt index 11e658aa7f..3dd527bc9e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt index ebf9fec3c4..73ddf6857e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import android.os.Parcelable import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt similarity index 97% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt index b5ab948b05..49bb01f71e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt similarity index 98% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt index 0d2bbefd89..4c256bf3b4 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -44,8 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.deactivation.impl.R -import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog +import io.element.android.features.deactivation.impl.ui.AccountDeactivationActionDialog import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.form.textFieldState diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt similarity index 93% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt index c93d519e67..c0c62cf6cc 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt similarity index 96% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt index 84037d7eb1..f4862d0df7 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt index ab9d87c543..7e255b0b5b 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt similarity index 99% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt index d6402c2f59..8a9aac510b 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt similarity index 98% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt index 5c650fea9d..4da3f236a9 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt @@ -8,7 +8,7 @@ @file:OptIn(ExperimentalTestApi::class) -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest @@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import io.element.android.features.deactivation.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_PASSWORD diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt similarity index 95% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt index a9fdf3ff36..0a6fbb801c 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts index 5e7f81e4b4..1c541c5080 100644 --- a/features/enterprise/api/build.gradle.kts +++ b/features/enterprise/api/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { implementation(projects.libraries.compound) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) } diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts index 2585d00d23..a600920586 100644 --- a/features/enterprise/impl-foss/build.gradle.kts +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(libs.serialization.json) testCommonDependencies(libs) diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts index 99dbe733dd..c37fc53de3 100644 --- a/features/enterprise/test/build.gradle.kts +++ b/features/enterprise/test/build.gradle.kts @@ -18,6 +18,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.compound) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(projects.tests.testutils) } diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt index 30a48c91de..492a63ca6e 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope 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.SessionId import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode @@ -40,6 +41,7 @@ class ForwardMessagesNode( @Assisted plugins: List, presenterFactory: ForwardMessagesPresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -62,7 +64,8 @@ class ForwardMessagesNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + // In Forward mode, the session cannot be changed presenter.onRoomSelected(roomIds) } @@ -75,6 +78,7 @@ class ForwardMessagesNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Forward, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt index c082e4ecd3..651b852a57 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError @@ -39,6 +40,7 @@ class DefaultForwardEntryPointTest { plugins = plugins, presenterFactory = { _, _ -> createForwardMessagesPresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, ) } val callback = object : ForwardEntryPoint.Callback { diff --git a/features/forward/test/build.gradle.kts b/features/forward/test/build.gradle.kts index bf9db05e34..a3d8428be2 100644 --- a/features/forward/test/build.gradle.kts +++ b/features/forward/test/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.features.forward.api) implementation(projects.tests.testutils) } diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 49b3baa71b..f6d1970a5a 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -32,12 +32,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.features.analytics.api) implementation(projects.features.logout.api) implementation(projects.features.securebackup.api) diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1c6a0a906e..486a8bafce 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + fun navigateToAddAccount() } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 5a9bb700f2..7fcc2dde32 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -45,14 +45,11 @@ dependencies { implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink.api) implementation(projects.libraries.fullscreenintent.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.features.announcement.api) implementation(projects.features.invite.api) - implementation(projects.features.networkmonitor.api) implementation(projects.features.leaveroom.api) implementation(projects.features.rageshake.api) implementation(projects.services.analytics.api) @@ -72,11 +69,8 @@ dependencies { testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.indicator.test) - testImplementation(projects.libraries.permissions.noop) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) - testImplementation(projects.services.toolbox.test) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index f9c5fc3bc0..f4620b0f09 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -8,8 +8,8 @@ package io.element.android.features.home.impl -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.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder { matrixUser } else { // Use the data from the DB - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) + it.toMatrixUser() } } .let { sessionList -> diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 7d2bb09d30..6d1eec3dff 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SwitchToAccount(val sessionId: SessionId) : HomeEvent + data object DismissMultiAccountAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 74dd6e441d..a481171e97 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -221,6 +221,7 @@ class HomeFlowNode( homeState = state, onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, + onAddAccountClick = callback::navigateToAddAccount, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index f0bcedbb43..95c98a4aec 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,18 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Inject @@ -43,6 +48,8 @@ class HomePresenter( private val homeSpacesPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, + private val featureFlagService: FeatureFlagService, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @@ -59,6 +66,30 @@ class HomePresenter( }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val showMultiAccountAnnouncement by remember { + combine( + announcementService.announcementsToShowFlow(), + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount), + sessionStore.sessionsFlow(), + ) { announcements, isMultiAccountEnabled, sessions -> + // Do not announce the feature to users who already have several accounts + isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount) + } + }.collectAsState(false) + LaunchedEffect(Unit) { + // The user already uses several accounts, so the feature does not need to be announced anymore, + // even if they later remove accounts. + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + announcements.contains(Announcement.MultiAccount) && sessions.size > 1 + } + .filter { it } + .collect { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } @@ -82,6 +113,9 @@ class HomePresenter( is HomeEvent.SwitchToAccount -> coroutineState.launch { sessionStore.setLatestSession(event.sessionId.value) } + HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } } } @@ -95,6 +129,7 @@ class HomePresenter( homeSpacesState = homeSpacesState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index 462084aefb..a49d3876e9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -28,6 +28,7 @@ data class HomeState( val homeSpacesState: HomeSpacesState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, + val showMultiAccountAnnouncement: Boolean, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f39942275a..f86c32c532 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -48,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + ) + aHomeState( + showMultiAccountAnnouncement = true, ) } @@ -61,6 +63,7 @@ internal fun aHomeState( roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, + showMultiAccountAnnouncement: Boolean = false, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -68,6 +71,7 @@ internal fun aHomeState( hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, canReportBug = canReportBug, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index bcc3229598..6ff31a7c12 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.home.impl.components.HomeTopBar +import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.model.RoomListRoomSummary @@ -76,6 +77,7 @@ fun HomeView( homeState: HomeState, onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, + onAddAccountClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -110,6 +112,13 @@ fun HomeView( ) } + if (homeState.showMultiAccountAnnouncement) { + MultiAccountAnnouncementBottomSheet( + onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) }, + onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() }, + ) + } + leaveRoomView() HomeScaffold( @@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta homeState = state, onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { homeState = aHomeState(), onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt new file mode 100644 index 0000000000..cec1dbf3a0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +/** + * Bottom sheet announcing that several accounts can now be added. + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAccountAnnouncementBottomSheet( + onDismiss: () -> Unit, + onAddAccountClick: () -> Unit, +) { + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + ) + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + scrollable = false, + ) { + MultiAccountAnnouncementContent( + onAddAccountClick = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + onAddAccountClick() + } + }, + ) + } +} + +@Composable +private fun MultiAccountAnnouncementContent( + onAddAccountClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { heading() }, + text = stringResource(R.string.screen_multi_account_announcement_title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = annotatedTextWithBold( + text = stringResource(R.string.screen_multi_account_announcement_description, path), + boldText = path, + ), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + Button( + text = stringResource(R.string.screen_multi_account_announcement_action), + leadingIcon = IconSource.Vector(CompoundIcons.Plus()), + modifier = Modifier.fillMaxWidth(), + onClick = onAddAccountClick, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) { + MultiAccountAnnouncementBottomSheet( + onDismiss = {}, + onAddAccountClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 228ca4d754..68aba21188 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -47,6 +47,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -119,7 +120,7 @@ class RoomListPresenter( val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } val showAllActivity by produceState(initialValue = false) { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it } + featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature).collect { value = it } } fun handleEvent(event: RoomListEvent) { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index f07a169589..bbd7d57ae3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -10,12 +10,14 @@ package io.element.android.features.home.impl.search import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.home.impl.search.history.SearchHistoryResult @@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType @@ -81,6 +84,7 @@ class GlobalSearchPresenter( val isEnabled by produceState(false) { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } } + val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) } val queryState = rememberTextFieldState() var isSearchActive by remember { mutableStateOf(false) } @@ -88,6 +92,8 @@ class GlobalSearchPresenter( var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } + StartSearchBackfillWhenVisible(isEnabled = isEnabled, isSearchActive = isSearchActive) + val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { searchHistoryStore.history.collectLatest { history -> val mappedHistoryItems = history.mapNotNull { result -> @@ -297,6 +303,35 @@ class GlobalSearchPresenter( ) } + @Composable + private fun StartSearchBackfillWhenVisible( + isEnabled: Boolean, + isSearchActive: Boolean, + ) { + val coroutineScope = rememberCoroutineScope() + var hasBackfillRun by rememberSaveable { mutableStateOf(false) } + if (isEnabled && isSearchActive && !hasBackfillRun) { + DisposableEffect(Unit) { + // When we open the message search, we want to backfill the search results so that we can show the user more results if they scroll down. + val handle = matrixClient.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrNull() + ?: return@DisposableEffect onDispose { + Timber.w("Could not start search backfill, it may already be running.") + } + + val job = coroutineScope.launch(coroutineDispatchers.io) { + while (handle.isRunning()) { + delay(100.milliseconds) + } + hasBackfillRun = true + } + onDispose { + job.cancel() + handle.close() + } + } + } + } + private fun mapMediaContent( result: MessageSearchResult, roomInfo: RoomInfo, diff --git a/features/home/impl/src/main/res/values-in/translations.xml b/features/home/impl/src/main/res/values-in/translations.xml index 36512038ba..3b293502b1 100644 --- a/features/home/impl/src/main/res/values-in/translations.xml +++ b/features/home/impl/src/main/res/values-in/translations.xml @@ -14,7 +14,7 @@ "Penyimpanan kunci Anda tidak sinkron" "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci." "Tingkatkan pengalaman panggilan Anda" - "Semua Obrolan" + "Obrolan" "Space" "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?" "Tolak undangan" @@ -46,7 +46,7 @@ Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapa "Selamat! Anda tidak memiliki pesan yang belum dibaca!" "Permintaan untuk bergabung dikirim" - "Semua Obrolan" + "Obrolan" "Tandai sebagai dibaca" "Tandai sebagai belum dibaca" "Ruangan ini telah ditingkatkan" diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index b3c14354f5..4f3420fa23 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) invited you" "This is a one time process, thanks for waiting." "Setting up your account." + "Add account" + "Go to %1$s to sign into another account. Then you can easily switch between them." + "You can now add multiple accounts" "Create a new conversation or room" "Clear filters" "Get started by messaging someone." diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index cf9336b6ea..643de32ecb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -52,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToAddAccount() = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index a5553352db..cf592d9e7e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -9,12 +9,18 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.matrix.api.MatrixClient @@ -30,6 +36,8 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -144,6 +152,99 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue() + } + } + + @Test + fun `present - multi account announcement is not shown when the feature is disabled`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to false), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + } + } + + @Test + fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@alice:server.org"), + aSessionData(sessionId = "@bob:server.org"), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } + + @Test + fun `present - dismiss multi account announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = announcementService, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + val state = expectMostRecentItem() + assertThat(state.showMultiAccountAnnouncement).isTrue() + state.eventSink(HomeEvent.DismissMultiAccountAnnouncement) + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } } internal fun createHomePresenter( @@ -154,6 +255,8 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), ) = HomePresenter( client = client, syncService = syncService, @@ -163,4 +266,6 @@ internal fun createHomePresenter( homeSpacesPresenter = homeSpacesPresenter, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, + announcementService = announcementService, + featureFlagService = featureFlagService, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index e67a0334b8..f73e39b8d7 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.FakeDateTimeObserver import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory @@ -32,7 +33,6 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter @@ -40,6 +40,7 @@ import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient @@ -655,7 +656,7 @@ class RoomListPresenterTest { ) val featureFlagService = FakeFeatureFlagService( initialState = mapOf( - FeatureFlags.ShowAllActivityInRoomList.key to true, + ShowAllActivityInRoomListFeature.key to true, FeatureFlags.UnreadIndicatorCount.key to true, ) ) @@ -672,7 +673,7 @@ class RoomListPresenterTest { assertThat(enabledState.contentAsRooms().showAllActivity).isTrue() assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue() - featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false) + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, false) val allActivityDisabledState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity }.last() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index bd18cd903e..3cff7ca28b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.home.impl.HomeEvent import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.R import io.element.android.features.home.impl.aHomeState @@ -259,6 +260,23 @@ class RoomListViewTest : RobolectricTest() { ) ) } + + @Test + fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + setRoomListView( + state = aRoomListState(), + showMultiAccountAnnouncement = true, + homeEventSink = eventsRecorder, + onAddAccountClick = callback, + ) + clickOn(R.string.screen_multi_account_announcement_action) + // Wait for the bottom sheet to be hidden + waitForIdle() + } + eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement) + } } private fun AndroidComposeUiTest.setRoomListView( @@ -273,12 +291,20 @@ private fun AndroidComposeUiTest.setRoomListView( onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), + onAddAccountClick: () -> Unit = EnsureNeverCalled(), + showMultiAccountAnnouncement: Boolean = false, + homeEventSink: (HomeEvent) -> Unit = {}, ) { setSafeContent { HomeView( - homeState = aHomeState(roomListState = state), + homeState = aHomeState( + roomListState = state, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, + eventSink = homeEventSink, + ), onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, + onAddAccountClick = onAddAccountClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 0dda72b0fb..5273e5555b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -33,13 +33,16 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.search.MessageSearchService 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.FakeSdkPendingTask import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.search.FakeMessageSearch import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf @@ -205,7 +208,9 @@ class GlobalSearchPresenterTest { @Test fun `present - message search results are mapped when the message search emits`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -232,7 +237,9 @@ class GlobalSearchPresenterTest { @Test fun `present - message search displays the uninitialized state after removing the query`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -267,7 +274,9 @@ class GlobalSearchPresenterTest { @Test fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -432,6 +441,44 @@ class GlobalSearchPresenterTest { assertThat(awaitItem().results.isLoading()).isTrue() } } + + @Test + fun `present - will automatically start search backfill and it will be cancelled when the presenter is closed`() = runTest { + var isRunning = false + val closeLambda = lambdaRecorder { isRunning = false } + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { + isRunning = true + Result.success(FakeSdkPendingTask(isRunningLambda = { isRunning }, closeLambda = closeLambda)) + }, + ), + ) + val presenter = createGlobalSearchPresenter( + matrixClient = matrixClient, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MessageSearch.key to true), + ), + ) + presenter.test { + // Await the initial state, with the feature enabled + val initialState = consumeItemsUntilPredicate { it.isEnabled }.last() + assertThat(isRunning).isFalse() + + // Opening the search starts the backfill + initialState.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + consumeItemsUntilPredicate { it.isSearchActive } + // The backfill polling loop never ends while the task is running, so don't wait until the scheduler is idle + testScheduler.runCurrent() + assertThat(isRunning).isTrue() + closeLambda.assertions().isNeverCalled() + + // Closing the presenter closes the task + cancelAndIgnoreRemainingEvents() + closeLambda.assertions().isCalledOnce() + assertThat(isRunning).isFalse() + } + } } private fun TestScope.createGlobalSearchPresenter( @@ -442,7 +489,8 @@ private fun TestScope.createGlobalSearchPresenter( dateFormatter: DateFormatter = FakeDateFormatter(), fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), permalinkParser: PermalinkParser = FakePermalinkParser(), - matrixClient: MatrixClient = FakeMatrixClient(), + matrixClient: MatrixClient = + FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) })), searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(), ): GlobalSearchPresenter { return GlobalSearchPresenter( diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts index 084ad1088d..390fc6f3eb 100644 --- a/features/invite/api/build.gradle.kts +++ b/features/invite/api/build.gradle.kts @@ -18,7 +18,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - implementation(projects.services.analytics.api) } diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts index e033f2740c..0ea9923f5a 100644 --- a/features/invite/impl/build.gradle.kts +++ b/features/invite/impl/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts index 2ab2fcb4a3..7d1dd2ac59 100644 --- a/features/invitepeople/impl/build.gradle.kts +++ b/features/invitepeople/impl/build.gradle.kts @@ -39,12 +39,10 @@ dependencies { implementation(projects.libraries.testtags) implementation(libs.coil.compose) implementation(projects.services.apperror.api) - implementation(projects.libraries.featureflag.api) api(projects.features.invitepeople.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.services.apperror.test) - testImplementation(projects.libraries.featureflag.test) } diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts index 71acdad128..c18a4b8a81 100644 --- a/features/joinroom/impl/build.gradle.kts +++ b/features/joinroom/impl/build.gradle.kts @@ -37,12 +37,9 @@ dependencies { implementation(projects.features.invite.api) implementation(projects.features.roomdirectory.api) implementation(projects.services.analytics.api) - implementation(projects.libraries.preferences.api) - implementation(projects.appconfig) testCommonDependencies(libs, true) testImplementation(projects.features.invite.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) } diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index e6a1a30167..2e60838b41 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.designsystem) diff --git a/features/knockrequests/test/build.gradle.kts b/features/knockrequests/test/build.gradle.kts index dc3407da01..726ea6969f 100644 --- a/features/knockrequests/test/build.gradle.kts +++ b/features/knockrequests/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.knockrequests.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/licenses/api/build.gradle.kts b/features/licenses/api/build.gradle.kts index fcbf81f210..86070f65f9 100644 --- a/features/licenses/api/build.gradle.kts +++ b/features/licenses/api/build.gradle.kts @@ -15,6 +15,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/licenses/impl/build.gradle.kts b/features/licenses/impl/build.gradle.kts index 52f21ac0bd..85023488cd 100644 --- a/features/licenses/impl/build.gradle.kts +++ b/features/licenses/impl/build.gradle.kts @@ -31,5 +31,4 @@ dependencies { testCommonDependencies(libs) testImplementation(libs.coroutines.core) - testImplementation(projects.libraries.matrix.test) } diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts index 4659a2d2b8..7c86e1d057 100644 --- a/features/linknewdevice/impl/build.gradle.kts +++ b/features/linknewdevice/impl/build.gradle.kts @@ -28,38 +28,24 @@ setupDependencyInjection() dependencies { // TODO Cleanup - implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) - implementation(projects.features.rageshake.api) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) - implementation(projects.libraries.oauth.api) - implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.linknewdevice.api) testCommonDependencies(libs, true) - testImplementation(projects.features.linknewdevice.test) testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.enterprise.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) } diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts index 388612f920..39a99e7d67 100644 --- a/features/linknewdevice/test/build.gradle.kts +++ b/features/linknewdevice/test/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.features.linknewdevice.api) implementation(projects.tests.testutils) } diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 8830b39a22..36dbfdb139 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -68,12 +68,9 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.wellknown.api) implementation(projects.features.enterprise.api) implementation(libs.coil.compose) - implementation(libs.datetime) testCommonDependencies(libs) } diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 1fcb2e1fc4..9733f3c768 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -42,14 +42,11 @@ dependencies { implementation(projects.services.appnavstate.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.datastore.preferences) - implementation(libs.datetime) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) @@ -58,10 +55,8 @@ dependencies { testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) testImplementation(projects.features.location.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 68333251a5..d90888da5c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -140,7 +140,7 @@ fun MapBottomSheetScaffold( isReady && mapState != null -> { MaplibreMap( state = mapState, - cameraPadding = sheetPadding, + viewportInsets = sheetPadding, renderOptions = renderOptions, interactions = interactions, uiOptions = uiOptions, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index aecb6f12b8..bfa94f8c61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -8,33 +8,28 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import org.maplibre.compose.layers.LocationIndicatorDefaults +import org.maplibre.compose.layers.LocationIndicatorLayer import org.maplibre.compose.location.LocationMeasurement -import org.maplibre.compose.location.LocationPuck -import org.maplibre.compose.location.LocationPuckColors -import org.maplibre.compose.location.LocationPuckSizes -import org.maplibre.spatialk.units.Length @Composable fun UserLocationPuck( location: LocationMeasurement?, ) { - LocationPuck( - idPrefix = "user-location", - location = location, - // Hide the accuracy circle. - accuracyThreshold = Length.PositiveInfinity, - // Hide the bearing (and bearing accuracy) indicator. + LocationIndicatorLayer( + id = "user-location", + location = location?.position, + // Hide the accuracy circle and the bearing accuracy sector: leaving `accuracyRadius` + // and `bearingAccuracy` null is what hides them. + accuracyRadius = null, + bearingAccuracy = null, + // Hide the bearing indicator. bearing = null, - sizes = LocationPuckSizes( - dotRadius = 8.dp, - dotStrokeWidth = 2.dp, + bearingImage = null, + topImage = LocationIndicatorDefaults.topImage( + color = ElementTheme.colors.iconAccentPrimary, + borderColor = ElementTheme.colors.bgCanvasDefault, ), - colors = LocationPuckColors( - dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary, - dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary, - dotStrokeColor = ElementTheme.colors.bgCanvasDefault, - ) ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt index c5760c3a95..08cff42c4a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import io.element.android.features.location.impl.common.MapDefaults import kotlinx.coroutines.flow.distinctUntilChanged +import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.location.LocationMeasurement import org.maplibre.compose.map.MapState import kotlin.math.abs @@ -77,7 +78,7 @@ internal fun UserLocationTrackingEffect( zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) ) if (hasPositioned) { - mapState.animateCameraPosition(position) + mapState.animateCamera(position.toCameraUpdate(), CameraAnimation.Fly()) } else { mapState.setCameraPosition(position) hasPositioned = true diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 8e45996d09..8a797ad1a6 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.map.MapState @@ -149,7 +150,7 @@ private fun ShowLocationViewContent( zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM), ) coroutineScope.launch { - mapState.animateCameraPosition(position = position) + mapState.animateCamera(update = position.toCameraUpdate(), animation = CameraAnimation.Fly()) } }, modifier = modifier, diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index 414b4b5909..d287cbea78 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -32,12 +32,10 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.cryptography.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) implementation(projects.features.logout.api) implementation(projects.libraries.uiCommon) @@ -52,7 +50,6 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.impl) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.features.logout.test) diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 6cf8945e05..8cbfa9575d 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -71,14 +71,11 @@ dependencies { implementation(projects.libraries.oauth.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.login.api) testCommonDependencies(libs, true) - testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) testImplementation(projects.libraries.preferences.test) @@ -86,7 +83,6 @@ dependencies { testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) testImplementation(libs.androidx.camera.camera2) testImplementation(libs.androidx.camera.lifecycle) } diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 34ba1bd0ed..7a071a285b 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -89,7 +89,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain." "%1$s tidak didukung" "Siap untuk memindai" "Buka %1$s di perangkat desktop" - "Klik pada avatar Anda" + "Klik avatar Anda" "Pilih %1$s" "“Tautkan perangkat baru”" "Pindai kode QR dengan perangkat ini" diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts index 4453d37931..9a0d858dba 100644 --- a/features/logout/api/build.gradle.kts +++ b/features/logout/api/build.gradle.kts @@ -14,8 +14,5 @@ android { } dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index bc6646a68f..612037a618 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -29,20 +29,17 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) api(projects.features.logout.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) } diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 029ef624e0..20e05a1798 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -19,7 +19,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.preferences.api) api(projects.libraries.textcomposer.impl) } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 882d9f1f97..cd7ce3703a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.emoji.api) - implementation(projects.libraries.roomselect.api) implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) @@ -63,22 +62,17 @@ dependencies { implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) - implementation(projects.libraries.wellknown.api) - implementation(projects.features.networkmonitor.api) implementation(projects.features.contentscanner.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) implementation(libs.coil.compose) - implementation(libs.datetime) implementation(libs.jsoup) - implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.exifinterface) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.sigpwned.emoji4j) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) implementation(libs.matrix.emojibase.bindings) implementation(projects.features.knockrequests.api) @@ -106,7 +100,6 @@ dependencies { testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.emoji.impl) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 931e0de801..7b667c0d6a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 232c107698..2e71af8447 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -48,13 +48,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import kotlin.math.max diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index c7878c00f5..aef5952689 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -37,10 +37,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 5b3803713e..a4dede5d37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -47,12 +47,12 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt index 5b6c7fda37..17e650a9e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -71,8 +71,8 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.coroutines.launch internal val REACTION_SUMMARY_LINE_HEIGHT = 25.sp diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 840f19488d..cfb702678c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -8,7 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -51,8 +51,8 @@ data class GalleryItem( MediaRequestData( source = thumbnailSource ?: mediaSource, kind = MediaRequestData.Kind.Thumbnail( - width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH, - height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT, + width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT, ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 5e78adc945..e67738b0fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -10,9 +10,9 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData data class TimelineItemImageContent( override val filename: String, diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml index 33daf3b00f..8b8a43cde7 100644 --- a/features/messages/impl/src/main/res/values-hr/translations.xml +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -48,9 +48,9 @@ "Anketa" "Oblikovanje teksta" "Razlog (neobavezno)" - "Unesite…" + "Unesite razlog…" "(Neobavezno)" - "Potvrdite uklanjanje" + "Ukloniti poruku?" "Povijest poruka trenutačno nije dostupna." "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka." "Želite li ih pozvati natrag?" diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml index bae097e818..bed52fe550 100644 --- a/features/messages/impl/src/main/res/values-in/translations.xml +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -36,7 +36,7 @@ "Tambahkan media" "Blokir pengguna" "Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini" - "Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." + "Pesan ini akan dilaporkan ke penyedia akun Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." "Alasan melaporkan konten ini" "Kamera" "Ambil foto" diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index 1fab6ed5b3..d23ba8febc 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -49,9 +49,9 @@ "Опрос" "Форматирование текста" "Причина (необязательно)" - "Введите…" + "Укажите причину…" "(Необязательно)" - "Подтвердите удаление" + "Удалить сообщение?" "В настоящее время история сообщений недоступна в этой комнате." "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." "Хотите пригласить их снова?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index e4c3310089..8fac783581 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.timeline.Timeline @@ -28,7 +29,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.impl.DefaultMediaSender diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt index 466bfba4fd..7f608fe283 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -11,8 +11,8 @@ package io.element.android.features.messages.test.timeline.voicemessages.compose import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaSender diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt similarity index 90% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt index 471ee30d22..57faede46e 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt similarity index 88% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt index 000d898a6a..ca5b9a4882 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import io.element.android.libraries.architecture.AsyncData diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt index edefa7ce75..df5f38a797 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt @@ -12,8 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.api.MigrationEntryPoint -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationEntryPoint +import io.element.android.features.migration.api.MigrationState @ContributesBinding(AppScope::class) class DefaultMigrationEntryPoint( diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt index 2d78e5f706..1301c0e309 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.features.migration.impl.migrations.AppMigration import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt index de25284867..b43681427e 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt @@ -9,7 +9,7 @@ package io.element.android.features.migration.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.architecture.AsyncData internal class MigrationStatePreviewParam : PreviewParameterProvider { diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt index c7f4dd678a..c3f32eb06b 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt index 46bde271c4..8451d1b07f 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt @@ -10,7 +10,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts index 5adc383d64..e08e7e0f78 100644 --- a/features/networkmonitor/impl/build.gradle.kts +++ b/features/networkmonitor/impl/build.gradle.kts @@ -21,11 +21,8 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.features.networkmonitor.api) testCommonDependencies(libs) - testImplementation(projects.libraries.matrix.test) - testImplementation(projects.features.networkmonitor.test) } diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts index 175f7b4471..0d5790639e 100644 --- a/features/poll/impl/build.gradle.kts +++ b/features/poll/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.services.analytics.api) implementation(projects.features.messages.api) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 11f863e26d..c137d6be06 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -48,15 +48,12 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.cachestore.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.ui) - implementation(projects.libraries.network) implementation(projects.libraries.pushstore.api) implementation(projects.libraries.indicator.api) implementation(projects.libraries.preferences.api) @@ -80,14 +77,12 @@ dependencies { implementation(projects.features.licenses.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) - implementation(projects.features.home.api) implementation(projects.features.invite.api) implementation(projects.libraries.emoji.api) implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) - implementation(libs.datetime) implementation(libs.coil.compose) implementation(libs.color.picker) implementation(libs.androidx.browser) @@ -108,7 +103,6 @@ dependencies { testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) - testImplementation(projects.libraries.roomselect.test) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.features.deactivation.test) @@ -116,11 +110,9 @@ dependencies { testImplementation(projects.features.invite.test) testImplementation(projects.features.licenses.test) testImplementation(projects.features.lockscreen.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) - testImplementation(projects.libraries.cachestore.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.impl) testImplementation(projects.services.analytics.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt index 18365fdbb4..b356d154a7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt @@ -34,5 +34,5 @@ sealed interface NotificationSettingsEvent { data object DismissCallRingtoneDialog : NotificationSettingsEvent data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent data object LaunchCallRingtonePicker : NotificationSettingsEvent - data object ToggleShowAllActivityInRoomList : NotificationSettingsEvent + data class ToggleShowAllActivityInRoomList(val newValue: Boolean) : NotificationSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 24a366b52d..d2e5fc5b50 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -29,7 +29,7 @@ import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService @@ -200,7 +200,7 @@ class NotificationSettingsPresenter( } val showAllActivityInRoomList by produceState(false) { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList) + featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature) .collect { value = it } } @@ -284,8 +284,8 @@ class NotificationSettingsPresenter( NotificationSettingsEvent.DismissCallRingtoneCopyError -> { callRingtoneCopyError = false } - NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch { - featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, !showAllActivityInRoomList) + is NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch { + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, event.newValue) } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index aa085f3ac6..e6784b5ce7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -205,7 +205,7 @@ private fun NotificationSettingsContentView( title = stringResource(R.string.screen_notification_settings_show_all_activity_title), subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle), isChecked = state.showAllActivityInRoomList, - onCheckedChange = { state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) } + onCheckedChange = { newValue -> state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(newValue)) } ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 4bbe2eeaf8..53234c3723 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -29,8 +29,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient -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.toMatrixUser import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService @@ -85,14 +84,7 @@ class PreferencesRootPresenter( sessionStore.sessionsFlow().map { list -> list .filter { it.userId != matrixClient.sessionId.value } - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) - } + .map { it.toMatrixUser() } .toImmutableList() } }.collectAsState(initial = persistentListOf()) diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml index 1bec1cb75b..387da63a4e 100644 --- a/features/preferences/impl/src/main/res/values-hr/translations.xml +++ b/features/preferences/impl/src/main/res/values-hr/translations.xml @@ -38,7 +38,7 @@ "Ako je to isključeno, nećete moći slati ili primati potvrde o čitanju ili obavijesti o tipkanju." "Uvijek sakrij" "Uvijek prikaži" - "U privatnim sobama" + "Prikaži u privatnim sobama" "Skriveni medij uvijek se može prikazati tako se da se dodirne" "Prikaži medije na vremenskoj traci" "Omogući opciju za prikaz izvora poruke na vremenskoj traci." diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml index faad2bece7..379bca4fb0 100644 --- a/features/preferences/impl/src/main/res/values-in/translations.xml +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -85,7 +85,7 @@ Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah." "Semua" "Sebutan" "Beri tahu saya tentang" - "Beri tahu saya pada @room" + "Penyebutan @room" "Kustom" "Suara kustom" "Terjadi kesalahan saat menghapus file." diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml index 3142639d94..9b1904e95d 100644 --- a/features/preferences/impl/src/main/res/values-pl/translations.xml +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -93,6 +93,8 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "Wzmianki" "Powiadamiaj mnie przez" "Wzmianki @room" + "Kropki lub liczba nieprzeczytanych wiadomości" + "Pokaż całą aktywność na liście pokoi" "Własny" "Własny dźwięk…" "Błąd usuwania pliku" @@ -109,6 +111,7 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "ustawienia systemowe" "Powiadomienia systemowe wyłączone" "Powiadomienia" + "Dodaj konto" "Z dala od urządzenia" "Zaraz wracam" "Własny" diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index c0c1383f1c..96430521d5 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -38,8 +38,8 @@ "Если выключено, Вы не будете видеть, кто печатает и читает сообщения, а также другие пользователи не будут знать, когда Вы печатаете или читаете сообщения." "Всегда скрывать" "Всегда показывать" - "В приватных комнатах" - "Скрытые медиа всегда можно просмотреть, нажав на них." + "Показывать в приватных комнатах" + "Скрытый контент всегда можно отобразить, нажав на него." "Показать медиа в истории сообщений" "Включить опцию просмотра источника сообщения в истории сообщений." "У Вас нет заблокированных пользователей" @@ -62,6 +62,9 @@ "Попробуйте функции в разработке. Эти функции ещё не завершены, они нестабильны и могут измениться." "Хотите попробовать?" "Лаборатория" + "Приглашения" + "Другие пользователи" + "Ваш внешний вид" "Дополнительные параметры" "Мелодия звонка" "Аудио и видео звонки" diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index 9ba85717b5..bdce557b12 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -93,6 +93,8 @@ "Згадки" "Сповіщати мене про" "Згадки @room" + "Крапки або кількість непрочитаних повідомлень" + "Показати всю діяльність у списку кімнат" "Власний" "Користувацький звук…" "Помилка видалення файлу" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml index 48c269815e..bf6bc9fa51 100644 --- a/features/preferences/impl/src/main/res/values-zh/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -91,6 +91,8 @@ "提及" "通知我以下类型" "含有 @room 的提及" + "用于未读消息的圆点或数字" + "显示房间列表中的所有活动" "自定义" "自定义提示音…" "删除文件时出错" @@ -107,6 +109,7 @@ "系统设置" "系统通知已关闭" "通知" + "添加账户" "离开" "马上回来" "自定义…" diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 6e70c9b685..c702ac62a3 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -110,6 +110,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Add account" "Away" "Be right back" "Custom…" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index c8dad6659e..f189949cdc 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.preferences.impl.notifications import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState @@ -1110,13 +1110,13 @@ class NotificationSettingsPresenterTest { @Test fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest { val featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to true) + initialState = mapOf(ShowAllActivityInRoomListFeature.key to true) ) val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) presenter.test { val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() assertThat(state.showAllActivityInRoomList).isTrue() - featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false) + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, false) val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() assertThat(updatedState.showAllActivityInRoomList).isFalse() @@ -1127,22 +1127,22 @@ class NotificationSettingsPresenterTest { @Test fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest { val featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to false) + initialState = mapOf(ShowAllActivityInRoomListFeature.key to false) ) val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) presenter.test { val initialState = awaitItem() assertThat(initialState.showAllActivityInRoomList).isFalse() - initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) + initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(true)) val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() assertThat(enabledState.showAllActivityInRoomList).isTrue() - assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isTrue() - enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) + assertThat(featureFlagService.isFeatureEnabled(ShowAllActivityInRoomListFeature)).isTrue() + enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(false)) val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() assertThat(disabledState.showAllActivityInRoomList).isFalse() - assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isFalse() + assertThat(featureFlagService.isFeatureEnabled(ShowAllActivityInRoomListFeature)).isFalse() cancelAndIgnoreRemainingEvents() } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index fe7238beea..10510d1172 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -207,7 +207,7 @@ class NotificationSettingsViewTest : RobolectricTest() { eventsRecorder.assertList( listOf( NotificationSettingsEvent.RefreshSystemNotificationsEnabled, - NotificationSettingsEvent.ToggleShowAllActivityInRoomList, + NotificationSettingsEvent.ToggleShowAllActivityInRoomList(!initialState), ) ) } diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts index 0200f21e61..578f86a39f 100644 --- a/features/rageshake/impl/build.gradle.kts +++ b/features/rageshake/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.viewfolder.api) - implementation(projects.services.toolbox.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.network) @@ -52,9 +51,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.viewfolder.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.services.toolbox.test) testImplementation(libs.network.mockwebserver) } diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index faad812d73..516562c556 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) - implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/reportroom/impl/build.gradle.kts b/features/reportroom/impl/build.gradle.kts index c945465aec..5a25ba5051 100644 --- a/features/reportroom/impl/build.gradle.kts +++ b/features/reportroom/impl/build.gradle.kts @@ -27,10 +27,8 @@ setupDependencyInjection() dependencies { api(projects.features.reportroom.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/rolesandpermissions/api/build.gradle.kts b/features/rolesandpermissions/api/build.gradle.kts index ca29972606..6ea7080314 100644 --- a/features/rolesandpermissions/api/build.gradle.kts +++ b/features/rolesandpermissions/api/build.gradle.kts @@ -17,7 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - api(projects.libraries.usersearch.api) } diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt similarity index 93% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt index 0526afc938..d50d872a1d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt similarity index 91% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt index 62cf3285f3..00d9059a3d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt similarity index 94% rename from features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt rename to features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt index ce0270033c..a2a1d9c163 100644 --- a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomaliasesolver.api +package io.element.android.features.roomaliasresolver.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/impl/build.gradle.kts b/features/roomaliasresolver/impl/build.gradle.kts index 1860c77017..0c5e1c2b07 100644 --- a/features/roomaliasresolver/impl/build.gradle.kts +++ b/features/roomaliasresolver/impl/build.gradle.kts @@ -27,11 +27,9 @@ setupDependencyInjection() dependencies { api(projects.features.roomaliasresolver.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt index c6f8966131..2eb83a1382 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt @@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.createNode @ContributesBinding(AppScope::class) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt index 041d43cb26..bff41efb82 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt index a519032ed2..f261e56adc 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.roomaliasresolver.impl import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/roomcall/api/build.gradle.kts b/features/roomcall/api/build.gradle.kts index 54bb7bbd02..1220f87a25 100644 --- a/features/roomcall/api/build.gradle.kts +++ b/features/roomcall/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.androidx.compose.ui.tooling.preview) } diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts index 069c890874..20f4eab571 100644 --- a/features/roomcall/impl/build.gradle.kts +++ b/features/roomcall/impl/build.gradle.kts @@ -26,7 +26,6 @@ dependencies { implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 77e2d0c229..b8aa939015 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -34,20 +34,13 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.testtags) api(projects.features.roomdetails.api) - api(projects.libraries.usersearch.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.call.api) - implementation(projects.features.startchat.api) implementation(projects.features.leaveroom.api) implementation(projects.features.userprofile.shared) implementation(projects.services.analytics.compose) @@ -65,14 +58,9 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) - testImplementation(projects.libraries.usersearch.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.rolesandpermissions.test) testImplementation(projects.features.securityandprivacy.test) @@ -81,7 +69,6 @@ dependencies { testImplementation(projects.features.messages.test) testImplementation(projects.features.poll.test) testImplementation(projects.features.reportroom.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml index f446cb8334..4b805104f7 100644 --- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -42,6 +42,9 @@ "Masz niezapisane zmiany." "Zapisać zmiany?" "Dodaj temat" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Szyfrowany" "Nieszyfrowany" "Pokój publiczny" @@ -67,6 +70,9 @@ "Profil" "Prośby o dołączenie" "Role i uprawnienia" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Nazwa" "Bezpieczeństwo i prywatność" "Bezpieczeństwo" diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml index 9094e032a9..a523f25f20 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -42,6 +42,9 @@ "У вас є не збережені зміни." "Зберегти зміни?" "Додати тему" + "Натисніть, щоб говорити" + "Аудіо в кімнаті" + "Відеовиклики" "Зашифровано" "Не зашифровано" "Загальнодоступна кімната" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml index dea8350422..768e5d7eb5 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -42,6 +42,9 @@ "你有未保存的更改。" "保存更改?" "添加主题" + "按下发言" + "房间音频" + "视频通话" "已加密" "未加密" "公共房间" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt index 9a97380d21..4baf43c477 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -13,12 +13,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.call.test.FakeElementCallEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.messages.test.FakeMessagesEntryPoint import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint diff --git a/features/roomdetailsedit/impl/build.gradle.kts b/features/roomdetailsedit/impl/build.gradle.kts index 6b1d886abe..8d5303c7a9 100644 --- a/features/roomdetailsedit/impl/build.gradle.kts +++ b/features/roomdetailsedit/impl/build.gradle.kts @@ -35,23 +35,16 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.services.analytics.api) implementation(projects.libraries.testtags) api(projects.features.roomdetailsedit.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetailsedit/test/build.gradle.kts b/features/roomdetailsedit/test/build.gradle.kts index ff9110eb49..99082d0135 100644 --- a/features/roomdetailsedit/test/build.gradle.kts +++ b/features/roomdetailsedit/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.roomdetetailsedit.test" + namespace = "io.element.android.features.roomdetailsedit.test" } dependencies { implementation(projects.features.roomdetailsedit.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 1c63872fc8..fa9492ecf7 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -30,12 +30,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.androidutils) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/roommembermoderation/api/build.gradle.kts b/features/roommembermoderation/api/build.gradle.kts index e168d2c8bf..437f10f2d8 100644 --- a/features/roommembermoderation/api/build.gradle.kts +++ b/features/roommembermoderation/api/build.gradle.kts @@ -16,7 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.matrix.api) } diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index 54d87ef22e..284517ce4a 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -33,9 +33,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.oauth.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) api(libs.statemachine) diff --git a/features/securityandprivacy/api/build.gradle.kts b/features/securityandprivacy/api/build.gradle.kts index 4260bea661..ec29da2f8b 100644 --- a/features/securityandprivacy/api/build.gradle.kts +++ b/features/securityandprivacy/api/build.gradle.kts @@ -16,6 +16,5 @@ android { } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/securityandprivacy/impl/build.gradle.kts b/features/securityandprivacy/impl/build.gradle.kts index d42bdef883..0a97231db6 100644 --- a/features/securityandprivacy/impl/build.gradle.kts +++ b/features/securityandprivacy/impl/build.gradle.kts @@ -29,7 +29,6 @@ setupDependencyInjection() dependencies { api(projects.features.securityandprivacy.api) implementation(projects.appconfig) - implementation(projects.appnav) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) @@ -39,12 +38,10 @@ dependencies { implementation(projects.libraries.previewutils) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.services.analytics.api) implementation(projects.libraries.featureflag.api) implementation(projects.features.enterprise.api) testCommonDependencies(libs, true) - testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.testtags) diff --git a/features/securityandprivacy/test/build.gradle.kts b/features/securityandprivacy/test/build.gradle.kts index 903ef6d194..1d6f0ee3a3 100644 --- a/features/securityandprivacy/test/build.gradle.kts +++ b/features/securityandprivacy/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.securityandprivacy.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index c83d4fbdc6..cf6ae2c219 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId interface ShareEntryPoint : FeatureEntryPoint { data class Params(val shareIntentData: ShareIntentData) @@ -25,6 +26,11 @@ interface ShareEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onDone(roomIds: List) + /** + * Called when the share is done, or cancelled. + * @param sessionId the session which has been used to share the data. It can be different from the current session. + * @param roomIds the rooms the data has been shared to, empty if the share has been cancelled. + */ + fun onDone(sessionId: SessionId, roomIds: List) } } diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 255263cf02..f10783d60c 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -27,27 +27,20 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.roomselect.api) - implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.services.appnavstate.api) api(libs.statemachine) api(projects.features.share.api) testCommonDependencies(libs, true) - testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.roomselect.test) testImplementation(projects.services.appnavstate.impl) } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt new file mode 100644 index 0000000000..54085791f6 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlin.coroutines.cancellation.CancellationException + +@ContributesBinding(AppScope::class) +class DefaultShareDataSender( + private val mediaSenderRoomFactory: MediaSenderRoomFactory, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, + private val activeRoomsHolder: ActiveRoomsHolder, + private val onSharedData: OnSharedData, +) : ShareDataSender { + override suspend fun send( + client: MatrixClient, + shareIntentData: ShareIntentData, + roomIds: List, + ): Result = runCatchingExceptions { + val result = when (shareIntentData) { + is ShareIntentData.PlainText -> { + roomIds + .map { roomId -> + client.findJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = shareIntentData.content, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + is ShareIntentData.Uris -> { + val filesToShare = shareIntentData.uris + if (filesToShare.isEmpty()) { + false + } else { + // The media optimization config is the same for all the files and all the rooms + val mediaOptimizationConfig = mediaOptimizationConfigProviderFactory.create(client).get() + roomIds + .map { roomId -> + val room = client.findJoinedRoom(roomId) ?: return@map false + val mediaSender = mediaSenderRoomFactory.create(client = client, room = room) + filesToShare + .map { fileToShare -> + val result = mediaSender.sendMedia( + caption = shareIntentData.text, + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, + ) + // If the coroutine was cancelled, destroy the room and rethrow the exception + val cancellationException = result.exceptionOrNull() as? CancellationException + if (cancellationException != null) { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + throw cancellationException + } + result.isSuccess + } + .all { isSuccess -> isSuccess } + .also { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + } + } + .all { it } + } + } + } + + // Handle post-processing of shared data + onSharedData(shareIntentData) + + if (!result) { + error("Failed to handle incoming share intent") + } + } + + private suspend fun MatrixClient.findJoinedRoom(roomId: RoomId): JoinedRoom? { + return activeRoomsHolder.getActiveRoom(sessionId) + ?.takeIf { it.roomId == roomId } + ?: getJoinedRoom(roomId) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt new file mode 100644 index 0000000000..a4f4c34970 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Sends the shared data to some rooms of a session. + */ +interface ShareDataSender { + /** + * Send [shareIntentData] to the rooms with [roomIds], using the session of [client]. + */ + suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index 1abac1afa6..8b44f2bd0f 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.parcelize.Parcelize @@ -39,6 +40,7 @@ class ShareNode( @Assisted plugins: List, presenterFactory: SharePresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -53,17 +55,17 @@ class ShareNode( data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.shareIntentData) + private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId) private val callback: ShareEntryPoint.Callback = callback() override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { - presenter.onRoomSelected(roomIds) + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + presenter.onRoomSelected(sessionId, roomIds) } override fun onCancel() { - callback.onDone(emptyList()) + callback.onDone(sessionId, emptyList()) } } @@ -71,6 +73,7 @@ class ShareNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Share, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), @@ -89,7 +92,7 @@ class ShareNode( val state = presenter.present() ShareView( state = state, - onShareSuccess = callback::onDone, + onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) }, ) } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 3bd9b98227..905840d1df 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -10,47 +10,54 @@ package io.element.android.features.share.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException @AssistedInject class SharePresenter( @Assisted private val shareIntentData: ShareIntentData, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val matrixClient: MatrixClient, - private val mediaSenderRoomFactory: MediaSenderRoomFactory, - private val activeRoomsHolder: ActiveRoomsHolder, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, - private val onSharedData: OnSharedData, + @Assisted initialSessionId: SessionId, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val shareDataSender: ShareDataSender, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(shareIntentData: ShareIntentData): SharePresenter + fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter } private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) - fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(shareIntentData, roomIds) + /** + * The session the data is shared with, it can be updated if the user selects rooms from another session. + */ + private var targetSessionId by mutableStateOf(initialSessionId) + + fun onRoomSelected(sessionId: SessionId, roomIds: List) { + if (shareActionState.value.isLoading()) return + targetSessionId = sessionId + appCoroutineScope.launch { + suspend { + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() + shareDataSender.send(client, shareIntentData, roomIds).getOrThrow() + roomIds + }.runCatchingUpdatingState(shareActionState) + } } @Composable @@ -62,80 +69,9 @@ class SharePresenter( } return ShareState( + sessionId = targetSessionId, shareAction = shareActionState.value, eventSink = ::handleEvent, ) } - - private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { - return activeRoomsHolder.getActiveRoom(matrixClient.sessionId) - ?.takeIf { it.roomId == roomId } - ?: matrixClient.getJoinedRoom(roomId) - } - - private fun CoroutineScope.share( - shareIntentData: ShareIntentData, - roomIds: List, - ) = launch { - suspend { - val result = when (shareIntentData) { - is ShareIntentData.PlainText -> { - roomIds - .map { roomId -> - getJoinedRoom(roomId)?.liveTimeline?.sendMessage( - body = shareIntentData.content, - htmlBody = null, - intentionalMentions = emptyList(), - )?.isSuccess.orFalse() - } - .all { it } - } - is ShareIntentData.Uris -> { - val filesToShare = shareIntentData.uris - if (filesToShare.isEmpty()) { - false - } else { - roomIds - .map { roomId -> - val room = getJoinedRoom(roomId) ?: return@map false - val mediaSender = mediaSenderRoomFactory.create(room = room) - filesToShare - .map { fileToShare -> - val result = mediaSender.sendMedia( - caption = shareIntentData.text, - uri = fileToShare.uri, - mimeType = fileToShare.mimeType, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), - ) - // If the coroutine was cancelled, destroy the room and rethrow the exception - val cancellationException = result.exceptionOrNull() as? CancellationException - if (cancellationException != null) { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - throw cancellationException - } - result.isSuccess - } - .all { isSuccess -> isSuccess } - .also { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - } - } - .all { it } - } - } - } - - // Handle post-processing of shared data - onSharedData(shareIntentData) - - if (!result) { - error("Failed to handle incoming share intent") - } - roomIds - }.runCatchingUpdatingState(shareActionState) - } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt index 7538309941..10046cf3b2 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -10,8 +10,10 @@ package io.element.android.features.share.impl import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId data class ShareState( + val sessionId: SessionId, val shareAction: AsyncAction>, val eventSink: (ShareEvent) -> Unit ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt index 18258f1b95..c765db9195 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt @@ -11,6 +11,7 @@ package io.element.android.features.share.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId open class ShareStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -31,9 +32,11 @@ open class ShareStatePreviewParam : PreviewParameterProvider { } fun aShareState( + sessionId: SessionId = SessionId("@alice:server.org"), shareAction: AsyncAction> = AsyncAction.Uninitialized, eventSink: (ShareEvent) -> Unit = {} ) = ShareState( + sessionId = sessionId, shareAction = shareAction, eventSink = eventSink ) diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt new file mode 100644 index 0000000000..bdd99f6a4d --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.libraries.core.mimetype.MimeTypes +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaSender +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultShareDataSenderTest : RobolectricTest() { + @Test + fun `send text ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(matrixClient, ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isSuccess).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send text, room not found`() = runTest { + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(FakeMatrixClient(), ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isFailure).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send media ok`() = runTest { + val sendMediaResult = lambdaRecorder> { Result.success(Unit) } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val mediaSender = FakeMediaSender( + sendMediaResult = sendMediaResult, + ) + val sender = createDefaultShareDataSender( + mediaSenderRoomFactory = { _, _ -> mediaSender }, + ) + val result = sender.send( + client = matrixClient, + shareIntentData = ShareIntentData.Uris( + text = A_MESSAGE, + listOf( + UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, + ) + ) + ), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isSuccess).isTrue() + sendMediaResult.assertions().isCalledOnce() + } + + @Test + fun `send media with no uris is a failure`() = runTest { + val sender = createDefaultShareDataSender() + val result = sender.send( + client = FakeMatrixClient(), + shareIntentData = ShareIntentData.Uris(text = A_MESSAGE, emptyList()), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isFailure).isTrue() + } +} + +private fun createDefaultShareDataSender( + mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { _, _ -> FakeMediaSender() }, + mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory = MediaOptimizationConfigProviderFactory { + FakeMediaOptimizationConfigProvider() + }, + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + onSharedData: OnSharedData = OnSharedData {}, +) = DefaultShareDataSender( + mediaSenderRoomFactory = mediaSenderRoomFactory, + mediaOptimizationConfigProviderFactory = mediaOptimizationConfigProviderFactory, + activeRoomsHolder = activeRoomsHolder, + onSharedData = onSharedData, +) diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 459fa423ec..85068f5402 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -15,7 +15,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -37,12 +39,13 @@ class DefaultShareEntryPointTest { ShareNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { createSharePresenter() }, + presenterFactory = { _, _ -> createSharePresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, ) } val callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) = lambdaError() + override fun onDone(sessionId: SessionId, roomIds: List) = lambdaError() } val params = ShareEntryPoint.Params( shareIntentData = ShareIntentData.PlainText(A_MESSAGE), diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt new file mode 100644 index 0000000000..370ed8b31c --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeShareDataSender( + private val sendResult: (MatrixClient, ShareIntentData, List) -> Result = { _, _, _ -> lambdaError() }, +) : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + return sendResult(client, shareIntentData, roomIds) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 44ccea92d6..8fc5109636 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -8,37 +8,32 @@ package io.element.android.features.share.impl -import android.net.Uri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData -import io.element.android.features.share.api.UriToShare import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.core.mimetype.MimeTypes 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.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeJoinedRoom -import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.test.FakeMediaSender -import io.element.android.services.appnavstate.api.ActiveRoomsHolder -import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class SharePresenterTest : RobolectricTest() { +class SharePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -49,22 +44,25 @@ class SharePresenterTest : RobolectricTest() { presenter.present() }.test { val initialState = awaitItem() + assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID) assertThat(initialState.shareAction.isUninitialized()).isTrue() } } @Test fun `present - on room selected error then clear error`() = runTest { - val presenter = createSharePresenter() + val presenter = createSharePresenter( + shareDataSender = FakeShareDataSender { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val failure = awaitItem() - assertThat(failure.shareAction.isFailure()).isTrue() + assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) failure.eventSink.invoke(ShareEvent.ClearError) assertThat(awaitItem().shareAction.isUninitialized()).isTrue() } @@ -72,113 +70,115 @@ class SharePresenterTest : RobolectricTest() { @Test fun `present - on room selected ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } + val client = FakeMatrixClient(sessionId = A_SESSION_ID) + val shareIntentData = ShareIntentData.PlainText(A_MESSAGE) + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + shareIntentData = shareIntentData, + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID))) } } @Test - fun `present - send text ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) + fun `present - on room selected from another session ok`() = runTest { + val getClient = lambdaRecorder> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) } + val sendResult = lambdaRecorder, Result> { client, _, _ -> + assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2) + Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + matrixClientProvider = FakeMatrixClientProvider { getClient(it) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID) + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + val sessionChanged = awaitItem() + assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2) + assertThat(sessionChanged.shareAction.isUninitialized()).isTrue() assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + sendResult.assertions().isCalledOnce() } } @Test - fun `present - send media ok`() = runTest { - val sendMediaResult = lambdaRecorder> { Result.success(Unit) } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline(), - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } - val mediaSender = FakeMediaSender( - sendMediaResult = sendMediaResult, - ) + fun `present - on room selected, failure to get the client`() = runTest { + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.Uris( - text = A_MESSAGE, - listOf( - UriToShare( - uri = Uri.parse("content://image.jpg"), - mimeType = MimeTypes.Jpeg, - ) - ) - ), - mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2) assertThat(awaitItem().shareAction.isLoading()).isTrue() + assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + sendResult.assertions().isNeverCalled() + } + } + + @Test + fun `present - on room selected while sharing is ignored`() = runTest { + val completable = CompletableDeferred>() + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val presenter = createSharePresenter( + shareDataSender = object : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + sendResult(client, shareIntentData, roomIds) + return completable.await() + } + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + completable.complete(Result.success(Unit)) val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) - sendMediaResult.assertions().isCalledOnce() + sendResult.assertions().isCalledOnce() } } } internal fun TestScope.createSharePresenter( shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), - matrixClient: MatrixClient = FakeMatrixClient(), - activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), - mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, - mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - onSharedData: OnSharedData = OnSharedData {}, + initialSessionId: SessionId = A_SESSION_ID, + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + shareDataSender: ShareDataSender = FakeShareDataSender(), ): SharePresenter { return SharePresenter( shareIntentData = shareIntentData, - sessionCoroutineScope = this, - matrixClient = matrixClient, - activeRoomsHolder = activeRoomsHolder, - mediaSenderRoomFactory = mediaSenderRoomFactory, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, - onSharedData = onSharedData, + initialSessionId = initialSessionId, + appCoroutineScope = this, + matrixClientProvider = matrixClientProvider, + shareDataSender = shareDataSender, ) } diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts index e4a987c113..6b8f8f7572 100644 --- a/features/share/test/build.gradle.kts +++ b/features/share/test/build.gradle.kts @@ -15,7 +15,5 @@ android { dependencies { implementation(projects.features.share.api) - implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt index 1f35da352a..3f9a3b3e4c 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -58,11 +58,4 @@ class SignedOutPresenter( eventSink = ::handleEvent, ) } - - private fun SessionData.toMatrixUser() = MatrixUser( - userId = sessionId, - displayName = userDisplayName, - avatarUrl = userAvatarUrl, - avatarThumbnail = userAvatarData, - ) } diff --git a/features/signedout/impl/src/main/res/values-pl/translations.xml b/features/signedout/impl/src/main/res/values-pl/translations.xml index 9b83e7d692..7da7ecc2f0 100644 --- a/features/signedout/impl/src/main/res/values-pl/translations.xml +++ b/features/signedout/impl/src/main/res/values-pl/translations.xml @@ -1,8 +1,9 @@ - "Zmieniono hasło w innej sesji" - "Sesja została usunięta z innej sesji" - "Administrator serwera unieważnił Twój dostęp" + "Usunięto urządzenie ze swojego konta" + "Hasło zostało zmienione na innym urządzeniu" + "Sesja serwera wygasła lub administrator zablokował Twój dostęp" "Mogłeś zostać wylogowany z powodów wymienionych poniżej. Zaloguj się ponownie, aby dalej korzystać z %s." - "Zostałeś wylogowany" + "Urządzenie zostało usunięte przez jedną z poniższych przyczyn. Zaloguj się ponownie, aby powiązać to urządzenie ze swoim kontem." + "Urządzenie usunięte" diff --git a/features/signedout/impl/src/main/res/values-uk/translations.xml b/features/signedout/impl/src/main/res/values-uk/translations.xml index ebf364518b..8360646986 100644 --- a/features/signedout/impl/src/main/res/values-uk/translations.xml +++ b/features/signedout/impl/src/main/res/values-uk/translations.xml @@ -1,8 +1,9 @@ - "Ви змінили пароль під час іншого сеансу" - "Ви видалили сеанс з іншого сеансу" - "Адміністратор вашого сервера визнав ваш доступ недійсним" + "Ви вилучили цей пристрій зі свого облікового запису" + "Ви змінили пароль на іншому пристрої" + "Термін дії сеансу на вашому сервері закінчився або адміністратор заблокував ваш доступ" "Можливо, ви вийшли з системи з однієї з причин, наведених нижче. Будь ласка, увійдіть знову, щоб продовжити використання %s." - "Ви вийшли з системи" + "Цей пристрій було видалено з однієї з наведених нижче причин. Будь ласка, увійдіть знову, щоб підключити цей пристрій до вашого облікового запису." + "Пристрій вилучено" diff --git a/features/signedout/impl/src/main/res/values-zh/translations.xml b/features/signedout/impl/src/main/res/values-zh/translations.xml index 6768608294..6c9b5a4bbe 100644 --- a/features/signedout/impl/src/main/res/values-zh/translations.xml +++ b/features/signedout/impl/src/main/res/values-zh/translations.xml @@ -1,8 +1,9 @@ - "你在另一个会话中更改了密码" - "你已从其它会话中删除此会话" - "服务器管理员已禁止你的访问" + "你已为账户移除此设备" + "你已在另一设备上更改密码" + "你的服务器会话已过期,或管理员已阻止你的访问。" "你可能因以下原因而被注销。请重新登录以继续使用 %s。" - "你已注销" + "此设备因以下原因之一已被移除。请重新登录以连接设备到你的账户。" + "设备已移除" diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index deec862fa3..57bb0f7122 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -34,10 +34,8 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.invite.api) implementation(projects.libraries.previewutils) @@ -49,7 +47,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.rolesandpermissions.test) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index c0ec58d267..41fef18a3f 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -12,8 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.FakeSpaceFlowGraph import io.element.android.libraries.matrix.api.core.RoomId diff --git a/features/startchat/impl/build.gradle.kts b/features/startchat/impl/build.gradle.kts index fe7bfa9ae2..1b04c175e1 100644 --- a/features/startchat/impl/build.gradle.kts +++ b/features/startchat/impl/build.gradle.kts @@ -35,13 +35,9 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.deeplink.api) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.usersearch.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.enterprise.api) api(projects.features.startchat.api) @@ -49,12 +45,8 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.startchat.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt index 9cb8d48778..4f19f0e573 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt @@ -22,7 +22,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.createroom.api.CreateRoomEntryPoint -import io.element.android.features.startchat.DefaultStartChatNavigator import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode import io.element.android.features.startchat.impl.root.StartChatNode diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt similarity index 97% rename from features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt rename to features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt index 125dc2c2b3..2bd2ac2f1b 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.startchat +package io.element.android.features.startchat.impl import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt index c81cb1af66..acfe51ff92 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt @@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.plugins import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt index 533f680674..be8b96c21a 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt @@ -19,7 +19,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.MatrixClient diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt index 60ef9ee00f..69d82ad37c 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt @@ -21,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt index b00a595747..c5a8f992f1 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt @@ -12,7 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt index ba1b6a4e69..4b7b289490 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt @@ -8,7 +8,6 @@ package io.element.android.features.startchat.impl -import io.element.android.features.startchat.StartChatNavigator import io.element.android.libraries.matrix.api.core.RoomIdOrAlias class FakeStartChatNavigator( diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt index 393f51dd2d..924ca691b7 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt @@ -9,8 +9,8 @@ package io.element.android.features.startchat.impl.joinbyaddress import com.google.common.truth.Truth.assertThat -import io.element.android.features.startchat.StartChatNavigator import io.element.android.features.startchat.impl.FakeStartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt index 39d6f34b21..c656e83858 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -10,12 +10,12 @@ package io.element.android.features.startchat.impl.root import androidx.compose.runtime.MutableState import com.google.common.truth.Truth.assertThat -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction import io.element.android.features.startchat.impl.userlist.FakeUserListPresenter import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.startchat.impl.userlist.UserListDataStore +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId diff --git a/features/startchat/test/build.gradle.kts b/features/startchat/test/build.gradle.kts index d6d1471832..ed47f27fc8 100644 --- a/features/startchat/test/build.gradle.kts +++ b/features/startchat/test/build.gradle.kts @@ -11,13 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.invitepeople.test" + namespace = "io.element.android.features.startchat.test" } dependencies { - implementation(libs.coroutines.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrix.test) implementation(projects.libraries.architecture) implementation(projects.tests.testutils) api(projects.features.startchat.api) diff --git a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt similarity index 95% rename from features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt rename to features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt index c4969df4d9..70cbe1883c 100644 --- a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt +++ b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.invitepeople.test +package io.element.android.features.startchat.test import androidx.compose.runtime.MutableState import io.element.android.features.startchat.api.StartDMAction diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 3e68fb2b9c..b04ca61a23 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -31,10 +31,8 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.verifysession.api) @@ -47,7 +45,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.features.startchat.test) diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 57a7ae273f..b163b04ce3 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -15,9 +15,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.features.userprofile.api.UserProfileEvent import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState diff --git a/features/userprofile/shared/build.gradle.kts b/features/userprofile/shared/build.gradle.kts index 7f4d96c883..1fdce484dd 100644 --- a/features/userprofile/shared/build.gradle.kts +++ b/features/userprofile/shared/build.gradle.kts @@ -30,16 +30,10 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) api(projects.features.userprofile.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.startchat.api) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index fc51d87acd..24ba7ccb3e 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -25,7 +25,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) @@ -33,17 +32,13 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - implementation(projects.features.logout.api) api(libs.statemachine) api(projects.features.verifysession.api) testCommonDependencies(libs, true) - testImplementation(projects.features.logout.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 00daf9b5b8..98a1791774 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState @@ -101,16 +102,7 @@ class IncomingVerificationPresenter( } val currentUser by remember { sessionStore.sessionsFlow().mapNotNull { sessions -> - sessions.find { it.userId == sessionId.value }?.let { sessionData -> - MatrixUser( - userId = sessionId, - displayName = sessionData.userDisplayName, - avatarUrl = sessionData.userAvatarUrl, - // Locally stored avatar, used as a fallback when the avatar cannot be loaded, - // for instance for a session which is not the current one - avatarThumbnail = sessionData.userAvatarData, - ) - } + sessions.find { it.userId == sessionId.value }?.toMatrixUser() } }.collectAsState(MatrixUser(sessionId)) val step by remember { diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml index 71cea2c403..bc326cbc00 100644 --- a/features/verifysession/impl/src/main/res/values-pl/translations.xml +++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml @@ -44,6 +44,7 @@ "Otwórz aplikację na drugim zweryfikowanym urządzeniu" "Dla dodatkowej ochrony, zweryfikuj tego użytkownika, porównując zestaw emoji na obu urządzeniach. Zrób to, korzystając z zaufanej metody komunikacji." "Zweryfikować tego użytkownika?" + "Użytkownik proszący o weryfikację:" "Dla dodatkowej ochrony, inny użytkownik chce zweryfikować Twoją tożsamość. Pojawi się unikalny zestaw emoji do porównania." "Powinno wyskoczyć okno na drugim urządzeniu. Rozpocznij tam weryfikację." "Rozpocznij weryfikację na drugim urządzeniu" diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml index 803e57fa39..620229e2f6 100644 --- a/features/verifysession/impl/src/main/res/values-uk/translations.xml +++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml @@ -44,6 +44,7 @@ "Відкрийте застосунок на іншому звіреному пристрої" "Для додаткової безпеки звірте цього користувача, порівнявши набір емоджі на ваших пристроях. Зробіть це, використовуючи надійний спосіб спілкування." "Звірити цього користувача?" + "Користувач, який запитує звірку:" "Для додаткової безпеки інший користувач хоче звірити вашу цифрову ідентичність. Вам буде показано набір емодзі для порівняння." "На іншому пристрої має з’явитися спливне вікно. Розпочніть звіряння звідти." "Розпочати звіряння на іншому пристрої" diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml index 4b32d53ea6..3ae122cbb7 100644 --- a/features/verifysession/impl/src/main/res/values-zh/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml @@ -44,6 +44,7 @@ "在另一台验证的设备上打开应用" "为提高安全性,请通过比较设备上的一组 Emoji 以验证此用户。通过使用安全的方式比如面对面来实施此步骤。" "验证此用户?" + "用户正在请求验证:" "为提高安全性,另一用户想要验证你的数字身份。你将看到一组 Emoij 供你比较。" "你应该会在另一台设备上看到一个弹出窗口。现在从该处开始验证。" "在另一台设备上开始验证" diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt index 6dd621ac6a..9e5b158b5d 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileContentReader +package io.element.android.features.viewfolder.impl.file class FakeFileContentReader : FileContentReader { private var result: Result> = Result.success(emptyList()) diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt index 3649d30759..87af5947bb 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileSave +package io.element.android.features.viewfolder.impl.file class FakeFileSave : FileSave { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt index 7348e4bed0..3b3a4a7245 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileShare +package io.element.android.features.viewfolder.impl.file class FakeFileShare : FileShare { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt similarity index 93% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt index 7e47d0064d..e61dfebad2 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt @@ -6,18 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file +package io.element.android.features.viewfolder.impl.file import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.viewfolder.impl.file.ColorationMode -import io.element.android.features.viewfolder.impl.file.FileContentReader -import io.element.android.features.viewfolder.impl.file.FileSave -import io.element.android.features.viewfolder.impl.file.FileShare -import io.element.android.features.viewfolder.impl.file.ViewFileEvent -import io.element.android.features.viewfolder.impl.file.ViewFilePresenter import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.toast.FakeToastHelper diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt index 7761bfb976..fd28fc8040 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder -import io.element.android.features.viewfolder.impl.folder.FolderExplorer import io.element.android.features.viewfolder.impl.model.Item class FakeFolderExplorer : FolderExplorer { diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt similarity index 94% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt index 31fd91eabe..1e8f90976f 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt @@ -6,14 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.viewfolder.impl.folder.FolderExplorer -import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter import io.element.android.features.viewfolder.impl.model.Item import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80fcece2eb..84028f6ce4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,6 @@ firebaseAppDistribution = "5.3.0" # AndroidX core = "1.18.0" datastore = "1.2.1" -constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" @@ -45,7 +44,7 @@ appyx = "1.7.1" sqldelight = "2.4.0" wysiwyg = "2.42.1" telephoto = "0.19.0" -maplibre-compose = "0.17.0" +maplibre-compose = "0.18.0" # Dependency analysis dependencyAnalysis = "3.19.2" @@ -97,7 +96,6 @@ androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.11.0" androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2" -androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } @@ -122,7 +120,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.17.0" +androidx_webkit = "androidx.webkit:webkit:1.17.1" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha29' } @@ -171,7 +169,7 @@ test_mockk = "io.mockk:mockk:1.14.11" test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" -test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.23" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.24" test_robolectric = "org.robolectric:robolectric:4.17" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3" @@ -188,7 +186,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # it as a plain `require`: without this, a library built against a newer SDK would silently drag this app # onto it. With it, this pin always wins, and a library that needs something newer fails at configuration # time naming both versions instead of at runtime with NoSuchMethodError. -matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.09.28" } } +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.05-nightly" } } rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others @@ -223,7 +221,7 @@ maplibre_compose_location_runtime_gms = { module = "org.maplibre.compose:locatio opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.1.1" google_zxing = "com.google.zxing:core:3.5.4" -google_guava = "com.google.guava:guava:33.7.1-android" +google_guava = "com.google.guava:guava:33.7.2-android" color_picker = "io.mhssn:colorpicker:1.0.0" @@ -231,7 +229,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" posthog = "com.posthog:posthog-android:3.60.7" sentry = "io.sentry:sentry-android:8.57.0" # main branch can be tested replacing the version with main-SNAPSHOT -matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.3" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 5097068a8d..9fa7f28828 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1eca32e7b4..a78c88e539 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index 2497299f5b..e28b13bc44 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -20,8 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index e1d6b2800e..8ed9a18b85 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter -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.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,14 +29,7 @@ class AccountSelectPresenter( val accounts by produceState>(persistentListOf()) { // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. value = sessionStore.getAllSessions() - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) - } + .map { it.toMatrixUser() } .toImmutableList() } diff --git a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt similarity index 94% rename from libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt rename to libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt index ecb263dbb3..a75a71fc57 100644 --- a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt +++ b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediaplayer.test +package io.element.android.libraries.audio.test import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester diff --git a/libraries/cachestore/impl/build.gradle.kts b/libraries/cachestore/impl/build.gradle.kts index f0c7ba237c..f357cf8446 100644 --- a/libraries/cachestore/impl/build.gradle.kts +++ b/libraries/cachestore/impl/build.gradle.kts @@ -19,8 +19,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.cachestore.api) implementation(libs.sqldelight.driver.android) diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt similarity index 93% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt index 36d7e05532..d3581f9686 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt @@ -5,13 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import com.google.common.truth.Truth.assertThat import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.impl.CacheDatabase -import io.element.android.libraries.cachestore.impl.DatabaseCacheStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt similarity index 89% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt index 3dca9efaf3..149f21ecfb 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import io.element.android.libraries.cachestore.CacheData import java.util.Date diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt similarity index 87% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt index 30633e8ff9..b7800bdf86 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt similarity index 93% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt index f029c8bcde..132063541e 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData import io.element.android.libraries.cachestore.api.CacheStore diff --git a/libraries/cryptography/impl/build.gradle.kts b/libraries/cryptography/impl/build.gradle.kts index 3a1f55126e..b347ce1cbc 100644 --- a/libraries/cryptography/impl/build.gradle.kts +++ b/libraries/cryptography/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.di) implementation(libs.coroutines.core) api(projects.libraries.cryptography.api) diff --git a/libraries/dateformatter/impl/build.gradle.kts b/libraries/dateformatter/impl/build.gradle.kts index c9ddf459d6..84f8a0aea6 100644 --- a/libraries/dateformatter/impl/build.gradle.kts +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -37,13 +37,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.di) - implementation(projects.libraries.uiStrings) implementation(projects.services.toolbox.api) api(projects.libraries.dateformatter.api) api(libs.datetime) testCommonDependencies(libs, true) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.services.toolbox.test) } diff --git a/libraries/dateformatter/test/build.gradle.kts b/libraries/dateformatter/test/build.gradle.kts index 0367030ae1..fa0861c1a2 100644 --- a/libraries/dateformatter/test/build.gradle.kts +++ b/libraries/dateformatter/test/build.gradle.kts @@ -16,5 +16,4 @@ android { dependencies { api(projects.libraries.dateformatter.api) - api(libs.datetime) } diff --git a/libraries/emoji/api/build.gradle.kts b/libraries/emoji/api/build.gradle.kts index 48143d9512..d9e56fb68f 100644 --- a/libraries/emoji/api/build.gradle.kts +++ b/libraries/emoji/api/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.kotlinx.collections.immutable) api(libs.matrix.emojibase.bindings) diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 59a1ad6575..844f8ce403 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiStrings) implementation(libs.kotlinx.collections.immutable) implementation(libs.matrix.emojibase.bindings) diff --git a/libraries/featureflag/api/build.gradle.kts b/libraries/featureflag/api/build.gradle.kts index b34e082cd3..f9177a23bc 100644 --- a/libraries/featureflag/api/build.gradle.kts +++ b/libraries/featureflag/api/build.gradle.kts @@ -15,7 +15,6 @@ android { } dependencies { - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(libs.coroutines.core) } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 0024ce1f9a..406bf7ddb3 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -177,13 +177,19 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), - ShowAllActivityInRoomList( - key = "feature.show_all_activity_in_room_list", - title = "Show all activity in room list", - description = "Show all activity indicators in the room list, using unread messages instead of notifications.", - defaultValue = { true }, - // To hide it from the developer options, since it's displayed in the notification settings instead - isFinished = true, - isInLabs = false, - ) +} + +/** + * Feature flag to show all activity indicators in the room list, using unread messages instead of notifications. + * + * This is separate from the [FeatureFlags] enum because it is not a feature flag that can be toggled in developer options, + * but rather a setting that can be toggled in the app settings. + */ +object ShowAllActivityInRoomListFeature : Feature { + override val key: String = "feature.show_all_activity_in_room_list" + override val title: String = "Show all activity in room list" + override val description: String = "Show all activity indicators in the room list, using unread messages instead of notifications." + override val defaultValue: (BuildMeta) -> Boolean = { true } + override val isFinished: Boolean = false + override val isInLabs: Boolean = false } diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts index 63d968467a..6d2db6f365 100644 --- a/libraries/featureflag/impl/build.gradle.kts +++ b/libraries/featureflag/impl/build.gradle.kts @@ -23,9 +23,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.featureflag.api) implementation(libs.androidx.datastore.preferences) - implementation(projects.appconfig) - implementation(projects.libraries.di) - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.preferences.api) implementation(libs.coroutines.core) diff --git a/libraries/fullscreenintent/api/build.gradle.kts b/libraries/fullscreenintent/api/build.gradle.kts index 8d6be454ea..e8ad77c3a9 100644 --- a/libraries/fullscreenintent/api/build.gradle.kts +++ b/libraries/fullscreenintent/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) } diff --git a/libraries/fullscreenintent/impl/build.gradle.kts b/libraries/fullscreenintent/impl/build.gradle.kts index 57c4f5b836..326411a635 100644 --- a/libraries/fullscreenintent/impl/build.gradle.kts +++ b/libraries/fullscreenintent/impl/build.gradle.kts @@ -23,16 +23,12 @@ dependencies { api(projects.libraries.fullscreenintent.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.datastore.preferences) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt index 7d726ccdcc..201bc65e79 100644 --- a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt +++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt @@ -15,11 +15,15 @@ import android.provider.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.lifecycle.compose.LifecycleResumeEffect import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -61,9 +65,15 @@ class FullScreenIntentPermissionsPresenter( @Composable override fun present(): FullScreenIntentPermissionsState { val coroutineScope = rememberCoroutineScope() - val isGranted = notificationManagerCompat.canUseFullScreenIntent() + var isGranted by remember { mutableStateOf(notificationManagerCompat.canUseFullScreenIntent()) } val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true) + // The permission can be granted from the system settings, so check it again when the user comes back to the app. + LifecycleResumeEffect(Unit) { + isGranted = notificationManagerCompat.canUseFullScreenIntent() + onPauseOrDispose {} + } + fun handleEvent(event: FullScreenIntentPermissionsEvent) { when (event) { FullScreenIntentPermissionsEvent.Dismiss -> coroutineScope.launch { diff --git a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt similarity index 76% rename from libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt rename to libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt index 08bc2ed885..593fd71492 100644 --- a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt +++ b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt @@ -6,25 +6,24 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.fullscreenintent.test +package io.element.android.libraries.fullscreenintent.impl import android.content.Intent import android.os.Build import androidx.core.app.NotificationManagerCompat -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import androidx.lifecycle.Lifecycle import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.FakeLifecycleOwner import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testWithLifecycleOwner import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,9 +44,7 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns false } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isTrue() @@ -61,21 +58,39 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns true } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isFalse() } } + @Test + fun `shouldDisplay - becomes false when permission is granted while the app is in background`() = runTest { + var canUseFullScreenIntent = false + val presenter = createPresenter( + notificationManagerCompat = mockk { + every { canUseFullScreenIntent() } answers { canUseFullScreenIntent } + } + ) + val lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) + presenter.testWithLifecycleOwner(lifecycleOwner) { + skipItems(1) + assertThat(awaitItem().shouldDisplayBanner).isTrue() + // User opens the system settings and grants the permission + lifecycleOwner.givenState(Lifecycle.State.STARTED) + canUseFullScreenIntent = true + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + val finalItem = awaitItem() + assertThat(finalItem.permissionGranted).isTrue() + assertThat(finalItem.shouldDisplayBanner).isFalse() + } + } + @Test fun `dismissFullScreenIntentBanner - makes shouldDisplay false`() = runTest { val presenter = createPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.Dismiss) @@ -89,9 +104,7 @@ class FullScreenIntentPermissionsPresenterTest { val launchLambda = lambdaRecorder { _ -> } val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) val presenter = createPresenter(externalIntentLauncher = externalIntentLauncher) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) @@ -108,9 +121,7 @@ class FullScreenIntentPermissionsPresenterTest { buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.Q), externalIntentLauncher = externalIntentLauncher, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index 3c464a2ea8..e779b5086f 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.ResolvedTextDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -513,7 +514,7 @@ private fun Modifier.drawInlineCodeBackgrounds( val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true)) if (lineEnd <= lineStart) continue val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) - val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) + val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true).takeUnless { it == 0f } ?: layout.getLineEndPosition(line) val left = minOf(startX, endX) - horizontalPadding val right = maxOf(startX, endX) + horizontalPadding val topLeft = Offset(left, layout.getLineTop(line)) @@ -540,6 +541,16 @@ private fun Modifier.drawInlineCodeBackgrounds( } } +private fun TextLayoutResult.getLineEndPosition(line: Int): Float { + val lineEnd = getLineEnd(line, visibleEnd = true) + val direction = getParagraphDirection(lineEnd) + return if (direction == ResolvedTextDirection.Ltr) { + getLineRight(line) + } else { + getLineLeft(line) + } +} + /** * Detects taps and long presses on link-annotated ranges ([LINK_ANNOTATION_TAG]) without consuming the touch events if none are detected, and invokes the * appropriate callbacks in [context]. diff --git a/libraries/htmlrenderer/impl/build.gradle.kts b/libraries/htmlrenderer/impl/build.gradle.kts index ba17d855f1..3aee77ded6 100644 --- a/libraries/htmlrenderer/impl/build.gradle.kts +++ b/libraries/htmlrenderer/impl/build.gradle.kts @@ -27,7 +27,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.htmlrenderer.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiCommon) implementation(libs.jsoup) testCommonDependencies(libs) diff --git a/libraries/indicator/api/build.gradle.kts b/libraries/indicator/api/build.gradle.kts index c6f33ec83f..a53cfb7707 100644 --- a/libraries/indicator/api/build.gradle.kts +++ b/libraries/indicator/api/build.gradle.kts @@ -13,7 +13,3 @@ plugins { android { namespace = "io.element.android.libraries.indicator.api" } - -dependencies { - implementation(libs.coroutines.core) -} diff --git a/libraries/indicator/impl/build.gradle.kts b/libraries/indicator/impl/build.gradle.kts index 8f1e9ea56d..4b723b6e75 100644 --- a/libraries/indicator/impl/build.gradle.kts +++ b/libraries/indicator/impl/build.gradle.kts @@ -21,7 +21,6 @@ android { dependencies { implementation(projects.libraries.di) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(libs.coroutines.core) @@ -29,6 +28,5 @@ dependencies { api(projects.libraries.indicator.api) testCommonDependencies(libs) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/indicator/test/build.gradle.kts b/libraries/indicator/test/build.gradle.kts index a5ce0c57c2..e97c6f5513 100644 --- a/libraries/indicator/test/build.gradle.kts +++ b/libraries/indicator/test/build.gradle.kts @@ -15,6 +15,5 @@ android { } dependencies { - implementation(projects.libraries.matrix.api) api(projects.libraries.indicator.api) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index d0790a6905..8387c1fa26 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -53,8 +53,8 @@ dependencies { implementation(libs.serialization.json) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.sessionStorage.api) implementation(projects.services.analytics.api) + implementation(projects.libraries.sessionStorage.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index e43f362e7c..3b4fd40885 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -133,6 +134,9 @@ interface MatrixClient : ClientUrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Backfills messages from the homeserver for searching for messages in a room. */ + val searchBackfillService: SearchBackfillService + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ val isShuttingDown: Boolean diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt new file mode 100644 index 0000000000..d1c74400c9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * Represents a pending task in the SDK that can be canceled and closed. + */ +interface SdkPendingTask : AutoCloseable { + /** Checks if the task is still running. */ + fun isRunning(): Boolean +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt new file mode 100644 index 0000000000..e02b9d3f0b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.search + +import io.element.android.libraries.matrix.api.SdkPendingTask + +/** + * Service interface for managing search backfill tasks in the Matrix SDK. + * This service allows starting, checking the status of, and canceling search backfill tasks. + */ +interface SearchBackfillService { + /** + * Enqueues a periodic search backfill task based on the provided strategy. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + */ + suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Cancels any currently scheduled periodic search backfill tasks. + */ + fun cancelPeriodicSearchBackfill() + + /** + * Starts a background task to backfill messages for rooms that have been joined but not fully backfilled yet. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + * + * @param strategy the strategy to use for backfilling messages. + * @return a [Result] indicating the success or failure of enqueuing the backfill task. + */ + fun startSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Checks if a search backfill task is currently running. + * + * @return `true` if a search backfill task is running, `false` otherwise. + */ + fun isSearchBackfillRunning(): Boolean + + /** + * Cancels the currently running search backfill task, if any. + * + * @return a [Result] indicating the success or failure of the cancellation operation. + */ + fun cancelSearchBackfill(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt new file mode 100644 index 0000000000..c22b0e12bb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.search + +enum class SearchBackfillStrategy { + /** + * The app is in the foreground: pause between paginations so this doesn't + * compete with interactive traffic. + */ + FOREGROUND, + + /** + * A time-boxed background task (e.g. iOS `BGAppRefreshTask`) where there's + * no interactive traffic to protect. + */ + BACKGROUND +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt new file mode 100644 index 0000000000..136dbf58c3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.user + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.sessionstorage.api.SessionData + +/** + * Create a [MatrixUser] from the profile data stored locally for the session. + * The avatar thumbnail is used as a fallback when the avatar cannot be loaded, + * for instance for a session which is not the current one. + */ +fun SessionData.toMatrixUser() = MatrixUser( + userId = UserId(userId), + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + avatarThumbnail = userAvatarData, +) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 6ef7891e10..9d025fc9a8 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -63,12 +63,12 @@ dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) + implementation(projects.features.networkmonitor.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.network) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) implementation(projects.services.analytics.api) @@ -77,14 +77,13 @@ dependencies { implementation(projects.libraries.core) implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.androidx.datastore.preferences) - implementation(libs.serialization.json) implementation(libs.kotlinx.collections.immutable) testCommonDependencies(libs) testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.kt new file mode 100644 index 0000000000..4a2a7a4079 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import org.matrix.rustcomponents.sdk.ClientSessionDelegate +import org.matrix.rustcomponents.sdk.Session + +/** + * A [ClientSessionDelegate] which does nothing, to be used for the clients which are not associated to a stored session, + * for instance the temporary clients, or the clients used to perform a login. The session is only stored once the login + * is done, with a client created by [RustMatrixClientFactory.create], which uses a [RustClientSessionDelegate]. + */ +internal object NoOpClientSessionDelegate : ClientSessionDelegate { + override fun saveSessionInKeychain(session: Session) = Unit + + override fun retrieveSessionFromKeychain(userId: String): Session { + // This should never be called, as it's only used for multi-process setups + error("retrieveSessionFromKeychain should never be called for Android") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt index a5c69bf831..78fc79f0e7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt @@ -37,6 +37,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate") * It implements both [ClientSessionDelegate] and [ClientDelegate] to react to session data updates and auth errors. * * IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly. + * Since [didReceiveAuthError] does not tell which session is concerned, each client must have its own instance. */ class RustClientSessionDelegate( private val sessionStore: SessionStore, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index d92509b262..70dbff1209 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.impl +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -16,7 +19,9 @@ import io.element.android.libraries.core.data.bytes import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes @@ -47,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState @@ -81,6 +87,8 @@ import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.search.RustMessageSearchService +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.map import io.element.android.libraries.matrix.impl.spaces.RustSpaceService import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.map @@ -110,8 +118,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -149,23 +159,37 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L @Suppress("LargeClass") +@AssistedInject class RustMatrixClient( - override val sessionPaths: SessionPaths, - val innerClient: Client, + @Assisted override val sessionPaths: SessionPaths, + @Assisted val innerClient: Client, + @Assisted private val innerSyncService: ClientSyncService, + @Assisted baseCacheDirectory: File, + @Assisted override val contentScanner: ContentScanner?, + @Assisted override val isMessageSearchAvailable: Boolean, + @Assisted private val sessionDelegate: RustClientSessionDelegate, private val sessionStore: SessionStore, - private val sessionDelegate: RustClientSessionDelegate, - private val innerSyncService: ClientSyncService, - appCoroutineScope: CoroutineScope, + @AppCoroutineScope appCoroutineScope: CoroutineScope, dispatchers: CoroutineDispatchers, - baseCacheDirectory: File, clock: SystemClock, timelineEventFilterFactory: TimelineEventFilterFactory, private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, private val workManagerScheduler: WorkManagerScheduler, - override val contentScanner: ContentScanner?, - override val isMessageSearchAvailable: Boolean, + private val searchBackfillServiceFactory: RustSearchBackfillService.Factory, ) : MatrixClient { + @AssistedFactory + interface Factory { + fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ): RustMatrixClient + } override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) override val server: String? = innerClient.server() @@ -280,6 +304,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + override val searchBackfillService = searchBackfillServiceFactory.create(innerClient) + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -332,6 +358,9 @@ class RustMatrixClient( // Schedule regular database vacuuming to ensure DB performance remains optimal scheduleDatabaseVacuum() + + // Schedule background search backfill if needed + scheduleBackgroundSearchBackfill() } private suspend fun setupUserProfile() { @@ -687,6 +716,7 @@ class RustMatrixClient( sessionCoroutineScope.cancel() clientDelegateTaskHandle?.cancelAndDestroy() ownProfileTaskHandle?.cancelAndDestroy() + searchBackfillService.cancelSearchBackfill() sessionVerificationService.destroy() sessionDelegate.clearCurrentClient() @@ -992,6 +1022,19 @@ class RustMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + + private fun scheduleBackgroundSearchBackfill() { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) + .distinctUntilChanged() + .onEach { isEnabled -> + if (isEnabled) { + searchBackfillService.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + } else { + searchBackfillService.cancelPeriodicSearchBackfill() + } + } + .launchIn(sessionCoroutineScope) + } } private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 9b381460a2..49663bae9e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -23,19 +23,17 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider -import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory import io.element.android.libraries.matrix.impl.scanner.RustContentScanner import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.util.anonymizedTokens import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.ClientSessionDelegate import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session @@ -61,16 +59,18 @@ class RustMatrixClientFactory( private val sessionStore: SessionStore, private val userAgentProvider: UserAgentProvider, private val proxyProvider: ProxyProvider, - private val clock: SystemClock, private val analyticsService: AnalyticsService, private val featureFlagService: FeatureFlagService, - private val timelineEventFilterFactory: TimelineEventFilterFactory, private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, - private val workManagerScheduler: WorkManagerScheduler, private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook, + private val innerMatrixClientFactory: RustMatrixClient.Factory, ) { - private val sessionDelegate = RustClientSessionDelegate( + /** + * Create a new [RustClientSessionDelegate]. Each client must have its own instance, since the delegate + * keeps a reference to the client it handles the auth errors for. + */ + private fun createSessionDelegate() = RustClientSessionDelegate( sessionStore = sessionStore, appCoroutineScope = appCoroutineScope, analyticsService = analyticsService, @@ -91,11 +91,14 @@ class RustMatrixClientFactory( indexDirectory.deleteRecursively() } + // Use a dedicated delegate for this client, so that an auth error only affects this session + val sessionDelegate = createSessionDelegate() val client = getBaseClientBuilder( sessionPaths = sessionPaths, clientSecret = clientSecret, slidingSyncType = ClientBuilderSlidingSync.Restored, isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, ) .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) @@ -131,22 +134,14 @@ class RustMatrixClientFactory( .withOfflineMode() .finish() - RustMatrixClient( + innerMatrixClientFactory.create( sessionPaths = sessionData.getSessionPaths(), innerClient = client, - sessionStore = sessionStore, - appCoroutineScope = appCoroutineScope, - sessionDelegate = sessionDelegate, innerSyncService = syncService, - dispatchers = coroutineDispatchers, baseCacheDirectory = cacheDirectory, - clock = clock, - timelineEventFilterFactory = timelineEventFilterFactory, - featureFlagService = featureFlagService, - analyticsService = analyticsService, - workManagerScheduler = workManagerScheduler, contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) }, isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } @@ -157,6 +152,7 @@ class RustMatrixClientFactory( clientSecret: ClientSecret?, slidingSyncType: ClientBuilderSlidingSync, isMessageSearchAvailable: Boolean, + sessionDelegate: ClientSessionDelegate, ): ClientBuilder { return clientBuilderProvider.provide() .run { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt new file mode 100644 index 0000000000..57f6f525c9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import org.matrix.rustcomponents.sdk.TaskHandle +import java.util.concurrent.atomic.AtomicBoolean + +class RustSdkPendingTask( + private val taskHandle: TaskHandle, +) : SdkPendingTask { + private val closed = AtomicBoolean(false) + + override fun isRunning(): Boolean = !closed.get() && !taskHandle.isFinished() + + override fun close() { + if (!closed.getAndSet(true)) { + taskHandle.cancelAndDestroy() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt index 98de23c7c3..aaafd9abc6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -35,6 +35,7 @@ class RustTemporaryMatrixClientFactory( clientSecret = null, slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, + sessionDelegate = NoOpClientSessionDelegate, ) .serverName(formattedServerName) .build() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 1ae2a16d38..90d640e82e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync +import io.element.android.libraries.matrix.impl.NoOpClientSessionDelegate import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper @@ -407,6 +408,7 @@ class RustMatrixAuthenticationService( clientSecret = pendingKey, slidingSyncType = ClientBuilderSlidingSync.Discovered, isMessageSearchAvailable = isMessageSearchAvailable(), + sessionDelegate = NoOpClientSessionDelegate, ) .config() .build() @@ -440,6 +442,7 @@ class RustMatrixAuthenticationService( clientSecret = pendingKey, slidingSyncType = ClientBuilderSlidingSync.Discovered, isMessageSearchAvailable = isMessageSearchAvailable(), + sessionDelegate = NoOpClientSessionDelegate, ) .serverNameOrHomeserverUrl(baseUrlOrServerName) .build() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt new file mode 100644 index 0000000000..69b3c0fe52 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.RustSdkPendingTask +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import org.matrix.rustcomponents.sdk.Client +import timber.log.Timber +import java.util.concurrent.atomic.AtomicReference + +@AssistedInject +class RustSearchBackfillService( + @Assisted private val innerClient: Client, + private val searchBackfillRequestBuilderFactory: SearchBackfillRequestBuilder.Factory, + private val workManagerScheduler: WorkManagerScheduler, +) : SearchBackfillService { + @AssistedFactory + interface Factory { + fun create(innerClient: Client): RustSearchBackfillService + } + + private val currentSearchBackfillTaskHandle: AtomicReference = AtomicReference(null) + + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + val sessionId = SessionId(innerClient.userId()) + if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.SEARCH_BACKFILL)) { + Timber.d("Background search backfill already scheduled for session $sessionId") + return@runCatchingExceptions + } + Timber.d("Scheduling background search backfill for session $sessionId") + val builder = searchBackfillRequestBuilderFactory.create(sessionId) + workManagerScheduler.submit(builder) + } + + override fun cancelPeriodicSearchBackfill() { + val sessionId = SessionId(innerClient.userId()) + Timber.d("Cancelling background search backfill for session $sessionId") + workManagerScheduler.cancel(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + if (currentSearchBackfillTaskHandle.get()?.isRunning() == true) { + error("Search backfill is already running") + } + RustSdkPendingTask(innerClient.runSearchBackfill(strategy.map())) + .also { currentSearchBackfillTaskHandle.set(it) } + } + + override fun isSearchBackfillRunning(): Boolean { + return currentSearchBackfillTaskHandle.get()?.isRunning() ?: false + } + + override fun cancelSearchBackfill(): Result = runCatchingExceptions { + currentSearchBackfillTaskHandle.getAndSet(null)?.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt new file mode 100644 index 0000000000..2b3a1c866a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +fun RustSearchBackfillStrategy.map(): SearchBackfillStrategy = when (this) { + RustSearchBackfillStrategy.FOREGROUND -> SearchBackfillStrategy.FOREGROUND + RustSearchBackfillStrategy.BACKGROUND -> SearchBackfillStrategy.BACKGROUND +} + +fun SearchBackfillStrategy.map(): RustSearchBackfillStrategy = when (this) { + SearchBackfillStrategy.FOREGROUND -> RustSearchBackfillStrategy.FOREGROUND + SearchBackfillStrategy.BACKGROUND -> RustSearchBackfillStrategy.BACKGROUND +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt new file mode 100644 index 0000000000..bb80672369 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.workDataOf +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillWorker.Companion.SESSION_ID_PARAM +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import kotlinx.coroutines.flow.first +import timber.log.Timber +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +interface SearchBackfillRequestBuilder : WorkManagerRequestBuilder { + fun interface Factory { + fun create(sessionId: SessionId): SearchBackfillRequestBuilder + } +} + +/** + * Builds a work request that runs the search backfill (back paginations) only when there is network connectivity and the device is charging. + */ +@AssistedInject +class DefaultSearchBackfillRequestBuilder( + @Assisted private val sessionId: SessionId, + private val networkMonitor: NetworkMonitor, +) : SearchBackfillRequestBuilder { + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : SearchBackfillRequestBuilder.Factory { + override fun create(sessionId: SessionId): DefaultSearchBackfillRequestBuilder + } + + override suspend fun build(): Result> { + val tag = workManagerTag(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + val type = WorkManagerWorkerType.Default + + val networkRequestBuilder = NetworkRequest.Builder() + // Only allow unmetered networks to avoid using mobile data for backfilling. + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + // Some customers use a VPN to connect to their homeserver, so VPN networks must be allowed. + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + + // In an air-gapped environment, validating internet connectivity would fail and the worker would never run. + if (networkMonitor.isInAirGappedEnvironment.first()) { + Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") + networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val constraints = Constraints.Builder() + .setRequiredNetworkRequest(networkRequestBuilder.build(), NetworkType.UNMETERED) + .setRequiresCharging(true) + .build() + + val request = PeriodicWorkRequestBuilder(1.hours.toJavaDuration()) + // Add a delay before the first execution to avoid running the backfill immediately after the app starts + .setInitialDelay(5.minutes.toJavaDuration()) + .setInputData(workDataOf(SESSION_ID_PARAM to sessionId.value)) + .addTag(tag) + .setConstraints(constraints) + .build() + + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt new file mode 100644 index 0000000000..48d1b31992 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.binding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class SearchBackfillWorker( + @Assisted params: WorkerParameters, + @ApplicationContext context: Context, + private val matrixClientProvider: MatrixClientProvider, +) : CoroutineWorker(context, params) { + companion object { + const val SESSION_ID_PARAM = "session_id" + private val TIMEOUT = 10.minutes + private val POLL_INTERVAL = 5.seconds + } + + override suspend fun doWork(): Result { + Timber.d("Starting search backfill. This will run up to $TIMEOUT") + val sessionId = inputData.getString(SESSION_ID_PARAM)?.let(::SessionId) ?: return Result.failure() + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure() + + if (client.searchBackfillService.isSearchBackfillRunning()) { + Timber.d("Search backfill is already running for session $sessionId, nothing to do") + return Result.success() + } + + Timber.d("Starting search backfill for session $sessionId") + val task = client.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.BACKGROUND).getOrElse { + Timber.e(it, "Failed to start search backfill") + return Result.failure() + } + + return task.use { + try { + withTimeout(TIMEOUT) { + while (task.isRunning()) { + delay(POLL_INTERVAL) + } + } + Timber.d("Search backfill finished") + Result.success() + } catch (_: TimeoutCancellationException) { + Timber.d("Search backfill timed out after $TIMEOUT, cancelling it") + // Run again later to continue backfilling. + Result.retry() + } + } + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(SearchBackfillWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index db8f4753ec..f77e690ebf 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -125,6 +125,10 @@ fun RustEventSendState?.map(): LocalEventSendState? { is QueueWedgeError.MissingMediaContent -> { LocalEventSendState.Failed.MissingMediaContent } + is QueueWedgeError.ExpiredAccessToken -> { + // This will automatically recover, so map it to the sending state + LocalEventSendState.Sending.Event + } } } is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 4dcb8b1df8..1eeffae466 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -12,8 +12,14 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider @@ -21,6 +27,7 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -29,13 +36,16 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientDelegate import java.io.File +import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService class RustMatrixClientFactoryTest { @Test fun test() = runTest { val scheduleVacuumLambda = lambdaRecorder {} - val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda, cancelLambda = { _, _ -> }) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) val result = sut.create(aSessionData()) @@ -44,6 +54,46 @@ class RustMatrixClientFactoryTest { scheduleVacuumLambda.assertions().isCalledOnce() result.destroy() } + + @Test + fun `an auth error on a client only invalidates its own session`() = runTest { + val aliceSessionId = "@alice:server.org" + val bobSessionId = "@bob:server.org" + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = aliceSessionId, isTokenValid = true), + aSessionData(sessionId = bobSessionId, isTokenValid = true), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ) + val userIds = ArrayDeque(listOf(aliceSessionId, bobSessionId)) + val delegates = mutableMapOf() + val sut = createRustMatrixClientFactory( + sessionStore = sessionStore, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), + clientBuilderProvider = FakeClientBuilderProvider { + val userId = userIds.removeFirst() + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + userId = userId, + withUtdHook = {}, + setDelegateResult = { delegates[userId] = it }, + ) + } + ) + }, + ) + val aliceClient = sut.create(sessionStore.getSession(aliceSessionId)!!) + val bobClient = sut.create(sessionStore.getSession(bobSessionId)!!) + + delegates.getValue(aliceSessionId)!!.didReceiveAuthError(isSoftLogout = false) + + assertThat(sessionStore.getSession(aliceSessionId)!!.isTokenValid).isFalse() + assertThat(sessionStore.getSession(bobSessionId)!!.isTokenValid).isTrue() + aliceClient.destroy() + bobClient.destroy() + } } fun TestScope.createRustMatrixClientFactory( @@ -52,7 +102,7 @@ fun TestScope.createRustMatrixClientFactory( updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, @@ -61,12 +111,52 @@ fun TestScope.createRustMatrixClientFactory( sessionStore = sessionStore, userAgentProvider = SimpleUserAgentProvider(), proxyProvider = FakeProxyProvider(), - clock = FakeSystemClock(), analyticsService = FakeAnalyticsService(), featureFlagService = FakeFeatureFlagService(), - timelineEventFilterFactory = FakeTimelineEventFilterFactory(), clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, - workManagerScheduler = workManagerScheduler, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), + innerMatrixClientFactory = createRustMatrixClientInnerFactory(sessionStore, workManagerScheduler), ) + +private fun TestScope.createRustMatrixClientInnerFactory( + sessionStore: SessionStore, + workManagerScheduler: FakeWorkManagerScheduler, +) = object : RustMatrixClient.Factory { + override fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ) = RustMatrixClient( + sessionPaths = sessionPaths, + innerClient = innerClient, + innerSyncService = innerSyncService, + baseCacheDirectory = baseCacheDirectory, + contentScanner = contentScanner, + isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, + sessionStore = sessionStore, + appCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + clock = FakeSystemClock(), + timelineEventFilterFactory = FakeTimelineEventFilterFactory(), + featureFlagService = FakeFeatureFlagService(), + analyticsService = FakeAnalyticsService(), + workManagerScheduler = workManagerScheduler, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = workManagerScheduler, + ) + }, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index 4fe61e4d14..2e027930fd 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProf import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.test.scanner.FakeContentScanner import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -325,8 +328,19 @@ class RustMatrixClientTest { timelineEventFilterFactory = FakeTimelineEventFilterFactory(), featureFlagService = FakeFeatureFlagService(), analyticsService = FakeAnalyticsService(), - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), contentScanner = FakeContentScanner(), isMessageSearchAvailable = false, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), + ) + }, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index f4a229b30b..4fb3250071 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -8,19 +8,7 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory -import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory -import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider -import io.element.android.libraries.network.useragent.SimpleUserAgentProvider -import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -56,28 +44,4 @@ class RustTemporaryMatrixClientFactoryTest { rustMatrixClientFactory = rustMatrixClientFactory, ) } - - private fun TestScope.createRustMatrixClientFactory( - cacheDirectory: File = File("/cache"), - sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _, _ -> }, - ), - clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - ) = RustMatrixClientFactory( - cacheDirectory = cacheDirectory, - appCoroutineScope = backgroundScope, - coroutineDispatchers = testCoroutineDispatchers(), - sessionStore = sessionStore, - userAgentProvider = SimpleUserAgentProvider(), - proxyProvider = FakeProxyProvider(), - clock = FakeSystemClock(), - analyticsService = FakeAnalyticsService(), - featureFlagService = FakeFeatureFlagService(), - timelineEventFilterFactory = FakeTimelineEventFilterFactory(), - clientBuilderProvider = clientBuilderProvider, - sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), - workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), - ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index 9b589b2281..df564ca710 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -265,7 +265,7 @@ class RustMatrixAuthenticationServiceTest { cacheDirectory = File("/cache"), sessionStore = sessionStore, clientBuilderProvider = clientBuilderProvider, - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, ) return RustMatrixAuthenticationService( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index c2080747d5..9d6d8dd63a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate import org.matrix.rustcomponents.sdk.UserProfile +import uniffi.matrix_sdk.SearchBackfillStrategy import uniffi.matrix_sdk_base.MediaRetentionPolicy class FakeFfiClient( @@ -73,7 +74,9 @@ class FakeFfiClient( private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() }, private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, + private val runSearchBackfillResult: (SearchBackfillStrategy) -> TaskHandle = { lambdaError() }, private val closeResult: () -> Unit = {}, + private val setDelegateResult: (ClientDelegate?) -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId override fun deviceId(): String = deviceId @@ -83,7 +86,10 @@ class FakeFfiClient( override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings override fun encryption(): Encryption = encryption override fun session(): Session = session - override fun setDelegate(delegate: ClientDelegate?): TaskHandle = FakeFfiTaskHandle() + override fun setDelegate(delegate: ClientDelegate?): TaskHandle { + setDelegateResult(delegate) + return FakeFfiTaskHandle() + } override suspend fun cachedAvatarUrl(): String? = null override suspend fun restoreSession(session: Session) = Unit override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder() @@ -170,5 +176,7 @@ class FakeFfiClient( contentScannerResult() } + override fun runSearchBackfill(strategy: SearchBackfillStrategy): TaskHandle = runSearchBackfillResult(strategy) + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt index 24d7884547..782bebd295 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt @@ -11,7 +11,11 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.TaskHandle -class FakeFfiTaskHandle : TaskHandle(NoHandle) { - override fun cancel() = Unit +class FakeFfiTaskHandle( + private val isFinishedResult: () -> Boolean = { false }, + private val cancelResult: () -> Unit = {}, +) : TaskHandle(NoHandle) { + override fun isFinished(): Boolean = isFinishedResult() + override fun cancel() = cancelResult() override fun destroy() = Unit } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt new file mode 100644 index 0000000000..aeac7a506c --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.TaskHandle +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +class RustSearchBackfillServiceTest { + @Test + fun `schedulePeriodicSearchBackfill submits a request when nothing is pending`() = runTest { + val builder = aRequestBuilder() + val createBuilderLambda = lambdaRecorder { builder } + val submitLambda = lambdaRecorder { } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = { _, _ -> false }, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + createBuilderLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + submitLambda.assertions().isCalledOnce().with(value(builder)) + } + + @Test + fun `schedulePeriodicSearchBackfill does nothing when work is already pending`() = runTest { + val hasPendingWorkLambda = lambdaRecorder { _, _ -> true } + val createBuilderLambda = lambdaRecorder { error("Should not be called") } + val submitLambda = lambdaRecorder { error("Should not be called") } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = hasPendingWorkLambda, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + hasPendingWorkLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + createBuilderLambda.assertions().isNeverCalled() + submitLambda.assertions().isNeverCalled() + } + + @Test + fun `schedulePeriodicSearchBackfill returns a failure when submitting fails`() = runTest { + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = { throw IllegalStateException("boom") }), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `cancelPeriodicSearchBackfill cancels the search backfill work of the session`() { + val cancelLambda = lambdaRecorder { _, _ -> } + val service = createService(workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelLambda)) + + service.cancelPeriodicSearchBackfill() + + cancelLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + } + + @Test + fun `startSearchBackfill maps the strategy and returns a running task`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + val task = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrThrow() + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.FOREGROUND)) + assertThat(task.isRunning()).isTrue() + assertThat(service.isSearchBackfillRunning()).isTrue() + } + + @Test + fun `startSearchBackfill maps the background strategy`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + service.startSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.BACKGROUND)) + } + + @Test + fun `startSearchBackfill fails when a backfill is already running`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + strategyLambda.assertions().isCalledOnce() + } + + @Test + fun `startSearchBackfill can start again once the previous task has finished`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { true }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + @Test + fun `startSearchBackfill returns a failure when the SDK fails`() { + val service = createService(client = FakeFfiClient(runSearchBackfillResult = { error("boom") })) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.isFailure).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `isSearchBackfillRunning is false when nothing was started`() { + assertThat(createService().isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill cancels the running task`() { + val cancelLambda = lambdaRecorder { } + val service = createService( + client = FakeFfiClient(runSearchBackfillResult = { FakeFfiTaskHandle(cancelResult = cancelLambda) }), + ) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.cancelSearchBackfill() + + assertThat(result.isSuccess).isTrue() + cancelLambda.assertions().isCalledOnce() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill without a running task is a no-op`() { + assertThat(createService().cancelSearchBackfill().isSuccess).isTrue() + } + + @Test + fun `startSearchBackfill works again after cancelSearchBackfill`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + service.cancelSearchBackfill() + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + private fun aRequestBuilder() = object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + + private fun createService( + client: FakeFfiClient = FakeFfiClient(), + requestBuilderFactory: SearchBackfillRequestBuilder.Factory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + ) = RustSearchBackfillService( + innerClient = client, + searchBackfillRequestBuilderFactory = requestBuilderFactory, + workManagerScheduler = workManagerScheduler, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt new file mode 100644 index 0000000000..6518f93a49 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.net.NetworkCapabilities +import androidx.work.PeriodicWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class DefaultSearchBackfillRequestBuilderTest : RobolectricTest() { + private val expectedTag = workManagerTag(A_SESSION_ID, WorkManagerRequestType.SEARCH_BACKFILL) + + @Test + fun `build - creates a periodic request with session id and tag`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + assertThat(result.type).isEqualTo(WorkManagerWorkerType.Default) + result.request.run { + assertThat(this).isInstanceOf(PeriodicWorkRequest::class.java) + assertThat(workSpec.isPeriodic).isTrue() + assertThat(workSpec.intervalDuration).isEqualTo(1.hours.inWholeMilliseconds) + assertThat(workSpec.initialDelay).isEqualTo(5.minutes.inWholeMilliseconds) + assertThat(workSpec.input.hasKeyWithValueOfType(SearchBackfillWorker.SESSION_ID_PARAM)).isTrue() + assertThat(workSpec.input.getString(SearchBackfillWorker.SESSION_ID_PARAM)).isEqualTo(A_SESSION_ID.value) + assertThat(tags).contains(expectedTag) + assertThat(workSpec.workerClassName).isEqualTo(SearchBackfillWorker::class.java.name) + } + } + + @Test + fun `build - requires charging and an unmetered network`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + val constraints = result.request.workSpec.constraints + assertThat(constraints.requiresCharging()).isTrue() + val networkRequest = constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).isTrue() + } + + @Test + fun `build - validates the network when not in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = false).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isTrue() + } + + @Test + fun `build - does not validate the network when in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = true).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse() + } + + private fun createBuilder( + isInAirGapEnvironment: Boolean = false, + ) = DefaultSearchBackfillRequestBuilder( + sessionId = A_SESSION_ID, + networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) }, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt new file mode 100644 index 0000000000..f450b9f68a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor +import androidx.work.workDataOf +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.ListenableFuture +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +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.FakeSdkPendingTask +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchBackfillWorkerTest : RobolectricTest() { + @Test + fun `missing session id - fails`() = runTest { + val worker = createWorker(input = null, client = FakeMatrixClient()) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `client cannot be restored - fails`() = runTest { + val worker = createWorker(client = null) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill already running - does not start another one`() = runTest { + val startLambda = lambdaRecorder> { error("Should not be called") } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + isSearchBackfillRunningLambda = { true }, + startSearchBackfillLambda = startLambda, + ), + ) + + val result = createWorker(client = client).doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isNeverCalled() + } + + @Test + fun `start fails - fails`() = runTest { + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.failure(IllegalStateException("boom")) }, + ), + ) + + assertThat(createWorker(client = client).doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill finishes - succeeds and closes the task`() = runTest { + var running = true + val closeLambda = lambdaRecorder { } + val startLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { running }, closeLambda = closeLambda)) + } + val client = FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = startLambda)) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(10.seconds) + running = false + advanceTimeBy(10.seconds) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isCalledOnce().with(value(SearchBackfillStrategy.BACKGROUND)) + closeLambda.assertions().isCalledOnce() + } + + @Test + fun `backfill times out after 10 minutes - closes the task and retries`() = runTest { + val closeLambda = lambdaRecorder { } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.success(FakeSdkPendingTask(isRunningLambda = { true }, closeLambda = closeLambda)) }, + ), + ) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(9.minutes) + assertThat(deferred.isCompleted).isFalse() + advanceTimeBy(2.minutes) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.retry()) + closeLambda.assertions().isCalledOnce() + } + + private fun TestScope.createWorker( + input: String? = A_SESSION_ID.value, + client: MatrixClient?, + ) = SearchBackfillWorker( + params = createWorkerParams( + inputData = input?.let { workDataOf(SearchBackfillWorker.SESSION_ID_PARAM to it) } ?: Data.EMPTY, + ), + context = InstrumentationRegistry.getInstrumentation().context, + matrixClientProvider = FakeMatrixClientProvider( + getClient = { client?.let { Result.success(it) } ?: Result.failure(IllegalStateException("No client")) } + ), + ) + + private fun TestScope.createWorkerParams( + inputData: Data = Data.EMPTY, + ): WorkerParameters = WorkerParameters( + UUID.randomUUID(), + inputData, + emptySet(), + WorkerParameters.RuntimeExtras(), + 0, + 0, + Executors.newSingleThreadExecutor(), + backgroundScope.coroutineContext, + WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), + MetroWorkerFactory(emptyMap()), + { _, _, _ -> FakeListenableFuture() }, + { _, _, _ -> FakeListenableFuture() }, + ) +} + +private class FakeListenableFuture : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = Unit + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true + override fun get(): T? = null + override fun get(timeout: Long, unit: TimeUnit?): T? = null + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = false +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index f176d3b006..6d45db5041 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -131,6 +133,7 @@ class FakeMatrixClient( private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, private val markAllRoomsAsReadResult: () -> Result = { Result.success(Unit) }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, + override val searchBackfillService: SearchBackfillService = FakeSearchBackfillService(), private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt new file mode 100644 index 0000000000..98f3b00117 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSdkPendingTask( + private val isRunningLambda: () -> Boolean = { lambdaError() }, + private val closeLambda: () -> Unit = { lambdaError() }, +) : SdkPendingTask { + override fun isRunning(): Boolean = isRunningLambda() + + override fun close() = closeLambda() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt new file mode 100644 index 0000000000..33652af13e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.search + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSearchBackfillService( + private val schedulePeriodicSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val cancelPeriodicSearchBackfillLambda: () -> Unit = { lambdaError() }, + private val startSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val isSearchBackfillRunningLambda: () -> Boolean = { false }, + private val cancelSearchBackfillLambda: () -> Result = { lambdaError() }, +) : SearchBackfillService { + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result { + return schedulePeriodicSearchBackfillLambda(strategy) + } + + override fun cancelPeriodicSearchBackfill() { + cancelPeriodicSearchBackfillLambda() + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result { + return startSearchBackfillLambda(strategy) + } + + override fun isSearchBackfillRunning(): Boolean { + return isSearchBackfillRunningLambda() + } + + override fun cancelSearchBackfill(): Result { + return cancelSearchBackfillLambda() + } +} diff --git a/libraries/matrixmedia/api/build.gradle.kts b/libraries/matrixmedia/api/build.gradle.kts index 90c7ee635e..500b714c6a 100644 --- a/libraries/matrixmedia/api/build.gradle.kts +++ b/libraries/matrixmedia/api/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.api" + namespace = "io.element.android.libraries.matrixmedia.api" } dependencies { diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt similarity index 92% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt index 4d4dd402d6..09c0d19f2c 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api /** * The size in pixel of the thumbnail to generate for the avatar. diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt index 228fbd2cff..e78bdfc3b5 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt index f145383e87..f77fd5f032 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import android.graphics.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt similarity index 96% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt index 47841a573b..a1f4d2af04 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import io.element.android.libraries.matrix.api.media.MediaSource diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts index 62799b5eaa..3a05a6c1c7 100644 --- a/libraries/matrixmedia/impl/build.gradle.kts +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.impl" + namespace = "io.element.android.libraries.matrixmedia.impl" } setupDependencyInjection() @@ -31,6 +31,5 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt similarity index 73% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt index 0b1a09bdf9..1310f0f750 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt @@ -5,10 +5,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal fun AvatarData.toMediaRequestData(): MediaRequestData { return MediaRequestData( diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt similarity index 96% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt index 9d7767e42d..264be31e6e 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt index d31b5671eb..cd9073bb51 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.decode.DataSource import coil3.decode.ImageSource @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.exception.isNetworkError import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.matrixmedia.api.MediaRequestData import okio.Buffer import okio.FileSystem import okio.Path.Companion.toOkioPath diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt similarity index 93% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt index a0957a3b13..0aec82dcbd 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import dev.zacsweers.metro.AppScope @@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt index 71c894f305..0baa317c5c 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Canvas import android.graphics.Paint @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator @ContributesBinding(AppScope::class) class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator { diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt index b414560fc9..a37a5bcf53 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.content.Context import android.os.Build diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt similarity index 86% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt index f0cfd2047b..32c196afe7 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt @@ -6,12 +6,13 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher import coil3.request.Options import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class MediaRequestDataFetcherFactory( private val matrixMediaLoader: MatrixMediaLoader, diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt similarity index 87% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt index 660b80500a..c8a654d634 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt @@ -6,11 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.key.Keyer import coil3.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class AvatarDataKeyer : Keyer { override fun key(data: AvatarData, options: Options): String? { diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt index eefacb3065..d6989aa72a 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Bitmap import coil3.ComponentRegistry diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt index 2bb1ddcae4..b84fa9153c 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt similarity index 93% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt index bcafad0737..506db985d0 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader diff --git a/libraries/matrixmedia/test/build.gradle.kts b/libraries/matrixmedia/test/build.gradle.kts index 5b8e966d08..86aac509c9 100644 --- a/libraries/matrixmedia/test/build.gradle.kts +++ b/libraries/matrixmedia/test/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.test" + namespace = "io.element.android.libraries.matrixmedia.test" } dependencies { diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt similarity index 96% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt index d3cd7388e5..d35d29c4f8 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ComponentRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt similarity index 85% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt index 59b9efda7d..8adb434438 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt @@ -6,12 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder class FakeImageLoaderHolder( val fakeImageLoader: ImageLoader = FakeImageLoader(), diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt similarity index 87% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt index 2d0df756ac..83759c4690 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt @@ -5,11 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator import io.element.android.tests.testutils.lambda.lambdaError class FakeInitialsAvatarBitmapGenerator( diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 5513dc240e..408751a8b1 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -41,6 +41,4 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 38c5732875..1b8da50426 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.parcelize.Parcelize @Composable diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts index 5e3d78dbc4..9bbf322536 100644 --- a/libraries/mediapickers/api/build.gradle.kts +++ b/libraries/mediapickers/api/build.gradle.kts @@ -17,9 +17,7 @@ android { } dependencies { - implementation(projects.libraries.uiStrings) implementation(projects.libraries.core) - implementation(projects.libraries.di) testCommonDependencies(libs) } diff --git a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt similarity index 94% rename from libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt rename to libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt index 752398f7c4..bdbf517886 100644 --- a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt @@ -6,13 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.api import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.mediapickers.api.PickerType import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts index 6ed9cd97dc..8363e2b2b3 100644 --- a/libraries/mediapickers/impl/build.gradle.kts +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -19,7 +19,6 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts index d7c2989871..46729db7de 100644 --- a/libraries/mediapickers/test/build.gradle.kts +++ b/libraries/mediapickers/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediaplayer/impl/build.gradle.kts b/libraries/mediaplayer/impl/build.gradle.kts index aa9140df35..0eddeb92ec 100644 --- a/libraries/mediaplayer/impl/build.gradle.kts +++ b/libraries/mediaplayer/impl/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(projects.libraries.audio.api) - implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(libs.coroutines.core) diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt index f7a748b636..7807b3ac65 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -14,8 +14,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.api.MediaPlayer -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.TimeoutCancellationException diff --git a/libraries/mediaplayer/test/build.gradle.kts b/libraries/mediaplayer/test/build.gradle.kts index e2f1c8ac29..bef4437b24 100644 --- a/libraries/mediaplayer/test/build.gradle.kts +++ b/libraries/mediaplayer/test/build.gradle.kts @@ -19,5 +19,4 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) } diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index a9f876c67f..fd3e2cfd8e 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -15,11 +15,7 @@ android { } dependencies { - implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.matrix.api) api(projects.libraries.preferences.api) - implementation(libs.coroutines.core) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt index 60dbf353eb..b0efb618b5 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt @@ -8,6 +8,8 @@ package io.element.android.libraries.mediaupload.api +import io.element.android.libraries.matrix.api.MatrixClient + /** * Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults. */ @@ -15,3 +17,15 @@ fun interface MediaOptimizationConfigProvider { /** Returns the configuration to pass to the pre-processing and sending calls. */ suspend fun get(): MediaOptimizationConfig } + +/** + * Creates a [MediaOptimizationConfigProvider] for a given session, used outside the session scope, for instance when sharing to another account. + */ +fun interface MediaOptimizationConfigProviderFactory { + /** + * Create a [MediaOptimizationConfigProvider] using the preferences of the session of [client]. + * + * @param client the client of the session whose preferences should be used. + */ + fun create(client: MatrixClient): MediaOptimizationConfigProvider +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 808575f9e6..aa76473634 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline @@ -34,9 +35,11 @@ fun interface MediaSenderRoomFactory { /** * Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live. * + * @param client the client of the session the room belongs to, used to read the media preferences of that session. * @param room the room the media should be sent to. */ fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt new file mode 100644 index 0000000000..7d2e7d1a81 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory + +@ContributesBinding(AppScope::class) +class DefaultMediaOptimizationConfigProviderFactory( + private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, + private val featureFlagService: FeatureFlagService, +) : MediaOptimizationConfigProviderFactory { + override fun create(client: MatrixClient): MediaOptimizationConfigProvider { + return DefaultMediaOptimizationConfigProvider( + sessionPreferencesStore = sessionPreferencesStoreFactory.get(client.sessionId, client.sessionCoroutineScope), + featureFlagsService = featureFlagService, + ) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index 36c3ba99fb..a5e0b1c38a 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -9,12 +9,13 @@ package io.element.android.libraries.mediaupload.impl import android.net.Uri +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMapCatching import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory @@ -52,19 +54,20 @@ class DefaultMediaSenderFactory( } } -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultMediaSenderRoomFactory( private val preProcessor: MediaPreProcessor, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, ) : MediaSenderRoomFactory { override fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender { return DefaultMediaSender( preProcessor = preProcessor, room = room, timelineMode = Timeline.Mode.Live, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client), ) } } diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index a5d7369056..891eb4e862 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer.midi) implementation(libs.androidx.media3.ui) implementation(libs.telephoto.zoomableimage) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) implementation(projects.features.enterprise.api) @@ -47,7 +46,6 @@ dependencies { implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixmedia.api) @@ -66,9 +64,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.audio.test) testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.matrixui) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.services.toolbox.test) testImplementation(libs.coroutines.core) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt index 824e032dde..6b3b6a30ea 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -44,7 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.Strings diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt index 5367751a19..95ebb6c818 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -12,9 +12,9 @@ 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.media.MediaSource import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo sealed interface MediaItem { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index e921f1d80c..aa4c0c92a6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -94,8 +94,8 @@ import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSiz import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 3ca702a47f..bd25229237 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -14,12 +14,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaviewer.api.AvatarInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource diff --git a/libraries/network/build.gradle.kts b/libraries/network/build.gradle.kts index faabe91a1c..1546358b7b 100644 --- a/libraries/network/build.gradle.kts +++ b/libraries/network/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(libs.coroutines.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) - implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) implementation(platform(libs.network.okhttp.bom)) diff --git a/libraries/oauth/impl/build.gradle.kts b/libraries/oauth/impl/build.gradle.kts index d051c06497..5a3b605277 100644 --- a/libraries/oauth/impl/build.gradle.kts +++ b/libraries/oauth/impl/build.gradle.kts @@ -28,20 +28,13 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) - implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(libs.androidx.browser) implementation(platform(libs.network.retrofit.bom)) - implementation(libs.network.retrofit) - implementation(libs.serialization.json) api(projects.libraries.oauth.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) } diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts index 70e27db7f0..fa033dc064 100644 --- a/libraries/permissions/impl/build.gradle.kts +++ b/libraries/permissions/impl/build.gradle.kts @@ -32,11 +32,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.troubleshoot.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) api(projects.libraries.permissions.api) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt index 9dd53ade09..16226813d2 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt @@ -12,7 +12,7 @@ import android.graphics.Bitmap import androidx.core.graphics.drawable.IconCompat import coil3.ImageLoader import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL /** * Loads the avatars used to decorate notifications, since the notification APIs need bitmaps rather than Compose images. diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt index 95966f8eb1..0e41d63e26 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt @@ -65,4 +65,11 @@ interface NotificationCleaner { * @param roomId the room whose notifications are dismissed. */ fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) + + /** + * Dismisses the notifications of every read room. + * + * @param sessionId the session whose notifications should be dismissed. + */ + fun clearReadRoomsNotifications(sessionId: SessionId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 8615485bba..cd83392383 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -86,7 +86,6 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.workmanager.api) - implementation(projects.features.announcement.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) @@ -110,7 +109,6 @@ dependencies { testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.libraries.workmanager.test) - testImplementation(projects.features.announcement.test) testImplementation(projects.features.call.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.lockscreen.test) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt index 086914ecf5..e891fa3ea6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt @@ -23,9 +23,9 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import timber.log.Timber diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 6834f404ed..12109381df 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId @@ -19,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -53,7 +54,7 @@ class DefaultNotificationDrawerManager( private val notificationRenderer: NotificationRenderer, private val appNavigationStateService: AppNavigationStateService, @AppCoroutineScope - coroutineScope: CoroutineScope, + private val coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, private val activeNotificationsProvider: ActiveNotificationsProvider, @@ -187,6 +188,31 @@ class DefaultNotificationDrawerManager( clearSummaryNotificationIfNeeded(sessionId) } + override fun clearReadRoomsNotifications(sessionId: SessionId) { + val notifications = activeNotificationsProvider.getNotificationsForSession(sessionId) + val notificationsGroupedByRoomId = notifications.mapNotNull { + tryOrNull { RoomId(it.tag) }?.let { roomId -> roomId to it } + } + + if (notificationsGroupedByRoomId.isEmpty()) return + + coroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { return@launch } + + notificationsGroupedByRoomId.forEach { (roomId, notification) -> + val roomInfo = client.getRoomInfo(roomId).getOrNull() ?: return@forEach + + if (roomInfo.numUnreadNotifications > 0L || roomInfo.numUnreadMessages > 0 || roomInfo.numUnreadMentions > 0) { + return@forEach + } + + notificationDisplayer.cancelNotification(notification.tag, notification.id) + } + + clearSummaryNotificationIfNeeded(sessionId) + } + } + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index 7b08958cd1..583a10f6fb 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.di.annotations.ApplicationContext 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.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.intent.IntentProvider diff --git a/libraries/push/impl/src/main/res/values-in/translations.xml b/libraries/push/impl/src/main/res/values-in/translations.xml index db9147c0cf..fcd8eaa76b 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -45,7 +45,7 @@ "%1$s disebut atau dibalas" "Kami mengundang Anda untuk bergabung di ruang ini." "%1$s mengundang Anda untuk bergabung ke ruang ini." - "Anda sedang melihat pemberitahuan ini! Klik saya!" + "Anda sedang melihat notifikasi ini! Klik saya!" "Diskusi di %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -87,7 +87,7 @@ "Tidak dapat menampilkan notifikasi." "Notifikasi telah diklik!" "Tampilan notifikasi" - "Silakan klik pada notifikasi untuk melanjutkan tes." + "Silakan klik notifikasi untuk melanjutkan tes." "Pastikan aplikasi menerima notifikasi dorongan." "Kesalahan: pendorong telah menolak permintaan." "Kesalahan: %1$s." diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt index 408063712a..17ab5b3967 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt @@ -19,10 +19,10 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index cdc6a01fad..ce7690ed0a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification +import android.service.notification.StatusBarNotification import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService @@ -16,6 +17,8 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService 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.room.RoomInfo import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -27,8 +30,9 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -57,6 +61,7 @@ import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.anAppNavigationState import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -569,6 +574,126 @@ class DefaultNotificationDrawerManagerTest { createFallbackNotificationResult.assertions().isNeverCalled() } + @Test + fun `clearReadRoomsNotifications cancels notifications of rooms without unread counts`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledOnce().with(value(A_ROOM_ID.value), value(1)) + } + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread messages`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMessages = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread notifications`() = + testKeepsNotification(aRoomInfo(id = A_ROOM_ID, numUnreadNotifications = 1)) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread mentions`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMentions = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications when the room info is not found`() = testKeepsNotification(null) + + @Test + fun `clearReadRoomsNotifications does nothing when the client cannot be restored`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + clientResult = Result.failure(IllegalStateException("No client")), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications ignores notifications not linked to a room`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf( + mockk { + every { id } returns 1 + every { tag } returns null + }, + ), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications clears the summary when it is the only notification left`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + summaryNotification = mockk { every { id } returns summaryId }, + count = 1, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(1)), + listOf(value(null), value(summaryId)), + ) + } + + private fun testKeepsNotification(roomInfo: RoomInfo?) = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = roomInfo, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + private fun aRoomStatusBarNotification(roomId: RoomId, notificationId: Int): StatusBarNotification = mockk { + every { id } returns notificationId + every { tag } returns roomId.value + } + + private fun TestScope.createClearReadRoomsManager( + cancelNotificationResult: LambdaTwoParamsRecorder, + notifications: List, + roomInfo: RoomInfo?, + clientResult: Result? = null, + summaryNotification: StatusBarNotification? = null, + count: Int = notifications.size, + ): DefaultNotificationDrawerManager { + val matrixClient = FakeMatrixClient().apply { + getRoomInfoLambda = { Result.success(roomInfo) } + } + return createDefaultNotificationDrawerManager( + notificationDisplayer = FakeNotificationDisplayer(cancelNotificationResult = cancelNotificationResult), + activeNotificationsProvider = FakeActiveNotificationsProvider( + getNotificationsForSessionResult = { notifications }, + getSummaryNotificationResult = { summaryNotification }, + countResult = { count }, + ), + matrixClientProvider = FakeMatrixClientProvider(getClient = { clientResult ?: Result.success(matrixClient) }), + ) + } + /** * The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept. */ diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index 1a46546db8..46845fd55b 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_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.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 43991444fe..4a1970b4f2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_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.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index cb5fad7509..0296836ccb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2 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.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index ead16c02d7..513db1ef0c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -27,8 +27,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.NotificationActionIds diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index d125a2387e..f97a23a907 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.workmanager.api) implementation(projects.tests.testutils) implementation(libs.androidx.core) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt index 28a249dc9b..82202111dd 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt @@ -21,7 +21,8 @@ class FakeNotificationCleaner( private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() }, private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() }, private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() }, - private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() } + private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val clearReadRoomsNotificationsLambda: (SessionId) -> Unit = { lambdaError() }, ) : NotificationCleaner { override fun clearAllMessagesEvents(sessionId: SessionId) { clearAllMessagesEventsLambda(sessionId) @@ -46,4 +47,8 @@ class FakeNotificationCleaner( override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { clearMembershipNotificationForRoomLambda(sessionId, roomId) } + + override fun clearReadRoomsNotifications(sessionId: SessionId) { + clearReadRoomsNotificationsLambda(sessionId) + } } diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts index 3581a43952..d489ec5bfe 100644 --- a/libraries/pushproviders/api/build.gradle.kts +++ b/libraries/pushproviders/api/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index cfe5dd01fb..35fef58fa0 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -51,7 +51,6 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.services.toolbox.api) @@ -66,8 +65,6 @@ dependencies { } testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 1b24bc4b1f..7aa19b093b 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) - implementation(projects.libraries.uiStrings) api(projects.libraries.troubleshoot.api) implementation(projects.libraries.pushstore.api) @@ -46,8 +45,6 @@ dependencies { api(libs.unifiedpush) testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 4e643fd5f9..8691bd2196 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.push.pushstore.impl" + namespace = "io.element.android.libraries.pushstore.impl" defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt index a3b31acf6c..d06bd43582 100644 --- a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt +++ b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt @@ -18,7 +18,7 @@ import kotlin.concurrent.thread /** * Note: to clear the emulator, invoke: - * adb uninstall io.element.android.libraries.push.pushstore.impl.test + * adb uninstall io.element.android.libraries.pushstore.impl.test */ class DefaultUserPushStoreFactoryTest { /** diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt index 3c5c5d5ac3..5fec007261 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -13,9 +13,16 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId interface RoomSelectEntryPoint : FeatureEntryPoint { + /** + * @param sessionId the session to use initially. In [RoomSelectMode.Share] mode, the user can select another session. + * @param mode the mode of the screen. + * @param maxNumberOfRooms the maximum number of rooms the user can select. + */ data class Params( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) @@ -28,7 +35,11 @@ interface RoomSelectEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onRoomSelected(roomIds: List) + /** + * @param sessionId the session which has been selected by the user, the rooms belong to this session. + * @param roomIds the selected rooms. + */ + fun onRoomSelected(sessionId: SessionId, roomIds: List) fun onCancel() } diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts index 07443bbf9d..281e1025b8 100644 --- a/libraries/roomselect/impl/build.gradle.kts +++ b/libraries/roomselect/impl/build.gradle.kts @@ -32,10 +32,12 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) api(projects.libraries.roomselect.api) testCommonDependencies(libs, includeTestComposeView = true) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt index ff3ba06f0d..076c1209ee 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt @@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { override fun createNode( parentNode: Node, @@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { buildContext = buildContext, plugins = listOf( RoomSelectNode.Inputs( + sessionId = params.sessionId, mode = params.mode, maxNumberOfRooms = params.maxNumberOfRooms, ), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt index 13c5bc5f8d..0f7baecf12 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt @@ -8,10 +8,13 @@ package io.element.android.libraries.roomselect.impl +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.ui.model.SelectRoomInfo sealed interface RoomSelectEvent { data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data object ToggleSearchActive : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent + data object ToggleAccountListExpanded : RoomSelectEvent + data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt index 0297f13066..f137ecc848 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode @@ -22,11 +23,11 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode -@ContributesNode(SessionScope::class) +@ContributesNode(AppScope::class) @AssistedInject class RoomSelectNode( @Assisted buildContext: BuildContext, @@ -34,12 +35,14 @@ class RoomSelectNode( presenterFactory: RoomSelectPresenter.Factory, ) : Node(buildContext, plugins = plugins) { data class Inputs( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) : NodeInputs private val inputs: Inputs = inputs() private val presenter = presenterFactory.create( + initialSessionId = inputs.sessionId, mode = inputs.mode, maxNumberOfRooms = inputs.maxNumberOfRooms, ) @@ -52,7 +55,7 @@ class RoomSelectNode( RoomSelectView( state = state, onDismiss = callback::onCancel, - onSubmit = callback::onRoomSelected, + onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) }, modifier = modifier ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 618f30a3e8..3de9d22ba8 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.roomselect.impl +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -15,7 +16,9 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -24,22 +27,33 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import timber.log.Timber @AssistedInject class RoomSelectPresenter( + @Assisted private val initialSessionId: SessionId, @Assisted private val mode: RoomSelectMode, @Assisted private val maxNumberOfRooms: Int, private val dataSourceFactory: RoomSelectSearchDataSource.Factory, + private val matrixClientProvider: MatrixClientProvider, + private val sessionStore: SessionStore, ) : Presenter { @AssistedFactory fun interface Factory { fun create( + initialSessionId: SessionId, mode: RoomSelectMode, maxNumberOfRooms: Int, ): RoomSelectPresenter @@ -50,18 +64,75 @@ class RoomSelectPresenter( var selectedRooms by remember { mutableStateOf(persistentListOf()) } val queryState = rememberTextFieldState() var isSearchActive by remember { mutableStateOf(false) } - - val coroutineScope = rememberCoroutineScope() - val dataSource = remember { dataSourceFactory.create(coroutineScope) } - - val searchQuery = queryState.text.toString() - LaunchedEffect(searchQuery) { - dataSource.setSearchQuery(searchQuery) + var isAccountListExpanded by remember { mutableStateOf(false) } + var selectedSessionId by remember { mutableStateOf(initialSessionId) } + val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList()) + // All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error. + val accounts by remember { + derivedStateOf { sessions.map { it.toMatrixUser() } } + } + // The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is + // signed out from another device. The invalidation of the initial session, which is the current one, is handled by the application. + val isSelectedSessionInvalid by remember { + derivedStateOf { + selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid } + } + } + LaunchedEffect(isSelectedSessionInvalid) { + if (isSelectedSessionInvalid) { + // The rooms of an invalid session cannot be used + selectedRooms = persistentListOf() + } + } + val selectedAccount by remember { + derivedStateOf { + // Fallback to a MatrixUser with only the userId until the sessions are loaded + accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId) + } + } + val otherAccounts by remember { + derivedStateOf { + accounts.filter { it.userId != selectedSessionId }.toImmutableList() + } } - val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + val coroutineScope = rememberCoroutineScope() + // Create one data source per session. When the selected session changes, the previous + // data source is removed from the composition and its coroutine scope is cancelled. + val (selectedSessionDataSource, hasClientError) = key(selectedSessionId) { + val dataSourceCoroutineScope = rememberCoroutineScope() + // The sessions are restored at startup, so the client should already be in memory. + // A null value means that the client is being restored, or that the session is invalid: its client cannot be restored, + // and an in-memory client would have been destroyed, so do not try to get it. + val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService + val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) { + if (value == null && !isSelectedSessionInvalid) { + value = matrixClientProvider.getOrRestore(selectedSessionId) + .onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } + .map { it.roomListService } + } + } + val roomListService = roomListServiceResult?.getOrNull() + val dataSource = remember(roomListService) { + roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } + } + dataSource to (roomListServiceResult?.isFailure == true) + } + // Do not use the data source of an invalid session, its client has been destroyed + val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid } + val hasRoomListError = hasClientError || isSelectedSessionInvalid - val searchResults by remember>>> { + val searchQuery = queryState.text.toString() + LaunchedEffect(dataSource, searchQuery) { + dataSource?.setSearchQuery(searchQuery) + } + + // Use a key so that the rooms of the previously selected session are not rendered while loading the new ones + val roomSummaryDetailsList by key(dataSource) { + (dataSource?.roomInfoList ?: flowOf(persistentListOf())).collectAsState(initial = persistentListOf()) + } + + val searchResults by remember>>>(dataSource) { derivedStateOf { when { roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) @@ -82,8 +153,16 @@ class RoomSelectPresenter( } } RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive + RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded + is RoomSelectEvent.SelectAccount -> { + selectedSessionId = event.sessionId + // The selected rooms and the search query are specific to the session + selectedRooms = persistentListOf() + queryState.clearText() + isAccountListExpanded = false + } is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { - dataSource.updateVisibleRange(event.range) + dataSource?.updateVisibleRange(event.range) } } } @@ -95,6 +174,10 @@ class RoomSelectPresenter( searchQuery = queryState, isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = ::handleEvent, ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt index c6e6bc88fc..b17b74e48f 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30 @AssistedInject class RoomSelectSearchDataSource( @Assisted coroutineScope: CoroutineScope, - roomListService: RoomListService, + @Assisted roomListService: RoomListService, coroutineDispatchers: CoroutineDispatchers, ) { @AssistedFactory interface Factory { - fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource + fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource } private val roomList = roomListService.createRoomList( diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index aadb7c10d1..7af7b28ddf 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.TextFieldState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.collections.immutable.ImmutableList @@ -21,7 +22,19 @@ data class RoomSelectState( val searchQuery: TextFieldState, val isSearchActive: Boolean, val selectedRooms: ImmutableList, + val selectedAccount: MatrixUser, + /** + * True if the rooms of the selected account cannot be loaded. + */ + val hasRoomListError: Boolean, + val otherAccounts: ImmutableList, + val isAccountListExpanded: Boolean, val eventSink: (RoomSelectEvent) -> Unit, ) { val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms + + /** + * The account switch is only displayed when sharing, and if there are several accounts. + */ + val showAccountSwitch = mode == RoomSelectMode.Share && otherAccounts.isNotEmpty() } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index 41fbf93532..0c46c652ef 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo @@ -47,6 +49,25 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider = persistentListOf(), + selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + hasRoomListError: Boolean = false, + otherAccounts: ImmutableList = persistentListOf(), + isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, ) = RoomSelectState( mode = mode, @@ -65,9 +90,18 @@ internal fun aRoomSelectState( searchQuery = TextFieldState(initialText = searchQuery), isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = eventSink, ) +private fun anOtherAccountList() = persistentListOf( + aMatrixUser(id = "@bob:example.org", displayName = "Bob"), + aMatrixUser(id = "@carol:server.org", displayName = "Carol"), +) + internal fun aRoomSelectRoomList() = persistentListOf( aSelectRoomInfo( roomId = RoomId("!room1:domain"), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 77bf766204..a201faaa3d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -9,6 +9,11 @@ package io.element.android.libraries.roomselect.impl import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,7 +26,6 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -29,27 +33,42 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState @@ -60,6 +79,10 @@ import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffec import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserHeader +import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -68,6 +91,9 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-38510 + */ @Suppress("MultipleEmitters") // False positive @Composable fun RoomSelectView( @@ -111,7 +137,13 @@ fun RoomSelectView( onBack = { onBackButton(state) } ) - val lazyListState = rememberLazyListState() + // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top + val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } + // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. + // Use a new state when the selected account changes, so that the section is fully displayed again. + val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { + TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) } @@ -145,6 +177,14 @@ fun RoomSelectView( Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) + .then( + // Only when the account switch section is displayed, else its unknown height would consume all the scroll events + if (state.showAccountSwitch && !state.isSearchActive) { + Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) + } else { + Modifier + } + ) ) { SearchBar( modifier = Modifier @@ -165,6 +205,14 @@ fun RoomSelectView( state = lazyListState, contentPadding = lazyColumnContentPadding, ) { + if (summaries.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } + } item { SelectedRoomsHelper( selectedRooms = state.selectedRooms, @@ -172,7 +220,53 @@ fun RoomSelectView( ) } items(summaries, key = { it.roomId.value }) { roomSummary -> - Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) + }, + canBeSelected = state.canSelectMoreRooms, + ) + } + } + } + + if (!state.isSearchActive) { + if (state.showAccountSwitch) { + // Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing + Spacer(modifier = Modifier.height(8.dp)) + TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { + AccountSwitchSection( + selectedAccount = state.selectedAccount, + otherAccounts = state.otherAccounts, + isExpanded = state.isAccountListExpanded, + onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, + onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, + ) + } + } + SelectedRoomsHelper( + selectedRooms = state.selectedRooms, + // showVerticalSpace only if there is no other accounts + showVerticalSpace = !state.showAccountSwitch, + ) + if (state.hasRoomListError) { + RoomListError() + } else if (state.resultState is SearchBarResultState.Results) { + LazyColumn( + state = lazyListState, + contentPadding = lazyColumnContentPadding, + ) { + if (state.resultState.results.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } + } + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> RoomSummaryView( roomSummary, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, @@ -181,34 +275,6 @@ fun RoomSelectView( }, canBeSelected = state.canSelectMoreRooms, ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } - } - } - } - - if (!state.isSearchActive) { - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - showVerticalSpace = true, - ) - if (state.resultState is SearchBarResultState.Results) { - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, - ) { - items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> - Column { - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) - }, - canBeSelected = state.canSelectMoreRooms, - ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } } } } @@ -217,6 +283,114 @@ fun RoomSelectView( } } +@Composable +private fun RoomListError() { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + title = stringResource(CommonStrings.common_something_went_wrong), + subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms), + iconStyle = BigIcon.Style.AlertSolid, + ) +} + +/** + * Similar to the MultiAccountSection of the PreferencesRootView. + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759 + */ +@Composable +private fun AccountSwitchSection( + selectedAccount: MatrixUser, + otherAccounts: ImmutableList, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onSelectAccount: (SessionId) -> Unit, +) { + Column { + MatrixUserHeader( + matrixUser = selectedAccount, + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + val expandedStateDescription = if (isExpanded) { + stringResource(CommonStrings.a11y_state_expanded) + } else { + stringResource(CommonStrings.a11y_state_collapsed) + } + ListItem( + modifier = Modifier.semantics { + stateDescription = expandedStateDescription + }, + content = { Text(stringResource(CommonStrings.common_switch_account)) }, + onClick = onToggleExpand, + trailingContent = ListItemContent.Custom { _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = !isExpanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + AvatarRow( + avatarDataList = otherAccounts + .take(3) + .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .toImmutableList(), + avatarType = AvatarType.User, + lastOnTop = true, + ) + } + // Animate the chevron icon to rotate when the section is expanded/collapsed + val rotation: Float by animateFloatAsState( + targetValue = if (isExpanded) -180f else 0f, + animationSpec = tween( + delayMillis = 0, + durationMillis = 300, + ), + label = "chevron" + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = CompoundIcons.ChevronDown(), + contentDescription = null, + ) + } + }, + ) + AnimatedVisibility( + visible = isExpanded, + ) { + Column { + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + otherAccounts.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier + .clickable { + onSelectAccount(matrixUser.userId) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + verticalSpaceWidth = 16.dp, + ) + } + } + } + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + } +} + @Composable private fun SelectedRooms( selectedRooms: ImmutableList, @@ -241,28 +415,25 @@ private fun RoomSummaryView( canBeSelected: Boolean, onSelection: (SelectRoomInfo) -> Unit, ) { - Row( - modifier = Modifier - .clickable { onSelection(roomInfo) } - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .heightIn(56.dp), - verticalAlignment = Alignment.CenterVertically + ListItem( + onClick = { onSelection(roomInfo) }, + leadingContent = ListItemContent.Custom { + Avatar( + avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + ) + }, + trailingContent = ListItemContent.Checkbox( + checked = isSelected, + enabled = isSelected || canBeSelected, + ), ) { - Avatar( - avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), - avatarType = AvatarType.Room( - heroes = roomInfo.heroes.map { user -> - user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) - }.toImmutableList(), - isTombstoned = roomInfo.isTombstoned, - ), - ) - Column( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) - .weight(1f) - ) { + Column { // Name Text( style = ElementTheme.typography.fontBodyLgRegular, @@ -284,11 +455,6 @@ private fun RoomSummaryView( ) } } - Checkbox( - checked = isSelected, - enabled = isSelected || canBeSelected, - onCheckedChange = { onSelection(roomInfo) }, - ) } } diff --git a/libraries/roomselect/impl/src/main/res/values/localazy.xml b/libraries/roomselect/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..377391b835 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "The room list associated with this account cannot be loaded." + diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt index 6863c1ce20..c25e3303cc 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt @@ -12,6 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.tests.testutils.lambda.lambdaError @@ -36,7 +38,8 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { RoomSelectNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { mode, maxNumberOfRooms -> + presenterFactory = { initialSessionId, mode, maxNumberOfRooms -> + assertThat(initialSessionId).isEqualTo(A_SESSION_ID) assertThat(mode).isEqualTo(testMode) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) createRoomSelectPresenter(mode, maxNumberOfRooms) @@ -44,10 +47,11 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { ) } val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) = lambdaError() + override fun onRoomSelected(sessionId: SessionId, roomIds: List) = lambdaError() override fun onCancel() = lambdaError() } val params = RoomSelectEntryPoint.Params( + sessionId = A_SESSION_ID, mode = testMode, maxNumberOfRooms = testMaxNumberOfRooms, ) @@ -58,7 +62,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { callback = callback, ) assertThat(result).isInstanceOf(RoomSelectNode::class.java) - assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms)) + assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.sessionId, params.mode, params.maxNumberOfRooms)) assertThat(result.plugins).contains(callback) } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index b5a4ccfd12..0694cc27f0 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -9,18 +9,34 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import app.cash.turbine.Event import com.google.common.truth.Truth.assertThat import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService +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_2 +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers @@ -46,9 +62,228 @@ class RoomSelectPresenterTest { assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.canSelectMoreRooms).isTrue() + assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) + assertThat(initialState.otherAccounts).isEmpty() + assertThat(initialState.isAccountListExpanded).isFalse() + assertThat(initialState.showAccountSwitch).isFalse() + assertThat(initialState.hasRoomListError).isFalse() } } + @Test + fun `present - failure to get the client shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(state.hasRoomListError).isTrue() + assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + } + } + + @Test + fun `present - select another account whose client cannot be restored shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) { + Result.success(FakeMatrixClient(sessionId = sessionId)) + } else { + Result.failure(AN_EXCEPTION) + } + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + // Going back to the first account clears the error + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.hasRoomListError).isFalse() + } + } + + @Test + fun `present - share mode with a single account does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)), + ), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.otherAccounts).isEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + @Test + fun `present - share mode with several accounts shows the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.selectedAccount.displayName).isEqualTo("Alice") + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.showAccountSwitch).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isFalse() + } + } + + @Test + fun `present - accounts with an invalid token are displayed, selecting one displays an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = false), + ), + ), + // The client of the invalid session must not be requested + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) Result.success(FakeMatrixClient(sessionId = sessionId)) else lambdaError() + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 && it.hasRoomListError }.last() + // The account information is still available + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + } + } + + @Test + fun `present - the selected account is invalidated, an error is displayed and the selected rooms are cleared`() = runTest { + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val otherAccountState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() + otherAccountState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary2.toSelectRoomInfo())) + assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1) + // The session of the selected account is invalidated, for instance signed out from another device + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + // Go back to the current account: the invalid account is still proposed + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.showAccountSwitch).isTrue() + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + } + } + + @Test + fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest { + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + testScheduler.advanceUntilIdle() + // The state may not change at all + val finalState = cancelAndConsumeRemainingEvents().filterIsInstance>().lastOrNull()?.value ?: state + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(finalState.hasRoomListError).isFalse() + assertThat(finalState.showAccountSwitch).isTrue() + } + } + + @Test + fun `present - select another account displays the rooms of this account`() = runTest { + val roomSummary1 = aRoomSummary(roomId = A_ROOM_ID) + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary1))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + // Wait for the rooms to be loaded, they are emitted on another dispatcher + val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat((state.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID) + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary1.toSelectRoomInfo())) + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val finalState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + assertThat(finalState.selectedRooms).isEmpty() + assertThat(finalState.isAccountListExpanded).isFalse() + assertThat((finalState.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID_2) + } + } + + @Test + fun `present - forward mode with several accounts does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Forward, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts).isNotEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + private fun aSessionStoreWithTwoAccounts() = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = true), + ), + ) + @Test fun `present - toggle search active`() = runTest { val presenter = createRoomSelectPresenter() @@ -157,16 +392,23 @@ internal fun TestScope.createRoomSelectPresenter( mode: RoomSelectMode = RoomSelectMode.Forward, maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, roomListService: RoomListService = FakeRoomListService(), + sessionStore: SessionStore = InMemorySessionStore(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService)) + }, ) = RoomSelectPresenter( + initialSessionId = A_SESSION_ID, mode = mode, maxNumberOfRooms = maxNumberOfRooms, dataSourceFactory = object : RoomSelectSearchDataSource.Factory { - override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource { + override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource { return RoomSelectSearchDataSource( coroutineScope = coroutineScope, roomListService = roomListService, coroutineDispatchers = testCoroutineDispatchers(), ) } - } + }, + matrixClientProvider = matrixClientProvider, + sessionStore = sessionStore, ) diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index db0219bb04..71284e6ec5 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -13,16 +13,27 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.junit.Test import org.robolectric.annotation.Config @@ -72,6 +83,90 @@ class RoomSelectViewTest : RobolectricTest() { onNodeWithText("Room with a single hero").assertIsDisplayed() onNodeWithText("@alice:example.org").assertDoesNotExist() } + + @Test + fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_switch_account) + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.ToggleAccountListExpanded)) + } + + @Test + fun `clicking on another account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + isAccountListExpanded = true, + eventSink = eventsRecorder, + ), + ) + onNodeWithText("Bob").performClick() + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.SelectAccount(UserId("@bob:example.org")))) + } + + @Test + fun `the account switch is not displayed if there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() + } + + @Test + fun `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + + @Test + fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + + @Test + fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + hasRoomListError = true, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_something_went_wrong)).assertIsDisplayed() + onNodeWithText(activity!!.getString(R.string.screen_room_select_error_cannot_load_rooms)).assertIsDisplayed() + onNodeWithText("Room with alias").assertDoesNotExist() + } } private fun AndroidComposeUiTest.setRoomSelectView( @@ -87,3 +182,10 @@ private fun AndroidComposeUiTest.setRoomSelectView( ) } } + +private fun aLongRoomList() = List(30) { index -> + aSelectRoomInfo( + roomId = RoomId("!room$index:domain"), + name = "Room $index", + ) +}.toImmutableList() diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 8b7d9d6e15..9d00237239 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.sessionStorage.api) diff --git a/libraries/session-storage/test/build.gradle.kts b/libraries/session-storage/test/build.gradle.kts index 7a89746812..35e40e1fed 100644 --- a/libraries/session-storage/test/build.gradle.kts +++ b/libraries/session-storage/test/build.gradle.kts @@ -15,6 +15,5 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) } diff --git a/libraries/troubleshoot/api/build.gradle.kts b/libraries/troubleshoot/api/build.gradle.kts index 177b2a5fce..5eda87d84f 100644 --- a/libraries/troubleshoot/api/build.gradle.kts +++ b/libraries/troubleshoot/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(libs.androidx.corektx) implementation(libs.coroutines.core) } diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 0555716c57..cad2548b2d 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -101,6 +101,7 @@ "Wybierz plik" "Wybierz zdjęcie" "Wyczyść" + "Wyczyść wszystko" "Zamknij" "Dokończ weryfikację" "Potwierdź" @@ -139,6 +140,11 @@ "Zakończ" "Nie pamiętasz hasła?" "Przekaż dalej" + + "Przekaż %1$d wiadomość" + "Przekaż %1$d wiadomości" + "Przekaż %1$d wiadomości" + "Wróć" "Przejdź do ról i uprawnień" "Przejdź do ustawień" @@ -245,6 +251,7 @@ "Rozpoczęto rozmowę" "Odrzuciłeś połączenie" "Backup czatu" + "Czaty" "Blok kodu" "Skopiowano do schowka" "Prawa autorskie" @@ -285,6 +292,7 @@ Powód: %1$s." "Plik zapisany" "Plik zapisany do folderu Pobrane" "Przekaż wiadomość" + "Przekaż do" "Często używane" "Galeria" @@ -299,6 +307,7 @@ Powód: %1$s." "GIF" "Trwa połączenie grupowe" + "Pokoje" "Zdjęcie" "W odpowiedzi do %1$s" "Zainstaluj APK" @@ -419,6 +428,7 @@ Powód: %1$s." "Udostępniona lokalizacja na żywo" "Udostępniona lokalizacja" "Udostępniona przestrzeń" + "Zalogowano" "Usuwanie urządzenia" "Coś poszło nie tak" "Napotkaliśmy problem. Spróbuj ponownie." @@ -579,6 +589,11 @@ Czy na pewno chcesz kontynuować?" "Zmniejsz" "Udostępnianie lokalizacji na żywo" "Zaznaczono maksymalną liczbę wiadomości" + + "Przekazano %1$d wiadomość" + "Przekazano %1$d wiadomości" + "Przekazano %1$d wiadomości" + "Już oglądasz ten pokój!" "%1$s z %2$s" "%1$s przypiętych wiadomości" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index c80403468f..7279b906c0 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -111,6 +111,7 @@ "Скопировать ссылку" "Скопировать ссылку на сообщение" "Копировать текст" + "Скопировать в буфер обмена" "Создать" "Создать комнату" "Создать пространство" @@ -224,6 +225,7 @@ "Доступно обновление" "О приложении" "Политика допустимого использования" + "Настройки аккаунта" "Добавить аккаунт" "Добавить другой аккаунт" "Добавление подписи" @@ -232,6 +234,7 @@ "Аналитика" "Вы покинули комнату" "Вы вышли из сессии" + "Настройки приложения" "Внешний вид" "Аудио" "Бета-версия" @@ -242,6 +245,7 @@ "Звонок начат" "Ты отклонил звонок" "Резервная копия чатов" + "Блок кода" "Скопировано в буфер обмена" "Авторское право" "Создание комнаты…" @@ -308,11 +312,13 @@ "Трансляция местоположения завершена" "Загрузка…" "Загрузить больше…" + "Обмен местоположением" "%d" "%d другие" "%d другие" + "Качество загружаемого медиа" "%1$d участник" "%1$d участников" @@ -323,6 +329,7 @@ "Ошибка отправки сообщения" "Оформление сообщения" "Сообщение удалено" + "Модерация и безопасность" "Современный" "Выкл. звук" "Имя" @@ -428,6 +435,7 @@ "Успешно" "Рекомендуемые" "Предложения" + "Сменить аккаунт" "Синхронизация" "Текст" "Уведомления о третьих лицах" @@ -467,6 +475,7 @@ "Ожидание трансляции местоположения…" "Любой может видеть историю" "Вы" + "Ваш аккаунт" "Загрузка недоступна." "Файл не найден" "Сканирование…" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index e791c4b5be..0bc43a3027 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -101,6 +101,7 @@ "Вибрати файл" "Вибрати фото" "Очистити" + "Очистити все" "Закрити" "Завершити звіряння" "Підтвердити" @@ -250,6 +251,7 @@ "Виклик розпочато" "Ви відхилили виклик" "Резервне копіювання бесіди" + "Бесіди" "Блок коду" "Скопійовано до буферу обміну" "Авторське право" @@ -290,6 +292,7 @@ "Файл збережено" "Файл збережений у Завантаження" "Переслати повідомлення" + "Переслати" "Частовживані" "Галерея" @@ -424,6 +427,7 @@ "Поділитися місцезнаходженням в реальному часі" "Поширене розташування" "Простір спільного користування" + "Вхід виконано" "Вилучення пристрою" "Щось пішло не так" "Ми зіткнулися з проблемою. Будь ласка, повторіть спробу." diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 36331e302b..74cc7bf9f7 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -95,6 +95,7 @@ "选择文件" "选择照片" "清除" + "全部清除" "关闭" "完成验证" "确认" @@ -242,6 +243,7 @@ "通话已开始" "你已拒接来电" "聊天记录备份" + "聊天" "代码块" "已复制到剪贴板" "版权" @@ -282,6 +284,7 @@ "文件已保存" "文件已保存到“下载”" "转发消息" + "转发到" "常用" "媒体组" @@ -292,6 +295,7 @@ "GIF" "群组通话进行中" + "房间" "图片" "回复 %1$s" "安装 APK" @@ -349,7 +353,7 @@ "正在准备…" "隐私政策" - "私密" + "私有" "私有房间" "私有空间" "公共" @@ -400,6 +404,7 @@ "已共享实时位置" "共享位置" "已共享的空间" + "已登录" "正在移除设备" "发生了一些错误" "我们遇到了一个问题。请重试。" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0079c8f47e..0eeb366d72 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -247,6 +247,7 @@ "Call started" "You declined a call" "Chat backup" + "Chats" "Code block" "Copied to clipboard" "Copyright" @@ -300,6 +301,7 @@ Reason: %1$s." "GIF" "Group call in progress" + "Rooms" "Image" "In reply to %1$s" "Install APK" diff --git a/libraries/usersearch/impl/build.gradle.kts b/libraries/usersearch/impl/build.gradle.kts index bd8eb47cd1..897f63da12 100644 --- a/libraries/usersearch/impl/build.gradle.kts +++ b/libraries/usersearch/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.matrixui) diff --git a/libraries/usersearch/test/build.gradle.kts b/libraries/usersearch/test/build.gradle.kts index 21cc140a7d..6899ba8c9c 100644 --- a/libraries/usersearch/test/build.gradle.kts +++ b/libraries/usersearch/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.libraries.usersearch" + namespace = "io.element.android.libraries.usersearch.test" } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrixui) implementation(projects.libraries.matrix.api) api(projects.libraries.usersearch.api) } diff --git a/libraries/voiceplayer/api/build.gradle.kts b/libraries/voiceplayer/api/build.gradle.kts index e058210b7d..604fd3f5a2 100644 --- a/libraries/voiceplayer/api/build.gradle.kts +++ b/libraries/voiceplayer/api/build.gradle.kts @@ -14,8 +14,6 @@ android { } dependencies { - implementation(libs.androidx.annotationjvm) - implementation(libs.coroutines.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) } diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts index 8fe79fb774..1e0040d98e 100644 --- a/libraries/voiceplayer/impl/build.gradle.kts +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -22,7 +22,6 @@ dependencies { api(projects.libraries.voiceplayer.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) @@ -31,7 +30,6 @@ dependencies { implementation(projects.libraries.uiUtils) implementation(projects.services.analytics.api) - implementation(libs.androidx.annotationjvm) implementation(libs.androidx.datastore.preferences) implementation(libs.coroutines.core) diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt index 070b341d76..62f91d3ccf 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -17,13 +17,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioLevelCalculator +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioReaderFactory +import io.element.android.libraries.voicerecorder.impl.audio.FakeEncoder import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderBindingContainer -import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory -import io.element.android.libraries.voicerecorder.test.FakeEncoder -import io.element.android.libraries.voicerecorder.test.FakeFileSystem -import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager +import io.element.android.libraries.voicerecorder.impl.file.FakeFileSystem +import io.element.android.libraries.voicerecorder.impl.file.FakeVoiceFileManager import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt similarity index 77% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt index 48e12c3192..583d36fdfd 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator import kotlin.math.abs class FakeAudioLevelCalculator : AudioLevelCalculator { diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt similarity index 85% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt index b14bb400e5..e946abecd8 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt @@ -6,11 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt similarity index 70% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt index d3e434538e..2b5a998a90 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader class FakeAudioReaderFactory( private val audio: List