diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf7f262cee..6c77cb3770 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-debug path: | @@ -61,7 +61,7 @@ jobs: app/build/outputs/apk/fdroid/debug/*-universal-debug.apk - name: Upload x86_64 APK for Maestro if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-apk-maestro path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 0d9b5949cc..bce9d923f3 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -61,7 +61,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 7481bec0ba..ff0ac49f5c 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -44,7 +44,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - name: Upload APK as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-apk-maestro path: | @@ -69,7 +69,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.ref }} - name: Download APK artifact from previous job - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: elementx-apk-maestro - name: Enable KVM group perms @@ -102,7 +102,7 @@ jobs: script: | .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: test-results path: | diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 537565743e..080cf99b05 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -42,7 +42,7 @@ jobs: - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: kover-results path: | @@ -74,7 +74,7 @@ jobs: run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7190fffe77..7600d8965c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -97,7 +97,7 @@ jobs: run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: konsist-report path: | @@ -174,7 +174,7 @@ jobs: run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: linting-report path: | @@ -214,7 +214,7 @@ jobs: run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: detekt-report path: | @@ -254,7 +254,7 @@ jobs: run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ktlint-report path: | @@ -317,7 +317,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - name: Download reports from previous jobs - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 - name: Prepare Danger if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cce85bd5a..29ff4a5373 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -74,7 +74,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-enterprise-app-gplay-bundle-unsigned path: | @@ -102,7 +102,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22c302cbb3..4965530b5a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,7 +61,7 @@ jobs: - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: kover-error-report path: | @@ -73,7 +73,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: tests-and-screenshot-tests-results path: | diff --git a/CHANGES.md b/CHANGES.md index 1ac0cd1124..28e774a8ff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,86 @@ +Changes in Element X v25.11.2 +============================= + + + +## What's Changed +### ✨ Features +* Enable access to security and privacy by @bmarty in https://github.com/element-hq/element-x-android/pull/5566 +* Add ability to forward a media from the media viewer and the gallery by @bmarty in https://github.com/element-hq/element-x-android/pull/5622 +* Split notifications for messages in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5595 +### 🙌 Improvements +* Enable `SyncNotificationsWithWorkManager` in nightly and debug builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5573 +* Confirm exit without saving change in room details edit screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5618 +* Space : add view members entry by @ganfra in https://github.com/element-hq/element-x-android/pull/5619 +* Update notification sound by @bmarty in https://github.com/element-hq/element-x-android/pull/5667 +* Use the new notification sound only on debug and nightly build by @bmarty in https://github.com/element-hq/element-x-android/pull/5673 +* Make sure we know the session verification state before showing the options to verify the session by @bmarty in https://github.com/element-hq/element-x-android/pull/5677 +### 🐛 Bugfixes +* Improve how brand color is applied. by @bmarty in https://github.com/element-hq/element-x-android/pull/5584 +* Improve wellknown retrieval API by @bmarty in https://github.com/element-hq/element-x-android/pull/5587 +* Clearing the room list search clears the search term too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5603 +* Delete pin code only when the last session is deleted by @bmarty in https://github.com/element-hq/element-x-android/pull/5600 +* Fix issues with WorkManager on Android 12 and below by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5606 +* Fix marking a room as read re-instantiates its timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5628 +* Display only valid emojis in recent emoji list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5612 +* Fix navigation issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/5666 +* Fix forward events from media viewer from pinned media timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/5669 +* Try fixing 'Timeline Event object has already been destroyed' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5675 +* Use the SDK Client to check whether a homeserver is compatible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5664 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5610 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5662 +### 🧱 Build +* Remove `@Inject`, not necessary anymore when class is annotated with `@ContributesBinding` by @bmarty in https://github.com/element-hq/element-x-android/pull/5589 +* Upgrade ktlint to 1.7.1 and ensure Renovate will upgrade the version by @bmarty in https://github.com/element-hq/element-x-android/pull/5638 +* Improve architecture around Nodes by @bmarty in https://github.com/element-hq/element-x-android/pull/5641 +* Move dependencies block out of the android block. by @bmarty in https://github.com/element-hq/element-x-android/pull/5674 +* Always use the handleEvent(s) function the same way. by @bmarty in https://github.com/element-hq/element-x-android/pull/5672 +### Dependency upgrades +* fix(deps): update metro to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5576 +* fix(deps): update dependencyanalysis to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5577 +* fix(deps): update dependency io.sentry:sentry-android to v8.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5586 +* fix(deps): update dependency androidx.work:work-runtime-ktx to v2.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5590 +* fix(deps): update dependency com.posthog:posthog-android to v3.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5594 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5572 +* Update plugin sonarqube to v7.0.1.6134 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5605 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5620 +* fix(deps): update dependencyanalysis to v3.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5602 +* fix(deps): update dependency com.github.matrix-org:matrix-analytics-events to v0.29.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5621 +* fix(deps): update dependencyanalysis to v3.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5624 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.29 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5625 +* fix(deps): update dependency io.sentry:sentry-android to v8.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5629 +* fix(deps): update dependencyanalysis to v3.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5642 +* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5644 +* chore(deps): update danger/danger-js action to v13.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5652 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5643 +* fix(deps): update firebaseappdistribution to v5.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5640 +* fix(deps): update metro to v0.7.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5663 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.31 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5657 +* Update GitHub Artifact Actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5609 +* Update dependency io.element.android:element-call-embedded to v0.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5598 +* Update roborazzi to v1.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5676 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5681 +* fix(deps): update metro to v0.7.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5683 +### Others +* Improve code around Element .well-known configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/5565 +* misc: display offline banner for all LoggedIn screens by @ganfra in https://github.com/element-hq/element-x-android/pull/5574 +* Remove icon preview duplicate by @bmarty in https://github.com/element-hq/element-x-android/pull/5588 +* Remove application navigation state usage in the push module by @bmarty in https://github.com/element-hq/element-x-android/pull/5596 +* Design : update Home TopBar and RoomList Filters by @ganfra in https://github.com/element-hq/element-x-android/pull/5599 +* Add missing tests on the analytic modules by @bmarty in https://github.com/element-hq/element-x-android/pull/5604 +* design(space): let SpaceRoomItemView divider be full width by @ganfra in https://github.com/element-hq/element-x-android/pull/5597 +* Update notification style by @bmarty in https://github.com/element-hq/element-x-android/pull/5607 +* Improve how data is handled for the WorkManager. by @bmarty in https://github.com/element-hq/element-x-android/pull/5592 +* Revert "Make sure declining a call stops observing the ringing call state" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5615 +* Misc : space flow inject room by @ganfra in https://github.com/element-hq/element-x-android/pull/5614 +* Enable `SyncNotificationsWithWorkManager` by default in release mode apps too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5646 +* Revert "Update notification sound" by @bmarty in https://github.com/element-hq/element-x-android/pull/5671 +* Introduce new query to count accounts by @bmarty in https://github.com/element-hq/element-x-android/pull/5678 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.0...v25.11.2 + Changes in Element X v25.11.0 ============================= diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt index e48dd52daf..c9dfd266d9 100644 --- a/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt +++ b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt @@ -9,13 +9,14 @@ package io.element.android.x.di import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.Provides +import io.element.android.appnav.di.TimelineBindings import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.JoinedRoom @GraphExtension(RoomScope::class) -interface RoomGraph : NodeFactoriesBindings { +interface RoomGraph : NodeFactoriesBindings, TimelineBindings { @GraphExtension.Factory interface Factory { fun create( 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 bdad3c2ab0..0332f34306 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -349,7 +349,7 @@ class RootFlowNode( } else { // wait for the current session to be restored val loggedInFlowNode = attachSession(latestSessionId) - if (sessionStore.getAllSessions().size > 1) { + if (sessionStore.numberOfSessions() > 1) { // Several accounts, let the user choose which one to use backstack.push( NavTarget.AccountSelect( @@ -379,7 +379,7 @@ class RootFlowNode( is PermalinkData.FallbackLink -> Unit is PermalinkData.RoomEmailInviteLink -> Unit else -> { - if (sessionStore.getAllSessions().size > 1) { + if (sessionStore.numberOfSessions() > 1) { // Several accounts, let the user choose which one to use backstack.push( NavTarget.AccountSelect( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt new file mode 100644 index 0000000000..e338f2ba8e --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.matrix.api.timeline.TimelineProvider + +interface TimelineBindings { + val timelineProvider: TimelineProvider + val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider +} 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 a41eb8d777..3189384f53 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 @@ -30,7 +30,6 @@ 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.roomdirectory.api.RoomDescription -import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs @@ -70,7 +69,6 @@ class RoomFlowNode( private val joinRoomEntryPoint: JoinRoomEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val membershipObserver: RoomMembershipObserver, - private val spaceEntryPoint: SpaceEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Loading, @@ -105,9 +103,6 @@ class RoomFlowNode( @Parcelize data class JoinedRoom(val roomId: RoomId) : NavTarget - - @Parcelize - data class JoinedSpace(val spaceId: RoomId) : NavTarget } override fun onBuilt() { @@ -209,15 +204,6 @@ class RoomFlowNode( ) createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } - is NavTarget.JoinedSpace -> { - val spaceCallback = plugins().single() - spaceEntryPoint.createNode( - parentNode = this, - buildContext = buildContext, - inputs = SpaceEntryPoint.Inputs(roomId = navTarget.spaceId), - callback = spaceCallback, - ) - } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 16eaff89b1..25c1f0664b 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -22,10 +22,10 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.appnav.di.TimelineBindings import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint -import io.element.android.features.messages.api.MessagesEntryPointNode import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.BackstackView @@ -47,8 +47,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -136,8 +134,8 @@ class JoinedRoomLoadedFlowNode( callback.handlePermalinkClick(data, pushToBackstack) } - override fun startForwardEventFlow(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId)) + override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents)) } } return roomDetailsEntryPoint.createNode( @@ -169,7 +167,11 @@ class JoinedRoomLoadedFlowNode( createSpaceNode(buildContext) } is NavTarget.ForwardEvent -> { - val timelineProvider = { MutableStateFlow(inputs.room.liveTimeline).asStateFlow() } + val timelineProvider = if (navTarget.fromPinnedEvents) { + (graph as TimelineBindings).pinnedEventsTimelineProvider + } else { + (graph as TimelineBindings).timelineProvider + } val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { @@ -195,10 +197,6 @@ class JoinedRoomLoadedFlowNode( callback.navigateToRoom(roomId, viaParameters) } - override fun navigateToRoomDetails() { - backstack.push(NavTarget.RoomDetails) - } - override fun navigateToRoomMemberList() { backstack.push(NavTarget.RoomMemberList) } @@ -228,8 +226,8 @@ class JoinedRoomLoadedFlowNode( callback.handlePermalinkClick(data, pushToBackstack) } - override fun forwardEvent(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId)) + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents)) } override fun navigateToRoom(roomId: RoomId) { @@ -266,7 +264,7 @@ class JoinedRoomLoadedFlowNode( data class RoomMemberDetails(val userId: UserId) : NavTarget @Parcelize - data class ForwardEvent(val eventId: EventId) : NavTarget + data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget @Parcelize data object RoomNotificationSettings : NavTarget @@ -276,7 +274,7 @@ class JoinedRoomLoadedFlowNode( val messageNode = waitForChildAttached { navTarget -> navTarget is NavTarget.Messages } - (messageNode as? MessagesEntryPointNode)?.attachThread(threadId, focusedEventId) + (messageNode as? MessagesEntryPoint.NodeProxy)?.attachThread(threadId, focusedEventId) } @Composable diff --git a/fastlane/metadata/android/en-US/changelogs/202511020.txt b/fastlane/metadata/android/en-US/changelogs/202511020.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202511020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index 9efe5ba75b..5c1b4ef0a6 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixuiTest) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.appnavstate.test) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 7604dbae18..a7302c9730 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -201,7 +201,7 @@ class CallScreenPresenter( userAgent = userAgent, isCallActive = isWidgetLoaded, isInWidgetMode = isInWidgetMode, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, ) } 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/notifications/RingingCallNotificationCreatorTest.kt index 0af482fc49..7ec5532e83 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/notifications/RingingCallNotificationCreatorTest.kt @@ -18,7 +18,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.push.test.notifications.FakeImageLoaderHolder +import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.test.runTest 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/utils/DefaultActiveCallManagerTest.kt index 3d1c35df4d..9464af603f 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/utils/DefaultActiveCallManagerTest.kt @@ -33,9 +33,9 @@ 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.test.media.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.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.services.appnavstate.test.FakeAppForegroundStateService @@ -415,6 +415,7 @@ class DefaultActiveCallManagerTest { verify { notificationManagerCompat.cancel(any()) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `IncomingCall - ignore expired ring lifetime`() = runTest { diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 39b7c320d9..72f7640615 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -24,11 +24,12 @@ class FakeEnterpriseService( private val defaultHomeserverListResult: () -> List = { emptyList() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, + initialBrandColor: Color? = null, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, ) : EnterpriseService { - private val brandColorState = MutableStateFlow(null) + private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask { diff --git a/features/forward/impl/build.gradle.kts b/features/forward/impl/build.gradle.kts index de38d25651..0364ffadd8 100644 --- a/features/forward/impl/build.gradle.kts +++ b/features/forward/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.uiStrings) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt index 63fbb0bc7c..b1e9534533 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.getActiveTimeline import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber @AssistedInject class ForwardMessagesPresenter( @@ -54,7 +55,7 @@ class ForwardMessagesPresenter( return ForwardMessagesState( forwardAction = forwardingActionState.value, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } @@ -63,7 +64,11 @@ class ForwardMessagesPresenter( roomIds: List, ) = launch { suspend { - timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow() + timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) + .onFailure { + Timber.e(it, "Error while forwarding event") + } + .getOrThrow() roomIds }.runCatchingUpdatingState(forwardingActionState) } diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt index 214c795851..3e092c70fa 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt @@ -8,11 +8,13 @@ package io.element.android.features.forward.impl import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ForwardMessagesView( @@ -24,6 +26,9 @@ fun ForwardMessagesView( onSuccess = { onForwardSuccess(it) }, + errorMessage = { + stringResource(id = CommonStrings.error_unknown) + }, onErrorDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt index eb3c1330b3..32419a0a59 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState @@ -27,8 +29,33 @@ class ChooseSelfVerificationModePresenter( @Composable override fun present(): ChooseSelfVerificationModeState { val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() - val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() - val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } } + val canEnterRecoveryKey by encryptionService.recoveryStateStateFlow + .mapState { recoveryState -> + when (recoveryState) { + RecoveryState.WAITING_FOR_SYNC, + RecoveryState.UNKNOWN -> AsyncData.Loading() + RecoveryState.INCOMPLETE -> AsyncData.Success(true) + RecoveryState.ENABLED, + RecoveryState.DISABLED -> AsyncData.Success(false) + } + } + .collectAsState() + val buttonsState by remember { + derivedStateOf { + val canUseAnotherDevice = hasDevicesToVerifyAgainst.dataOrNull() + val canEnterRecoveryKey = canEnterRecoveryKey.dataOrNull() + if (canUseAnotherDevice == null || canEnterRecoveryKey == null) { + AsyncData.Loading() + } else { + AsyncData.Success( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = canUseAnotherDevice, + canEnterRecoveryKey = canEnterRecoveryKey, + ) + ) + } + } + } val directLogoutState = directLogoutPresenter.present() @@ -39,8 +66,7 @@ class ChooseSelfVerificationModePresenter( } return ChooseSelfVerificationModeState( - canUseAnotherDevice = hasDevicesToVerifyAgainst, - canEnterRecoveryKey = canEnterRecoveryKey, + buttonsState = buttonsState, directLogoutState = directLogoutState, eventSink = ::eventHandler, ) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt index 117768a6d2..5cc03352f0 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt @@ -8,10 +8,15 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.architecture.AsyncData data class ChooseSelfVerificationModeState( - val canUseAnotherDevice: Boolean, - val canEnterRecoveryKey: Boolean, + val buttonsState: AsyncData, val directLogoutState: DirectLogoutState, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, -) +) { + data class ButtonsState( + val canUseAnotherDevice: Boolean, + val canEnterRecoveryKey: Boolean, + ) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt index e053728e2c..fa480706fd 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt @@ -9,23 +9,49 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.architecture.AsyncData class ChooseSelfVerificationModeStateProvider : PreviewParameterProvider { override val values = sequenceOf( - aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = true), - aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = false), - aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = true), - aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = false), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = true), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = false), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = true), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = false), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Loading(), + ), ) } fun aChooseSelfVerificationModeState( - canUseAnotherDevice: Boolean = true, - canEnterRecoveryKey: Boolean = true, + buttonsState: AsyncData = AsyncData.Success(aButtonsState()), ) = ChooseSelfVerificationModeState( - canUseAnotherDevice = canUseAnotherDevice, - canEnterRecoveryKey = canEnterRecoveryKey, + buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), eventSink = {}, ) + +fun aButtonsState( + canUseAnotherDevice: Boolean = true, + canEnterRecoveryKey: Boolean = true, +) = ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = canUseAnotherDevice, + canEnterRecoveryKey = canEnterRecoveryKey, +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index b07c04ac9c..6907414863 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -23,6 +23,7 @@ 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.ftue.impl.R +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage @@ -50,7 +51,6 @@ fun ChooseSelfVerificationModeView( BackHandler { activity?.finish() } - HeaderFooterPage( modifier = modifier, topBar = { @@ -73,29 +73,12 @@ fun ChooseSelfVerificationModeView( ) }, footer = { - ButtonColumnMolecule( - modifier = Modifier.padding(bottom = 16.dp) - ) { - if (state.canUseAnotherDevice) { - Button( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_identity_use_another_device), - onClick = onUseAnotherDevice, - ) - } - if (state.canEnterRecoveryKey) { - Button( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_session_verification_enter_recovery_key), - onClick = onUseRecoveryKey, - ) - } - OutlinedButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_identity_confirmation_cannot_confirm), - onClick = onResetKey, - ) - } + ChooseSelfVerificationModeButtons( + state = state, + onUseAnotherDevice = onUseAnotherDevice, + onUseRecoveryKey = onUseRecoveryKey, + onResetKey = onResetKey, + ) } ) { Row( @@ -113,6 +96,53 @@ fun ChooseSelfVerificationModeView( } } +@Composable +private fun ChooseSelfVerificationModeButtons( + state: ChooseSelfVerificationModeState, + onUseAnotherDevice: () -> Unit, + onUseRecoveryKey: () -> Unit, + onResetKey: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 16.dp) + ) { + when (state.buttonsState) { + AsyncData.Uninitialized, + is AsyncData.Failure, + is AsyncData.Loading -> { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = false, + showProgress = true, + text = stringResource(CommonStrings.common_loading), + onClick = {}, + ) + } + is AsyncData.Success -> { + if (state.buttonsState.data.canUseAnotherDevice) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_identity_use_another_device), + onClick = onUseAnotherDevice, + ) + } + if (state.buttonsState.data.canEnterRecoveryKey) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_enter_recovery_key), + onClick = onUseRecoveryKey, + ) + } + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_identity_confirmation_cannot_confirm), + onClick = onResetKey, + ) + } + } + } +} + @PreviewsDayNight @Composable internal fun ChooseSelfVerificationModeViewPreview( diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt index 3dbf5a6932..2caead346a 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt @@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService @@ -22,23 +23,92 @@ import org.junit.Test class ChooseSessionVerificationModePresenterTest { @Test - fun `initial state - is relayed from EncryptionService`() = runTest { - val encryptionService = FakeEncryptionService().apply { - // Has device to verify against - emitHasDevicesToVerifyAgainst(false) - // Can enter recovery key - emitRecoveryState(RecoveryState.INCOMPLETE) - } - val presenter = createPresenter(encryptionService = encryptionService) + fun `present - initial state`() = runTest { + val presenter = createPresenter() presenter.test { awaitItem().run { - assertThat(canUseAnotherDevice).isFalse() - assertThat(canEnterRecoveryKey).isTrue() + assertThat(buttonsState.isLoading()).isTrue() assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue() } } } + @Test + fun `present - state is relayed from EncryptionService, order 1`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - state is relayed from EncryptionService, order 2`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - can use another device`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(true)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = true, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - can enter recovery key`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = true, + ) + ) + } + } + @Test fun `sing out action triggers a direct logout`() = runTest { val logoutEventRecorder = lambdaRecorder {} @@ -49,8 +119,8 @@ class ChooseSessionVerificationModePresenterTest { presenter.test { val initial = awaitItem() initial.eventSink(ChooseSelfVerificationModeEvent.SignOut) - - logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false))) + logoutEventRecorder.assertions().isCalledOnce() + .with(value(DirectLogoutEvents.Logout(ignoreSdkError = false))) } } diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt index ed7d99dd19..3112a7af59 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.features.ftue.impl.R +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.clickOn @@ -43,7 +44,7 @@ class ChooseSessionVerificationModeViewTest { fun `clicking on use another device calls the callback`() { ensureCalledOnce { callback -> rule.setChooseSelfVerificationModeView( - aChooseSelfVerificationModeState(canUseAnotherDevice = true), + aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))), onUseAnotherDevice = callback, ) rule.clickOn(R.string.screen_identity_use_another_device) @@ -55,7 +56,7 @@ class ChooseSessionVerificationModeViewTest { fun `clicking on enter recovery key calls the callback`() { ensureCalledOnce { callback -> rule.setChooseSelfVerificationModeView( - aChooseSelfVerificationModeState(canEnterRecoveryKey = true), + aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canEnterRecoveryKey = true))), onEnterRecoveryKey = callback, ) rule.clickOn(R.string.screen_session_verification_enter_recovery_key) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt index 0fcef6bedf..2d9c2d2e9c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt @@ -13,5 +13,4 @@ data class AccountProvider( val subtitle: String? = null, val isPublic: Boolean = false, val isMatrixOrg: Boolean = false, - val isValid: Boolean = false, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt index a5f0fd7d3b..886f0efb82 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -15,7 +15,7 @@ open class AccountProviderProvider : PreviewParameterProvider { get() = sequenceOf( anAccountProvider(), anAccountProvider().copy(subtitle = null), - anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false), + anAccountProvider().copy(subtitle = null, title = "invalid"), anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false), // Add other state here ) @@ -26,11 +26,9 @@ fun anAccountProvider( subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.", isPublic: Boolean = true, isMatrixOrg: Boolean = true, - isValid: Boolean = true, ) = AccountProvider( url = url, subtitle = subtitle, isPublic = isPublic, isMatrixOrg = isMatrixOrg, - isValid = isValid, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt index 0b4b088938..1e219a8a3b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt @@ -10,6 +10,4 @@ package io.element.android.features.login.impl.resolver data class HomeserverData( // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url val homeserverUrl: String, - // True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid - val isWellknownValid: Boolean, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt index 7b6f3e4102..c43839517c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt @@ -8,19 +8,16 @@ package io.element.android.features.login.impl.resolver import dev.zacsweers.metro.Inject -import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.parallelMap -import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.isValidUrl -import io.element.android.libraries.wellknown.api.WellKnown -import io.element.android.libraries.wellknown.api.WellknownRetriever +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import timber.log.Timber import java.util.Collections /** @@ -29,7 +26,7 @@ import java.util.Collections @Inject class HomeserverResolver( private val dispatchers: CoroutineDispatchers, - private val wellknownRetriever: WellknownRetriever, + private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, ) { fun resolve(userInput: String): Flow> = flow { val flowContext = currentCoroutineContext() @@ -41,20 +38,14 @@ class HomeserverResolver( // Run all the requests in parallel withContext(dispatchers.io) { list.parallelMap { url -> - val wellKnown = tryOrNull { - withTimeout(5000) { - wellknownRetriever.getWellKnown(url) - } - } - val isValid = wellKnown?.dataOrNull()?.isValid().orFalse() + val isValid = homeServerLoginCompatibilityChecker.check(url) + .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") } + .getOrNull() + ?: return@parallelMap + + // Emit the list as soon as possible if (isValid) { - // Emit the list as soon as possible - currentList.add( - HomeserverData( - homeserverUrl = url, - isWellknownValid = true, - ) - ) + currentList.add(HomeserverData(homeserverUrl = url)) withContext(flowContext) { emit(currentList.toList()) } @@ -63,14 +54,7 @@ class HomeserverResolver( } // If list is empty, and the user has entered an URL, do not block the user. if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) { - emit( - listOf( - HomeserverData( - homeserverUrl = trimmedUserInput, - isWellknownValid = false, - ) - ) - ) + emit(listOf(HomeserverData(homeserverUrl = trimmedUserInput))) } } @@ -88,7 +72,3 @@ class HomeserverResolver( } } } - -private fun WellKnown.isValid(): Boolean { - return homeServer?.baseURL?.isNotBlank().orFalse() -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt index 8e6a3ef0ba..940889728e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -37,7 +37,6 @@ class ChangeAccountProviderPresenter( subtitle = null, isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - isValid = true, ) } .toImmutableList() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index 73f03ba7c8..0c915959cb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -67,7 +67,6 @@ class ChooseAccountProviderPresenter( subtitle = null, isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - isValid = true, ) } .toImmutableList() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index e7e20aa70d..44d8095fa3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -90,7 +90,7 @@ class OnBoardingPresenter( } val isAddingAccount by produceState(initialValue = false) { // We are adding an account if there is at least one session already stored - value = sessionStore.getAllSessions().isNotEmpty() + value = sessionStore.numberOfSessions() > 0 } val loginMode by loginHelper.collectLoginMode() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt index 0aa06ca632..657a21111c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt @@ -57,14 +57,14 @@ class SearchAccountProviderPresenter( userInput = userInput, userInputResult = data.value, changeServerState = changeServerState, - eventSink = ::handleEvents + eventSink = ::handleEvents, ) } private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { data.value = AsyncData.Uninitialized // Debounce - delay(300) + delay(500) data.value = AsyncData.Loading() homeserverResolver.resolve(userInput).collect { data.value = AsyncData.Success(it) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt index fb0e0f5c5a..3dd7a3d8c5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -34,18 +34,14 @@ fun aSearchAccountProviderState( fun aHomeserverDataList(): List { return listOf( - aHomeserverData(isWellknownValid = true), - aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true), - aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false), + aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL), + aHomeserverData(homeserverUrl = "https://no.sliding.sync"), + aHomeserverData(homeserverUrl = "https://invalid"), ) } fun aHomeserverData( homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, - isWellknownValid: Boolean = true, ): HomeserverData { - return HomeserverData( - homeserverUrl = homeserverUrl, - isWellknownValid = isWellknownValid, - ) + return HomeserverData(homeserverUrl = homeserverUrl,) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 13bfd9e38e..2b289aa4c1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -192,7 +192,6 @@ private fun HomeserverData.toAccountProvider(): AccountProvider { // There is no need to know for other servers right now isPublic = isMatrixOrg, isMatrixOrg = isMatrixOrg, - isValid = isWellknownValid, ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index 3d84a1da41..8a8f6864cf 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -33,7 +33,6 @@ class AccountProviderDataSourceTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = false, ) ) } @@ -55,7 +54,6 @@ class AccountProviderDataSourceTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = false, ) ) } @@ -77,7 +75,6 @@ class AccountProviderDataSourceTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = false, ) ) } @@ -98,7 +95,6 @@ class AccountProviderDataSourceTest { subtitle = null, isPublic = false, isMatrixOrg = false, - isValid = false, ) ) sut.reset() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt index 89abc6ddef..f2e933390b 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -46,7 +46,6 @@ class ChangeAccountProviderPresenterTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = true, ) ) ) @@ -76,7 +75,6 @@ class ChangeAccountProviderPresenterTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = true, ), AccountProvider( url = "https://element.io", @@ -84,7 +82,6 @@ class ChangeAccountProviderPresenterTest { subtitle = null, isPublic = false, isMatrixOrg = false, - isValid = true, ) ) ) @@ -114,7 +111,6 @@ class ChangeAccountProviderPresenterTest { subtitle = null, isPublic = true, isMatrixOrg = true, - isValid = true, ) ) ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt index 5bad8a3638..2e13b0e555 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt @@ -37,14 +37,12 @@ class ChooseAccountProviderPresenterTest { subtitle = null, isPublic = false, isMatrixOrg = false, - isValid = true, ) val accountProvider2 = AccountProvider( url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(), subtitle = null, isPublic = false, isMatrixOrg = false, - isValid = true, ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt index 79cd3c954a..67453119c7 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -13,12 +13,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.features.wellknown.test.FakeWellknownRetriever import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.wellknown.api.WellKnown -import io.element.android.libraries.wellknown.api.WellKnownBaseConfig -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -33,9 +29,9 @@ class SearchAccountProviderPresenterTest { @Test fun `present - initial state`() = runTest { - val fakeWellknownRetriever = FakeWellknownRetriever() + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) }) val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), changeServerPresenter = { aChangeServerState() } ) moleculeFlow(RecompositionMode.Immediate) { @@ -47,9 +43,35 @@ class SearchAccountProviderPresenterTest { } } + @Test + fun `present - error while checking login compatibility`() = runTest { + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) }) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), + changeServerPresenter = { aChangeServerState() } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("https://test.org") + assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org") + ) + ) + ) + } + } + @Test fun `present - enter text no result`() = runTest { - val fakeWellknownRetriever = FakeWellknownRetriever() + val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) }) val presenter = SearchAccountProviderPresenter( homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), changeServerPresenter = { aChangeServerState() } @@ -67,48 +89,20 @@ class SearchAccountProviderPresenterTest { } } - @Test - fun `present - enter valid url no wellknown`() = runTest { - val fakeWellknownRetriever = FakeWellknownRetriever() - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), - changeServerPresenter = { aChangeServerState() } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("https://test.org") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = false) - ) - ) - ) - } - } - @Test fun `present - enter text one result with wellknown`() = runTest { - val getWellKnownResult = lambdaRecorder> { + val checkResult = lambdaRecorder> { when (it) { - "https://test.org" -> WellknownRetrieverResult.NotFound - "https://test.com" -> WellknownRetrieverResult.NotFound - "https://test.io" -> WellknownRetrieverResult.Success(aWellKnown()) - "https://test" -> WellknownRetrieverResult.NotFound + "https://test.org" -> Result.success(false) + "https://test.com" -> Result.success(false) + "https://test.io" -> Result.success(true) + "https://test" -> Result.success(false) else -> error("should not happen") } } - val fakeWellknownRetriever = FakeWellknownRetriever( - getWellKnownResult = getWellKnownResult, - ) + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), changeServerPresenter = { aChangeServerState() } ) moleculeFlow(RecompositionMode.Immediate) { @@ -127,7 +121,7 @@ class SearchAccountProviderPresenterTest { ) ) ) - getWellKnownResult.assertions().isCalledExactly(4) + checkResult.assertions().isCalledExactly(4) .withSequence( listOf(value("https://test.org")), listOf(value("https://test.com")), @@ -139,20 +133,18 @@ class SearchAccountProviderPresenterTest { @Test fun `present - enter text two results with wellknown`() = runTest { - val getWellKnownResult = lambdaRecorder> { + val checkResult = lambdaRecorder> { when (it) { - "https://test.org" -> WellknownRetrieverResult.Success(aWellKnown()) - "https://test.com" -> WellknownRetrieverResult.NotFound - "https://test.io" -> WellknownRetrieverResult.Success(aWellKnown()) - "https://test" -> WellknownRetrieverResult.NotFound + "https://test.org" -> Result.success(true) + "https://test.com" -> Result.success(false) + "https://test.io" -> Result.success(true) + "https://test" -> Result.success(false) else -> error("should not happen") } } - val fakeWellknownRetriever = FakeWellknownRetriever( - getWellKnownResult = getWellKnownResult, - ) + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), changeServerPresenter = { aChangeServerState() } ) moleculeFlow(RecompositionMode.Immediate) { @@ -179,7 +171,7 @@ class SearchAccountProviderPresenterTest { ) ) ) - getWellKnownResult.assertions().isCalledExactly(4) + checkResult.assertions().isCalledExactly(4) .withSequence( listOf(value("https://test.org")), listOf(value("https://test.com")), @@ -188,15 +180,4 @@ class SearchAccountProviderPresenterTest { ) } } - - private fun aWellKnown(): WellKnown { - return WellKnown( - homeServer = WellKnownBaseConfig( - baseURL = A_HOMESERVER_URL - ), - identityServer = WellKnownBaseConfig( - baseURL = A_HOMESERVER_URL - ), - ) - } } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index 54df2b7fec..99f9ed79a0 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -35,7 +35,7 @@ interface MessagesEntryPoint : FeatureEntryPoint { fun navigateToRoomDetails() fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) - fun forwardEvent(eventId: EventId) + fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) fun navigateToRoom(roomId: RoomId) } @@ -47,8 +47,8 @@ interface MessagesEntryPoint : FeatureEntryPoint { params: Params, callback: Callback, ): Node -} -interface MessagesEntryPointNode { - suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) + interface NodeProxy { + suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) + } } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt new file mode 100644 index 0000000000..026486e00a --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.pinned + +import io.element.android.libraries.matrix.api.timeline.TimelineProvider + +interface PinnedEventsTimelineProvider : TimelineProvider diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 7d468354d8..d51ebd250e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -32,10 +32,9 @@ import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint -import io.element.android.features.messages.api.MessagesEntryPointNode import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode -import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.threads.ThreadedMessagesNode @@ -115,7 +114,7 @@ class MessagesFlowNode( private val roomNamesCache: RoomNamesCache, private val mentionSpanUpdater: MentionSpanUpdater, private val mentionSpanTheme: MentionSpanTheme, - private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, + private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider, private val timelineController: TimelineController, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val dateFormatter: DateFormatter, @@ -130,8 +129,7 @@ class MessagesFlowNode( ), buildContext = buildContext, plugins = plugins, -), - MessagesEntryPointNode { +), MessagesEntryPoint.NodeProxy { sealed interface NavTarget : Parcelable { @Parcelize data class Messages(val focusedEventId: EventId?) : NavTarget @@ -315,9 +313,9 @@ class MessagesFlowNode( this@MessagesFlowNode.viewInTimeline(eventId) } - override fun forwardEvent(eventId: EventId) { + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { // Need to go to the parent because of the overlay - callback.forwardEvent(eventId) + callback.forwardEvent(eventId, fromPinnedEvents) } } mediaViewerEntryPoint.createNode( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 192ff30f39..370b3853ea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -122,7 +122,7 @@ class DefaultActionListPresenter( return ActionListState( target = target.value, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt index b62b4116e6..c0121b98b0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -172,7 +172,7 @@ class DefaultMediaOptimizationSelectorPresenter( selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(), displayMediaSelectorViews = displayMediaSelectorViews, displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, - eventSink = { handleEvent(it) }, + eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 612797261a..f7dd029826 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -382,7 +382,7 @@ class MessageComposerPresenter( suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt similarity index 93% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt index 811516e022..f4641c8a66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/PinnedEventsTimelineProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt @@ -7,8 +7,9 @@ package io.element.android.features.messages.impl.pinned -import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.mapState @@ -17,7 +18,6 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -29,12 +29,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext @SingleIn(RoomScope::class) -@Inject -class PinnedEventsTimelineProvider( +@ContributesBinding(RoomScope::class) +class DefaultPinnedEventsTimelineProvider( private val room: JoinedRoom, private val syncService: SyncService, private val dispatchers: CoroutineDispatchers, -) : TimelineProvider { +) : PinnedEventsTimelineProvider { private val _timelineStateFlow: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt index 5833da56dc..c0f2cccb6f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.BaseRoom @@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.onEach class PinnedMessagesBannerPresenter( private val room: BaseRoom, private val itemFactory: PinnedMessagesBannerItemFactory, - private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, + private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider, ) : Presenter { private val pinnedItems = mutableStateOf>>(AsyncData.Uninitialized) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 6d09e12447..764286ce2e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -26,7 +26,7 @@ import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.link.LinkState -import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig @@ -66,7 +66,7 @@ class PinnedMessagesListPresenter( @Assisted private val navigator: PinnedMessagesListNavigator, private val room: JoinedRoom, timelineItemsFactoryCreator: TimelineItemsFactory.Creator, - private val timelineProvider: PinnedEventsTimelineProvider, + private val timelineProvider: DefaultPinnedEventsTimelineProvider, private val timelineProtectionPresenter: Presenter, private val linkPresenter: Presenter, private val snackbarDispatcher: SnackbarDispatcher, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 8602de0532..6f9dab56c8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -289,7 +289,7 @@ class TimelinePresenter( messageShield = messageShield.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, displayThreadSummaries = displayThreadSummaries, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt index 3d1cc93d58..bbddbb1066 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -71,7 +71,7 @@ class CustomReactionPresenter( target = target.value, selectedEmoji = selectedEmoji, recentEmojis = recentEmojis, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt index 4354ef5b25..c3fb43d374 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt @@ -48,7 +48,7 @@ class ReactionSummaryPresenter( } return ReactionSummaryState( target = targetWithAvatars.value, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt index 33316a134a..a520f1fee1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt @@ -36,7 +36,7 @@ class ReadReceiptBottomSheetPresenter : Presenter { return ReadReceiptBottomSheetState( selectedEvent = selectedEvent, - eventSink = { handleEvent(it) }, + eventSink = ::handleEvent, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index 0d9db51d98..ded49a3804 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -56,7 +56,7 @@ class TimelineProtectionPresenter( return TimelineProtectionState( protectionState = protectionState, - eventSink = { event -> handleEvent(event) } + eventSink = ::handleEvent, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt index 880f89f3bc..e9502346b4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -90,7 +90,7 @@ class DefaultMessagesEntryPointTest { override fun navigateToRoomDetails() = lambdaError() override fun navigateToRoomMemberDetails(userId: UserId) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() - override fun forwardEvent(eventId: EventId) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun navigateToRoom(roomId: RoomId) = lambdaError() } val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt index 38182dec1d..b1be33d928 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt @@ -8,7 +8,7 @@ package io.element.android.features.messages.impl.pinned.banner import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService @@ -195,7 +195,7 @@ class PinnedMessagesBannerPresenterTest { internal fun TestScope.createPinnedEventsTimelineProvider( room: JoinedRoom = FakeJoinedRoom(), syncService: SyncService = FakeSyncService(), -) = PinnedEventsTimelineProvider( +) = DefaultPinnedEventsTimelineProvider( room = room, syncService = syncService, dispatchers = testCoroutineDispatchers(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 07778ab381..5087bd3558 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -13,7 +13,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator import io.element.android.features.messages.impl.link.aLinkState -import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -300,7 +300,7 @@ class PinnedMessagesListPresenterTest { analyticsService: AnalyticsService = FakeAnalyticsService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ): PinnedMessagesListPresenter { - val timelineProvider = PinnedEventsTimelineProvider( + val timelineProvider = DefaultPinnedEventsTimelineProvider( room = room, syncService = syncService, dispatchers = testCoroutineDispatchers(), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index c894aae05d..3aa9c61555 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -143,7 +143,7 @@ class EditUserProfilePresenter( saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, saveAction = saveAction.value, cameraPermissionState = cameraPermissionState, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, ) } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 89bec00ac5..7f173a8fe1 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -165,7 +165,7 @@ class DefaultBugReporter( } } val sessionData = sessionStore.getLatestSession() - val numberOfAccounts = sessionStore.getAllSessions().size + val numberOfAccounts = sessionStore.numberOfSessions() val deviceId = sessionData?.deviceId ?: "undefined" val userId = sessionData?.userId?.let { UserId(it) } // build the multi part request diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt index a6d4eeefd4..02966c79a3 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt @@ -124,7 +124,7 @@ class ChangeRoomPermissionsPresenter( hasChanges = hasChanges, saveAction = saveAction, confirmExitAction = confirmExitAction, - eventSink = { handleEvent(it) } + eventSink = ::handleEvent, ) } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt index 4f8bf2d201..d7a39c7b2e 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt @@ -100,7 +100,7 @@ class RolesAndPermissionsPresenter( canDemoteSelf = canDemoteSelf.value, changeOwnRoleAction = changeOwnRoleAction.value, resetPermissionsAction = resetPermissionsAction.value, - eventSink = { handleEvent(it) }, + eventSink = ::handleEvent, ) } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 3a690ddea6..96ff1ae96e 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -40,7 +40,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { fun navigateToGlobalNotificationSettings() fun navigateToRoom(roomId: RoomId, serverNames: List) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) - fun startForwardEventFlow(eventId: EventId) + fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) } fun createNode( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 39c555be20..35f50a4b1e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -302,7 +302,7 @@ class RoomDetailsFlowNode( // Cannot happen } - override fun forwardEvent(eventId: EventId) { + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { // Cannot happen } } @@ -334,8 +334,8 @@ class RoomDetailsFlowNode( callback.handlePermalinkClick(permalinkData, pushToBackstack = false) } - override fun forward(eventId: EventId) { - callback.startForwardEventFlow(eventId) + override fun forward(eventId: EventId, fromPinnedEvents: Boolean) { + callback.startForwardEventFlow(eventId, fromPinnedEvents) } } mediaGalleryEntryPoint.createNode( @@ -361,8 +361,8 @@ class RoomDetailsFlowNode( callback.handlePermalinkClick(data, pushToBackstack) } - override fun forwardEvent(eventId: EventId) { - callback.startForwardEventFlow(eventId) + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + callback.startForwardEventFlow(eventId, fromPinnedEvents) } override fun navigateToRoom(roomId: RoomId) { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 69efd04b9e..90619d2e2d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -179,7 +179,7 @@ class RoomMemberListPresenter( isSearchActive = isSearchActive, canInvite = canInvite, moderationState = roomModerationState, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt index 413e71169a..7bfdf9949c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt @@ -135,7 +135,7 @@ class RoomNotificationSettingsPresenter( setNotificationSettingAction = setNotificationSettingAction.value, restoreDefaultAction = restoreDefaultAction.value, displayMentionsOnlyDisclaimer = shouldDisplayMentionsOnlyDisclaimer, - eventSink = { handleEvents(it) }, + eventSink = ::handleEvents, ) } 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 ebe92332e8..f9bc40b711 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 @@ -64,7 +64,7 @@ class DefaultRoomDetailsEntryPointTest { override fun navigateToGlobalNotificationSettings() = lambdaError() override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() - override fun startForwardEventFlow(eventId: EventId) = lambdaError() + override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() } val params = RoomDetailsEntryPoint.Params( initialElement = RoomDetailsEntryPoint.InitialTarget.RoomDetails, diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt index 2ddb3ad34a..995baa069b 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -140,7 +140,7 @@ class RoomMemberModerationPresenter( kickUserAsyncAction = kickUserAsyncAction.value, banUserAsyncAction = banUserAsyncAction.value, unbanUserAsyncAction = unbanUserAsyncAction.value, - eventSink = { handleEvent(it) }, + eventSink = ::handleEvent, ) } 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 d3222edf5e..f5ce67498e 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 @@ -63,7 +63,7 @@ class SharePresenter( return ShareState( shareAction = shareActionState.value, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt index e53c0af112..d5a9a910a0 100644 --- a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -58,9 +58,11 @@ class SignedOutPresenterTest { val initialState = awaitItem() assertThat(initialState.signedOutSession).isEqualTo(aSessionData) assertThat(sessionStore.getAllSessions()).isNotEmpty() + assertThat(sessionStore.numberOfSessions()).isEqualTo(1) initialState.eventSink(SignedOutEvents.SignInAgain) assertThat(awaitItem().signedOutSession).isNull() assertThat(sessionStore.getAllSessions()).isEmpty() + assertThat(sessionStore.numberOfSessions()).isEqualTo(0) } } } diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt index 6b5bd7f892..e05a7d1e8b 100644 --- a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -28,7 +28,6 @@ interface SpaceEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToRoom(roomId: RoomId, viaParameters: List) - fun navigateToRoomDetails() fun navigateToRoomMemberList() } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index 1ef496d319..686729bae5 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -18,6 +18,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject @@ -26,14 +27,15 @@ import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.leave.LeaveSpaceNode import io.element.android.features.space.impl.root.SpaceNode +import io.element.android.features.space.impl.settings.SpaceSettingsNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.callback 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.di.RoomScope import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.spaces.SpaceService import kotlinx.parcelize.Parcelize @@ -42,6 +44,7 @@ import kotlinx.parcelize.Parcelize class SpaceFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, + room: JoinedRoom, spaceService: SpaceService, graphFactory: SpaceFlowGraph.Factory, ) : BaseFlowNode( @@ -52,15 +55,17 @@ class SpaceFlowNode( buildContext = buildContext, plugins = plugins, ), DependencyInjectionGraphOwner { - private val inputs: SpaceEntryPoint.Inputs = inputs() private val callback: SpaceEntryPoint.Callback = callback() - private val spaceRoomList = spaceService.spaceRoomList(inputs.roomId) + private val spaceRoomList = spaceService.spaceRoomList(room.roomId) override val graph = graphFactory.create(spaceRoomList) sealed interface NavTarget : Parcelable { @Parcelize data object Root : NavTarget + @Parcelize + data object Settings : NavTarget + @Parcelize data object Leave : NavTarget } @@ -77,7 +82,16 @@ class SpaceFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Leave -> { - createNode(buildContext, listOf(inputs)) + val callback = object : LeaveSpaceNode.Callback { + override fun closeLeaveSpaceFlow() { + backstack.pop() + } + + override fun navigateToRolesAndPermissions() { + // TODO + } + } + createNode(buildContext, listOf(callback)) } NavTarget.Root -> { val callback = object : SpaceNode.Callback { @@ -85,8 +99,8 @@ class SpaceFlowNode( callback.navigateToRoom(roomId, viaParameters) } - override fun navigateToRoomDetails() { - callback.navigateToRoomDetails() + override fun navigateToSpaceSettings() { + backstack.push(NavTarget.Settings) } override fun navigateToRoomMemberList() { @@ -97,7 +111,35 @@ class SpaceFlowNode( backstack.push(NavTarget.Leave) } } - createNode(buildContext, listOf(inputs, callback)) + createNode(buildContext, listOf(callback)) + } + NavTarget.Settings -> { + val callback = object : SpaceSettingsNode.Callback { + override fun closeSettings() { + backstack.pop() + } + + override fun navigateToSpaceInfo() { + // TODO + } + + override fun navigateToSpaceMembers() { + callback.navigateToRoomMemberList() + } + + override fun navigateToRolesAndPermissions() { + // TODO + } + + override fun navigateToSecurityAndPrivacy() { + // TODO + } + + override fun startLeaveSpaceFlow() { + backstack.push(NavTarget.Leave) + } + } + createNode(buildContext, listOf(callback)) } } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt index c60bddea1d..6eaa5d4e4a 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt @@ -16,10 +16,10 @@ 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.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowScope -import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.architecture.callback import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.JoinedRoom @ContributesNode(SpaceFlowScope::class) @AssistedInject @@ -27,12 +27,19 @@ class LeaveSpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, matrixClient: MatrixClient, + room: JoinedRoom, presenterFactory: LeaveSpacePresenter.Factory, ) : Node(buildContext, plugins = plugins) { - private val inputs: SpaceEntryPoint.Inputs = inputs() - private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId) + interface Callback : Plugin { + fun closeLeaveSpaceFlow() + fun navigateToRolesAndPermissions() + } + + private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId) private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle) + private val callback: Callback = callback() + override fun onBuilt() { super.onBuilt() lifecycle.subscribe( @@ -47,7 +54,8 @@ class LeaveSpaceNode( val state = presenter.present() LeaveSpaceView( state = state, - onCancel = ::navigateUp, + onCancel = callback::closeLeaveSpaceFlow, + onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, modifier = modifier ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt index 7432301f91..6f7a9903df 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -69,6 +69,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun LeaveSpaceView( state: LeaveSpaceState, onCancel: () -> Unit, + onRolesAndPermissionsClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -130,6 +131,9 @@ fun LeaveSpaceView( state.eventSink(LeaveSpaceEvents.LeaveSpace) }, onCancel = onCancel, + // TODO enable when navigation is ready + showRolesAndPermissionsButton = false, // state.isLastAdmin, + onRolesAndPermissionsClick = onRolesAndPermissionsClick, ) } } @@ -210,6 +214,8 @@ private fun LeaveSpaceButtons( showLeaveButton: Boolean, selectedRoomsCount: Int, onLeaveSpace: () -> Unit, + showRolesAndPermissionsButton: Boolean, + onRolesAndPermissionsClick: () -> Unit, onCancel: () -> Unit, ) { ButtonColumnMolecule( @@ -229,8 +235,14 @@ private fun LeaveSpaceButtons( destructive = true, ) } - // TODO For least admin space, add a button to open the settings. - // See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600 + if (showRolesAndPermissionsButton) { + Button( + text = stringResource(CommonStrings.action_go_to_roles_and_permissions), + onClick = onRolesAndPermissionsClick, + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Settings()), + ) + } TextButton( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_cancel), @@ -345,5 +357,6 @@ internal fun LeaveSpaceViewPreview( LeaveSpaceView( state = state, onCancel = {}, + onRolesAndPermissionsClick = {}, ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt index 174fa71ee8..c0271782b4 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -42,7 +42,7 @@ class SpaceNode( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun navigateToRoom(roomId: RoomId, viaParameters: List) - fun navigateToRoomDetails() + fun navigateToSpaceSettings() fun navigateToRoomMemberList() fun startLeaveSpaceFlow() } @@ -80,7 +80,7 @@ class SpaceNode( callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via) }, onDetailsClick = { - callback.navigateToRoomDetails() + callback.navigateToSpaceSettings() }, onShareSpace = { onShareRoom(context) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index f4c415b718..2779ab2687 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -328,7 +328,7 @@ private fun SpaceViewTopBar( }, text = { Text( - text = stringResource(id = CommonStrings.action_leave), + text = stringResource(id = CommonStrings.action_leave_space), color = ElementTheme.colors.textCriticalPrimary, ) }, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt new file mode 100644 index 0000000000..a6fe90ade6 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +sealed interface SpaceSettingsEvents diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt new file mode 100644 index 0000000000..ea2e07db77 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class SpaceSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpaceSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun closeSettings() + + fun navigateToSpaceInfo() + fun navigateToSpaceMembers() + fun navigateToRolesAndPermissions() + fun navigateToSecurityAndPrivacy() + fun startLeaveSpaceFlow() + } + + private val callback: Callback = callback() + private val stateFlow = launchMolecule { presenter.present() } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + SpaceSettingsView( + state = state, + modifier = modifier, + onSpaceInfoClick = callback::navigateToSpaceInfo, + onBackClick = callback::closeSettings, + onMembersClick = callback::navigateToSpaceMembers, + onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, + onSecurityAndPrivacyClick = callback::navigateToSecurityAndPrivacy, + onLeaveSpaceClick = callback::startLeaveSpaceFlow, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt new file mode 100644 index 0000000000..6e89a0ba8d --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin + +@Inject +class SpaceSettingsPresenter( + private val room: JoinedRoom, +) : Presenter { + @Composable + override fun present(): SpaceSettingsState { + val roomInfo by room.roomInfoFlow.collectAsState() + val isUserAdmin = room.isOwnUserAdmin() + return SpaceSettingsState( + roomId = room.roomId, + name = roomInfo.name.orEmpty(), + canonicalAlias = roomInfo.canonicalAlias, + avatarUrl = roomInfo.avatarUrl, + memberCount = roomInfo.activeMembersCount, + showRolesAndPermissions = isUserAdmin, + showSecurityAndPrivacy = isUserAdmin, + eventSink = {}, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt new file mode 100644 index 0000000000..95b3615f63 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +data class SpaceSettingsState( + val roomId: RoomId, + val name: String, + val canonicalAlias: RoomAlias?, + val avatarUrl: String?, + val memberCount: Long, + val showRolesAndPermissions: Boolean, + val showSecurityAndPrivacy: Boolean, + val eventSink: (SpaceSettingsEvents) -> Unit +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt new file mode 100644 index 0000000000..db1b336653 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +open class SpaceSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceSettingsState(), + aSpaceSettingsState(alias = null), + aSpaceSettingsState(showSecurityAndPrivacy = true), + aSpaceSettingsState(showRolesAndPermissions = true), + ) +} + +fun aSpaceSettingsState( + roomId: RoomId = RoomId("!aRoomId:element.io"), + name: String = "Space name", + alias: RoomAlias? = RoomAlias("#spacename:element.io"), + avatarUrl: String? = null, + memberCount: Long = 100, + showRolesAndPermissions: Boolean = false, + showSecurityAndPrivacy: Boolean = false, + eventSink: (SpaceSettingsEvents) -> Unit = {}, +) = SpaceSettingsState( + roomId = roomId, + name = name, + canonicalAlias = alias, + avatarUrl = avatarUrl, + memberCount = memberCount, + showRolesAndPermissions = showRolesAndPermissions, + showSecurityAndPrivacy = showSecurityAndPrivacy, + eventSink = eventSink, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt new file mode 100644 index 0000000000..fae5bf2f03 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.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.space.impl.R +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.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.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SpaceSettingsView( + state: SpaceSettingsState, + onBackClick: () -> Unit, + onSpaceInfoClick: () -> Unit, + onMembersClick: () -> Unit, + onRolesAndPermissionsClick: () -> Unit, + onSecurityAndPrivacyClick: () -> Unit, + onLeaveSpaceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + SpaceSettingsTopBar(onBackClick = onBackClick) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + SpaceInfoSection( + roomId = state.roomId, + name = state.name, + avatarUrl = state.avatarUrl, + canonicalAlias = state.canonicalAlias?.value, + onSpaceInfoClick = onSpaceInfoClick, + ) + Section(isVisible = state.showSecurityAndPrivacy, content = { + SecurityAndPrivacyItem( + onClick = onSecurityAndPrivacyClick + ) + }) + Section(content = { + MembersItem(state.memberCount, onClick = onMembersClick) + if (state.showRolesAndPermissions) { + RolesAndPermissionsItem(onClick = onRolesAndPermissionsClick) + } + }) + Section(content = { + LeaveSpaceItem( + onClick = onLeaveSpaceClick + ) + }) + } + } +} + +@Composable +private fun SpaceInfoSection( + roomId: RoomId, + name: String, + avatarUrl: String?, + canonicalAlias: String?, + onSpaceInfoClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSpaceInfoClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = AvatarData(roomId.value, name, avatarUrl, AvatarSize.SpaceListItem), + avatarType = AvatarType.Space(), + contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_avatar) }, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = name, + style = ElementTheme.typography.fontHeadingMdRegular, + color = ElementTheme.colors.textPrimary, + ) + if (canonicalAlias != null) { + Text( + text = canonicalAlias, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@Composable +private fun Section( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + if (isVisible) { + PreferenceCategory(content = content, modifier = modifier) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceSettingsTopBar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + titleStr = stringResource(CommonStrings.common_settings), + navigationIcon = { BackButton(onClick = onBackClick) }, + modifier = modifier, + ) +} + +@Composable +private fun SecurityAndPrivacyItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_space_settings_security_and_privacy)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun MembersItem( + memberCount: Long, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(CommonStrings.common_people)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())), + trailingContent = ListItemContent.Text(memberCount.toString()), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun RolesAndPermissionsItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_space_settings_roles_and_permissions)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun LeaveSpaceItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.action_leave_space)) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())), + style = ListItemStyle.Destructive, + onClick = onClick, + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun SpaceSettingsViewPreview( + @PreviewParameter(SpaceSettingsStateProvider::class) state: SpaceSettingsState +) = ElementPreview { + SpaceSettingsView( + state = state, + onBackClick = {}, + onSpaceInfoClick = {}, + onMembersClick = {}, + onRolesAndPermissionsClick = {}, + onSecurityAndPrivacyClick = {}, + onLeaveSpaceClick = {}, + modifier = Modifier, + ) +} diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml index c6ced29d41..a4df5e767d 100644 --- a/features/space/impl/src/main/res/values/localazy.xml +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -10,4 +10,7 @@ "You will not be removed from the following room(s) because you\'re the only administrator:" "Leave %1$s?" "You are the only admin for %1$s" + "Leave space" + "Roles & permissions" + "Security & privacy" 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 3fd260dd4f..319d1eeeff 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 @@ -15,6 +15,7 @@ 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 import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.tests.testutils.lambda.lambdaError @@ -40,12 +41,12 @@ class DefaultSpaceEntryPointTest { spaceService = FakeSpaceService( spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) } ), + room = FakeJoinedRoom(), graphFactory = FakeSpaceFlowGraph.Factory ) } val callback = object : SpaceEntryPoint.Callback { override fun navigateToRoom(roomId: RoomId, viaParameters: List) = lambdaError() - override fun navigateToRoomDetails() = lambdaError() override fun navigateToRoomMemberList() = lambdaError() } val result = entryPoint.createNode( diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt index fd019c3587..0b36452b45 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -103,7 +103,7 @@ class UserProfileFlowNode( // Cannot happen } - override fun forwardEvent(eventId: EventId) { + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { // Cannot happen } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f07c14167..5cfc639d67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.50.0" +roborazzi = "1.51.0" # Jetbrain datetime = "0.7.1" @@ -52,7 +52,7 @@ haze = "1.6.10" dependencyAnalysis = "3.4.1" # DI -metro = "0.7.3" +metro = "0.7.4" # Auto service autoservice = "1.1.1" @@ -177,7 +177,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.31" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.11.4" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -229,7 +229,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.16.0" +element_call_embedded = "io.element.android:element-call-embedded:0.16.1" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } diff --git a/libraries/compound/build.gradle.kts b/libraries/compound/build.gradle.kts index cbdb09d451..933ef3e495 100644 --- a/libraries/compound/build.gradle.kts +++ b/libraries/compound/build.gradle.kts @@ -18,12 +18,12 @@ android { testOptions { unitTests.isIncludeAndroidResources = true } - - dependencies { - implementation(libs.showkase) - testCommonDependencies(libs) - testImplementation(libs.test.roborazzi) - testImplementation(libs.test.roborazzi.compose) - testImplementation(libs.test.roborazzi.junit) - } +} + +dependencies { + implementation(libs.showkase) + testCommonDependencies(libs) + testImplementation(libs.test.roborazzi) + testImplementation(libs.test.roborazzi.compose) + testImplementation(libs.test.roborazzi.junit) } diff --git a/libraries/cryptography/test/build.gradle.kts b/libraries/cryptography/test/build.gradle.kts index 5564c972f9..e3a110d8fb 100644 --- a/libraries/cryptography/test/build.gradle.kts +++ b/libraries/cryptography/test/build.gradle.kts @@ -11,8 +11,8 @@ plugins { android { namespace = "io.element.android.libraries.cryptography.test" - - dependencies { - api(projects.libraries.cryptography.api) - } +} + +dependencies { + api(projects.libraries.cryptography.api) } diff --git a/libraries/dateformatter/api/build.gradle.kts b/libraries/dateformatter/api/build.gradle.kts index cebb9d4049..99c22515fd 100644 --- a/libraries/dateformatter/api/build.gradle.kts +++ b/libraries/dateformatter/api/build.gradle.kts @@ -13,8 +13,8 @@ plugins { android { namespace = "io.element.android.libraries.dateformatter.api" - - dependencies { - testCommonDependencies(libs) - } +} + +dependencies { + testCommonDependencies(libs) } diff --git a/libraries/dateformatter/impl/build.gradle.kts b/libraries/dateformatter/impl/build.gradle.kts index 72da2f81f6..15c0034f91 100644 --- a/libraries/dateformatter/impl/build.gradle.kts +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -30,19 +30,19 @@ android { ) } } - - 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) - } +} + +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 6f3877ea80..af8b2a8e19 100644 --- a/libraries/dateformatter/test/build.gradle.kts +++ b/libraries/dateformatter/test/build.gradle.kts @@ -11,9 +11,9 @@ plugins { android { namespace = "io.element.android.libraries.dateformatter.test" - - dependencies { - api(projects.libraries.dateformatter.api) - api(libs.datetime) - } +} + +dependencies { + api(projects.libraries.dateformatter.api) + api(libs.datetime) } diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index ff0b4878b2..8750b04683 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -25,24 +25,24 @@ android { consumerProguardFiles("consumer-rules.pro") } } - - dependencies { - api(projects.libraries.compound) - - implementation(libs.androidx.compose.material3.windowsizeclass) - implementation(libs.androidx.compose.material3.adaptive) - implementation(libs.coil.compose) - implementation(libs.vanniktech.blurhash) - implementation(projects.libraries.androidutils) - implementation(projects.libraries.architecture) - implementation(projects.libraries.core) - implementation(projects.libraries.preferences.api) - implementation(projects.libraries.testtags) - implementation(projects.libraries.uiStrings) - - ksp(libs.showkase.processor) - implementation(libs.showkase) - - testCommonDependencies(libs) - } +} + +dependencies { + api(projects.libraries.compound) + + implementation(libs.androidx.compose.material3.windowsizeclass) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.coil.compose) + implementation(libs.vanniktech.blurhash) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) + implementation(libs.showkase) + + testCommonDependencies(libs) } diff --git a/libraries/featureflag/test/build.gradle.kts b/libraries/featureflag/test/build.gradle.kts index e2920a07b7..f2361417a0 100644 --- a/libraries/featureflag/test/build.gradle.kts +++ b/libraries/featureflag/test/build.gradle.kts @@ -11,11 +11,11 @@ plugins { android { namespace = "io.element.android.libraries.featureflag.test" - - dependencies { - api(projects.libraries.featureflag.api) - implementation(projects.libraries.core) - implementation(projects.libraries.matrix.test) - implementation(libs.coroutines.core) - } +} + +dependencies { + api(projects.libraries.featureflag.api) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.test) + implementation(libs.coroutines.core) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000000..aec1665455 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +/** + * Checks the homeserver's compatibility with Element X. + */ +interface HomeServerLoginCompatibilityChecker { + /** + * Performs the compatibility check given the homeserver's [url]. + * @return a `true` value if the homeserver is compatible, `false` if not, or a failure result if the check unexpectedly failed. + */ + suspend fun check(url: String): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 961178ee3e..22059ad155 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.api.encryption +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import kotlinx.coroutines.flow.Flow @@ -17,7 +18,7 @@ interface EncryptionService { val recoveryStateStateFlow: StateFlow val enableRecoveryProgressStateFlow: StateFlow val isLastDevice: StateFlow - val hasDevicesToVerifyAgainst: StateFlow + val hasDevicesToVerifyAgainst: StateFlow> suspend fun enableBackups(): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000000..8cfaca077b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.impl.ClientBuilderProvider +import timber.log.Timber + +@ContributesBinding(AppScope::class) +@Inject +class RustHomeServerLoginCompatibilityChecker( + private val clientBuilderProvider: ClientBuilderProvider, +) : HomeServerLoginCompatibilityChecker { + override suspend fun check(url: String): Result = runCatchingExceptions { + clientBuilderProvider.provide() + .inMemoryStore() + .serverNameOrHomeserverUrl(url) + .build() + .use { + it.homeserverLoginDetails() + } + .use { + Timber.d("Homeserver $url | OIDC: ${it.supportsOidcLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}") + it.supportsOidcLogin() || it.supportsPasswordLogin() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index 91cafd1df0..8e22827063 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.impl.encryption +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.mapFailure @@ -42,6 +43,7 @@ import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.UserIdentity +import timber.log.Timber import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException @@ -103,14 +105,20 @@ class RustEncryptionService( * TODO This is a temporary workaround, when we will have a way to observe * the sessions, this code will have to be updated. */ - override val hasDevicesToVerifyAgainst: StateFlow = flow { + override val hasDevicesToVerifyAgainst: StateFlow> = flow { while (currentCoroutineContext().isActive) { - val result = hasDevicesToVerifyAgainst().getOrDefault(false) - emit(result) + val result = hasDevicesToVerifyAgainst() + result + .onSuccess { + emit(AsyncData.Success(it)) + } + .onFailure { + Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...") + } delay(5_000) } } - .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized) override suspend fun enableBackups(): Result = withContext(dispatchers.io) { runCatchingExceptions { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index bad622e456..7e06eb3c75 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -39,6 +39,7 @@ class NotificationMapper( isDirect = item.roomInfo.isDirect, activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), ) + val timestamp = item.timestamp() ?: clock.epochMillis() NotificationData( sessionId = sessionId, eventId = eventId, @@ -53,8 +54,8 @@ class NotificationMapper( isDm = isDm, isEncrypted = item.roomInfo.isEncrypted.orFalse(), isNoisy = item.isNoisy.orFalse(), - timestamp = item.timestamp() ?: clock.epochMillis(), - content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(), + timestamp = timestamp, + content = notificationContentMapper.map(item.event).getOrThrow(), hasMention = item.hasMention.orFalse(), ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index 2ca4a3c823..b2952461df 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -25,8 +25,9 @@ class TimelineEventToNotificationContentMapper { fun map(timelineEvent: TimelineEvent): Result { return runCatchingExceptions { timelineEvent.use { + val senderId = UserId(timelineEvent.senderId()) timelineEvent.eventType().use { eventType -> - eventType.toContent(senderId = UserId(timelineEvent.senderId())) + eventType.toContent(senderId = senderId) } } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt new file mode 100644 index 0000000000..4557d47f5b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider +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.fixtures.fakes.FakeFfiHomeserverLoginDetails +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustHomeserverLoginCompatibilityCheckerTest { + @Test + fun `check - is valid if it supports OIDC login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOidcLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue() + } + + @Test + fun `check - is valid if it supports password login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsPasswordLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue() + } + + @Test + fun `check - is not valid if it only supports SSO login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsSsoLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isFalse() + } + + @Test + fun `check - is not valid if fetching the data fails`() = runTest { + val sut = createChecker { error("Unexpected error!") } + assertThat(sut.check("https://matrix.host.org").isFailure).isTrue() + } + + private fun createChecker( + result: () -> FakeFfiHomeserverLoginDetails, + ) = RustHomeServerLoginCompatibilityChecker( + clientBuilderProvider = FakeClientBuilderProvider { + FakeFfiClientBuilder { + FakeFfiClient(homeserverLoginDetailsResult = result) + } + } + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt index 85328a2d4d..7951309bcd 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt @@ -12,10 +12,12 @@ import org.matrix.rustcomponents.sdk.NoHandle class FakeFfiHomeserverLoginDetails( private val url: String = "https://example.org", - private val supportsPasswordLogin: Boolean = true, - private val supportsOidcLogin: Boolean = false + private val supportsPasswordLogin: Boolean = false, + private val supportsOidcLogin: Boolean = false, + private val supportsSsoLogin: Boolean = false, ) : HomeserverLoginDetails(NoHandle) { override fun url(): String = url override fun supportsOidcLogin(): Boolean = supportsOidcLogin override fun supportsPasswordLogin(): Boolean = supportsPasswordLogin + override fun supportsSsoLogin(): Boolean = supportsSsoLogin } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 8db5fc6807..8c22c90cd7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.test +import androidx.annotation.ColorInt import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.EventId @@ -99,4 +100,5 @@ const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM" const val A_LOGIN_HINT = "mxid:@alice:example.org" -const val A_COLOR_INT = 0xFF0000 +@ColorInt +const val A_COLOR_INT: Int = 0xFFFF0000.toInt() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000000..934784b684 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.auth + +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker + +class FakeHomeServerLoginCompatibilityChecker( + private val checkResult: (String) -> Result, +) : HomeServerLoginCompatibilityChecker { + override suspend fun check(url: String): Result { + return checkResult(url) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index b4199ff8e4..5b2ead9acf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.test.encryption +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState @@ -34,7 +35,7 @@ class FakeEncryptionService( override val recoveryStateStateFlow: MutableStateFlow = MutableStateFlow(RecoveryState.UNKNOWN) override val enableRecoveryProgressStateFlow: MutableStateFlow = MutableStateFlow(EnableRecoveryProgress.Starting) override val isLastDevice: MutableStateFlow = MutableStateFlow(false) - override val hasDevicesToVerifyAgainst: MutableStateFlow = MutableStateFlow(true) + override val hasDevicesToVerifyAgainst: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) private var waitForBackupUploadSteadyStateFlow: Flow = flowOf() private var recoverFailure: Exception? = null @@ -84,7 +85,7 @@ class FakeEncryptionService( this.isLastDevice.value = isLastDevice } - fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: Boolean) { + fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: AsyncData) { this.hasDevicesToVerifyAgainst.value = hasDevicesToVerifyAgainst } diff --git a/libraries/matrixui-test/build.gradle.kts b/libraries/matrixui-test/build.gradle.kts new file mode 100644 index 0000000000..c385d05b54 --- /dev/null +++ b/libraries/matrixui-test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.matrix.ui.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(libs.coil.compose) +} diff --git a/libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoader.kt b/libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoader.kt new file mode 100644 index 0000000000..05e380fb9e --- /dev/null +++ b/libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoader.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.test.media + +import coil3.ComponentRegistry +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.ImageResult + +class FakeImageLoader : ImageLoader { + private val executedRequests = mutableListOf() + + override val defaults: ImageRequest.Defaults + get() = error("Not implemented") + override val components: ComponentRegistry + get() = error("Not implemented") + override val memoryCache: MemoryCache? + get() = error("Not implemented") + override val diskCache: DiskCache? + get() = error("Not implemented") + + override fun enqueue(request: ImageRequest): Disposable { + error("Not implemented") + } + + override suspend fun execute(request: ImageRequest): ImageResult { + executedRequests.add(request) + error("Not implemented") + } + + override fun shutdown() { + error("Not implemented") + } + + override fun newBuilder(): ImageLoader.Builder { + error("Not implemented") + } + + fun getExecutedRequestsData(): List { + return executedRequests.map { it.data } + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoaderHolder.kt b/libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoaderHolder.kt similarity index 67% rename from libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoaderHolder.kt rename to libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoaderHolder.kt index 4c92dc8c18..4deef7274b 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoaderHolder.kt +++ b/libraries/matrixui-test/src/main/kotlin/io/element/android/libraries/matrix/ui/test/media/FakeImageLoaderHolder.kt @@ -1,21 +1,22 @@ /* - * Copyright 2024 New Vector Ltd. + * Copyright 2025 New Vector Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.push.test.notifications +package io.element.android.libraries.matrix.ui.test.media 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 -class FakeImageLoaderHolder : ImageLoaderHolder { - private val fakeImageLoader = FakeImageLoader() +class FakeImageLoaderHolder( + val fakeImageLoader: ImageLoader = FakeImageLoader(), +) : ImageLoaderHolder { override fun get(client: MatrixClient): ImageLoader { - return fakeImageLoader.getImageLoader() + return fakeImageLoader } override fun remove(sessionId: SessionId) { diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts index c130cd7900..aaf9b4cd0c 100644 --- a/libraries/mediapickers/api/build.gradle.kts +++ b/libraries/mediapickers/api/build.gradle.kts @@ -13,12 +13,12 @@ plugins { android { namespace = "io.element.android.libraries.mediapickers.api" - - dependencies { - implementation(projects.libraries.uiStrings) - implementation(projects.libraries.core) - implementation(projects.libraries.di) - - testCommonDependencies(libs) - } +} + +dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + + testCommonDependencies(libs) } diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts index ee743bb63d..dc37741b02 100644 --- a/libraries/mediapickers/test/build.gradle.kts +++ b/libraries/mediapickers/test/build.gradle.kts @@ -15,10 +15,10 @@ setupDependencyInjection() android { namespace = "io.element.android.libraries.mediapickers.test" - - dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.di) - api(projects.libraries.mediapickers.api) - } +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt index dd71f302d4..2fdcaf1bd0 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt @@ -23,6 +23,6 @@ interface MediaGalleryEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onBackClick() fun viewInTimeline(eventId: EventId) - fun forward(eventId: EventId) + fun forward(eventId: EventId, fromPinnedEvents: Boolean) } } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index 201573dc70..ab1c78abea 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -31,7 +31,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onDone() fun viewInTimeline(eventId: EventId) - fun forwardEvent(eventId: EventId) + fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) } data class Params( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt index cc71b49dac..e21da04586 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt @@ -85,7 +85,7 @@ class MediaGalleryFlowNode( } override fun forward(eventId: EventId) { - callback.forward(eventId) + callback.forward(eventId, fromPinnedEvents = false) } override fun showItem(item: MediaItem.Event) { @@ -119,9 +119,9 @@ class MediaGalleryFlowNode( callback.viewInTimeline(eventId) } - override fun forwardEvent(eventId: EventId) { + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { // Need to go to the parent because of the overlay - callback.forward(eventId) + callback.forward(eventId, fromPinnedEvents) } } mediaViewerEntryPoint.createNode( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt index 77e253dfa5..b3fa321170 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt @@ -11,6 +11,6 @@ import io.element.android.libraries.matrix.api.core.EventId interface MediaViewerNavigator { fun onViewInTimelineClick(eventId: EventId) - fun onForwardClick(eventId: EventId) + fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) fun onItemDeleted() } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 79a95c4648..f47f341657 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -64,8 +64,8 @@ class MediaViewerNode( callback.viewInTimeline(eventId) } - override fun onForwardClick(eventId: EventId) { - callback.forwardEvent(eventId) + override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) { + callback.forwardEvent(eventId, fromPinnedEvents) } override fun onItemDeleted() { @@ -81,11 +81,7 @@ class MediaViewerNode( timelineMediaGalleryDataSource } else { // Can we use a specific timeline? - val timelineMode = when (val mode = inputs.mode) { - is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> mode.timelineMode - is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> mode.timelineMode - else -> null - } + val timelineMode = inputs.mode.getTimelineMode() when (timelineMode) { null -> timelineMediaGalleryDataSource Timeline.Mode.Live, @@ -149,3 +145,11 @@ class MediaViewerNode( } } } + +internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? { + return when (this) { + is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode + is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode + else -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 726e9989ce..a04a1a370a 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -33,6 +33,7 @@ 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.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia @@ -119,7 +120,10 @@ class MediaViewerPresenter( } is MediaViewerEvents.Forward -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - navigator.onForwardClick(event.eventId) + navigator.onForwardClick( + eventId = event.eventId, + fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents, + ) } is MediaViewerEvents.OpenInfo -> coroutineScope.launch { mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt index 94ac0fea21..31964a6629 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt @@ -27,6 +27,7 @@ class SingleMediaGalleryDataSource( override fun start() = Unit override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) override fun getLastData(): AsyncData = AsyncData.Success(data) + override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit override suspend fun deleteItem(eventId: EventId) = Unit diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt index 189644ebd5..991e29cd98 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt @@ -40,7 +40,7 @@ class DefaultMediaGalleryEntryPointTest { val callback = object : MediaGalleryEntryPoint.Callback { override fun onBackClick() = lambdaError() override fun viewInTimeline(eventId: EventId) = lambdaError() - override fun forward(eventId: EventId) = lambdaError() + override fun forward(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, 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 b53fe7913c..f6246545b5 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 @@ -72,7 +72,7 @@ class DefaultMediaViewerEntryPointTest { val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() = lambdaError() override fun viewInTimeline(eventId: EventId) = lambdaError() - override fun forwardEvent(eventId: EventId) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() } val params = createMediaViewerEntryPointParams() val result = entryPoint.createNode( @@ -118,7 +118,7 @@ class DefaultMediaViewerEntryPointTest { val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() = lambdaError() override fun viewInTimeline(eventId: EventId) = lambdaError() - override fun forwardEvent(eventId: EventId) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() } val params = entryPoint.createParamsForAvatar( filename = "fn", diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt index 791527445b..d223ad1ce9 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt @@ -12,15 +12,15 @@ import io.element.android.tests.testutils.lambda.lambdaError class FakeMediaViewerNavigator( private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }, - private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() }, + private val onForwardClickLambda: (EventId, Boolean) -> Unit = { _, _ -> lambdaError() }, private val onItemDeletedLambda: () -> Unit = { lambdaError() }, ) : MediaViewerNavigator { override fun onViewInTimelineClick(eventId: EventId) { onViewInTimelineClickLambda(eventId) } - override fun onForwardClick(eventId: EventId) { - onForwardClickLambda(eventId) + override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) { + onForwardClickLambda(eventId, fromPinnedEvents) } override fun onItemDeleted() { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 359eda7a5e..f6de72b2d8 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -785,7 +785,7 @@ class MediaViewerPresenterTest { @Test fun `present - forward hides the bottom sheet and invokes the navigator`() = runTest { - val onForwardClickLambda = lambdaRecorder { } + val onForwardClickLambda = lambdaRecorder { _, _ -> } val navigator = FakeMediaViewerNavigator( onForwardClickLambda = onForwardClickLambda, ) @@ -804,7 +804,35 @@ class MediaViewerPresenterTest { initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID)) val finalState = awaitItem() assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) - onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + onForwardClickLambda.assertions().isCalledOnce() + .with(value(AN_EVENT_ID), value(false)) + } + } + + @Test + fun `present - forward from pinned events hides the bottom sheet and invokes the navigator`() = runTest { + val onForwardClickLambda = lambdaRecorder { _, _ -> } + val navigator = FakeMediaViewerNavigator( + onForwardClickLambda = onForwardClickLambda, + ) + val presenter = createMediaViewerPresenter( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.PinnedEvents), + localMediaFactory = localMediaFactory, + mediaViewerNavigator = navigator, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onForwardClickLambda.assertions().isCalledOnce() + .with(value(AN_EVENT_ID), value(true)) } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index f12d92a50d..c6bfbd73ed 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -125,7 +125,7 @@ class DefaultPermissionsPresenter( showDialog = showDialog.value, permissionAlreadyAsked = isAlreadyAsked, permissionAlreadyDenied = isAlreadyDenied, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } diff --git a/libraries/preferences/test/build.gradle.kts b/libraries/preferences/test/build.gradle.kts index 9db4ab1866..44116aac13 100644 --- a/libraries/preferences/test/build.gradle.kts +++ b/libraries/preferences/test/build.gradle.kts @@ -11,12 +11,12 @@ plugins { android { namespace = "io.element.android.libraries.preferences.test" - - dependencies { - api(projects.libraries.preferences.api) - implementation(projects.libraries.matrix.api) - implementation(projects.tests.testutils) - implementation(libs.coroutines.core) - implementation(libs.androidx.datastore.preferences) - } +} + +dependencies { + api(projects.libraries.preferences.api) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) + implementation(libs.coroutines.core) + implementation(libs.androidx.datastore.preferences) } diff --git a/libraries/previewutils/build.gradle.kts b/libraries/previewutils/build.gradle.kts index 92218e9286..111cb4c830 100644 --- a/libraries/previewutils/build.gradle.kts +++ b/libraries/previewutils/build.gradle.kts @@ -11,11 +11,11 @@ plugins { android { namespace = "io.element.android.libraries.previewutils" - - dependencies { - implementation(projects.libraries.designsystem) - implementation(projects.libraries.matrix.api) - - implementation(libs.kotlinx.collections.immutable) - } +} + +dependencies { + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + + implementation(libs.kotlinx.collections.immutable) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 87b3c681f1..2de79ab456 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -21,6 +21,12 @@ android { isIncludeAndroidResources = true } } + + buildTypes { + register("nightly") { + matchingFallbacks += listOf("release") + } + } } setupDependencyInjection() @@ -70,6 +76,7 @@ dependencies { testCommonDependencies(libs) testImplementation(libs.coil.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixuiTest) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) diff --git a/libraries/push/impl/src/debug/res/raw/message.mp3 b/libraries/push/impl/src/debug/res/raw/message.mp3 new file mode 100644 index 0000000000..abc056786c Binary files /dev/null and b/libraries/push/impl/src/debug/res/raw/message.mp3 differ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 481d354e8d..5e397c94d9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -8,7 +8,10 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context +import android.graphics.ImageDecoder import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -138,7 +141,13 @@ class DefaultNotifiableEventResolver( is NotificationContent.MessageLike.RoomMessage -> { val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue() == MediaPreviewValue.On val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) - val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName) + val imageMimeType = if (showMediaPreview) content.getImageMimetype() else null + val imageUriString = imageMimeType?.let { content.fetchImageIfPresent(client, imageMimeType)?.toString() } + val messageBody = descriptionFromMessageContent( + content = content, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + hasImageUri = imageUriString != null, + ) val notifiableMessageEvent = buildNotifiableMessageEvent( sessionId = userId, senderId = content.senderId, @@ -149,8 +158,8 @@ class DefaultNotifiableEventResolver( timestamp = this.timestamp, senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, body = messageBody, - imageUriString = if (showMediaPreview) content.fetchImageIfPresent(client)?.toString() else null, - imageMimeType = if (showMediaPreview) content.getImageMimetype() else null, + imageUriString = imageUriString, + imageMimeType = imageMimeType.takeIf { imageUriString != null }, roomName = roomDisplayName, roomIsDm = isDm, roomAvatarPath = roomAvatarUrl, @@ -299,13 +308,18 @@ class DefaultNotifiableEventResolver( private fun descriptionFromMessageContent( content: NotificationContent.MessageLike.RoomMessage, senderDisambiguatedDisplayName: String, - ): String { + hasImageUri: Boolean, + ): String? { return when (val messageType = content.messageType) { is AudioMessageType -> messageType.bestDescription is VoiceMessageType -> stringProvider.getString(CommonStrings.common_voice_message) is EmoteMessageType -> "* $senderDisambiguatedDisplayName ${messageType.body}" is FileMessageType -> messageType.bestDescription - is ImageMessageType -> messageType.bestDescription + is ImageMessageType -> if (hasImageUri) { + messageType.caption + } else { + messageType.bestDescription + } is StickerMessageType -> messageType.bestDescription is NoticeMessageType -> messageType.body is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser) @@ -326,14 +340,34 @@ class DefaultNotifiableEventResolver( } } - private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? { + /** + * Fetch the image for message type, only if the mime type is supported, as recommended + * per [NotificationCompat.MessagingStyle.Message.setData] documentation. + * Then convert to a [Uri] accessible to the Notification Service. + */ + private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent( + client: MatrixClient, + mimeType: String, + ): Uri? { val fileResult = when (val messageType = messageType) { - is ImageMessageType -> notificationMediaRepoFactory.create(client) - .getMediaFile( - mediaSource = messageType.source, - mimeType = messageType.info?.mimetype, - filename = messageType.filename, - ) + is ImageMessageType -> { + val isMimeTypeSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageDecoder.isMimeTypeSupported(mimeType) + } else { + // Assume it's supported on old systems... + true + } + if (isMimeTypeSupported) { + notificationMediaRepoFactory.create(client).getMediaFile( + mediaSource = messageType.source, + mimeType = messageType.info?.mimetype, + filename = messageType.filename, + ) + } else { + Timber.tag(loggerTag.value).d("Mime type $mimeType not supported by the system") + null + } + } is VideoMessageType -> null // Use the thumbnail here? else -> null } 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 01a1b1f9a9..6d52cbfe13 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 @@ -7,15 +7,10 @@ package io.element.android.libraries.push.impl.notifications -import androidx.annotation.VisibleForTesting -import androidx.core.app.NotificationManagerCompat import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -32,11 +27,7 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.currentSessionId import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import timber.log.Timber - -private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag) /** * This class receives notification events as they arrive from the PushHandler calling [onNotifiableEventReceived] and @@ -46,7 +37,7 @@ private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag. @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultNotificationDrawerManager( - private val notificationManager: NotificationManagerCompat, + private val notificationDisplayer: NotificationDisplayer, private val notificationRenderer: NotificationRenderer, private val appNavigationStateService: AppNavigationStateService, @AppCoroutineScope @@ -55,25 +46,17 @@ class DefaultNotificationDrawerManager( private val imageLoaderHolder: ImageLoaderHolder, private val activeNotificationsProvider: ActiveNotificationsProvider, ) : NotificationCleaner { - private var appNavigationStateObserver: Job? = null - // TODO EAx add a setting per user for this private var useCompleteNotificationFormat = true init { // Observe application state - appNavigationStateObserver = coroutineScope.launch { + coroutineScope.launch { appNavigationStateService.appNavigationState .collect { onAppNavigationStateChange(it.navigationState) } } } - // For test only - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun destroy() { - appNavigationStateObserver?.cancel() - } - private var currentAppNavigationState: NavigationState? = null private fun onAppNavigationStateChange(navigationState: NavigationState) { @@ -124,7 +107,7 @@ class DefaultNotificationDrawerManager( * Clear all known message events for a [sessionId]. */ override fun clearAllMessagesEvents(sessionId: SessionId) { - notificationManager.cancel(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + notificationDisplayer.cancelNotification(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) clearSummaryNotificationIfNeeded(sessionId) } @@ -133,7 +116,7 @@ class DefaultNotificationDrawerManager( */ fun clearAllEvents(sessionId: SessionId) { activeNotificationsProvider.getNotificationsForSession(sessionId) - .forEach { notificationManager.cancel(it.tag, it.id) } + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } } /** @@ -142,7 +125,7 @@ class DefaultNotificationDrawerManager( * Can also be called when a notification for this room is dismissed by the user. */ override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { - notificationManager.cancel(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + notificationDisplayer.cancelNotification(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) clearSummaryNotificationIfNeeded(sessionId) } @@ -152,13 +135,13 @@ class DefaultNotificationDrawerManager( */ override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { val tag = NotificationCreator.messageTag(roomId, threadId) - notificationManager.cancel(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + notificationDisplayer.cancelNotification(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) clearSummaryNotificationIfNeeded(sessionId) } override fun clearMembershipNotificationForSession(sessionId: SessionId) { activeNotificationsProvider.getMembershipNotificationForSession(sessionId) - .forEach { notificationManager.cancel(it.tag, it.id) } + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } clearSummaryNotificationIfNeeded(sessionId) } @@ -167,7 +150,7 @@ class DefaultNotificationDrawerManager( */ override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId) - .forEach { notificationManager.cancel(it.tag, it.id) } + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } clearSummaryNotificationIfNeeded(sessionId) } @@ -176,14 +159,14 @@ class DefaultNotificationDrawerManager( */ override fun clearEvent(sessionId: SessionId, eventId: EventId) { val id = NotificationIdProvider.getRoomEventNotificationId(sessionId) - notificationManager.cancel(eventId.value, id) + notificationDisplayer.cancelNotification(eventId.value, id) clearSummaryNotificationIfNeeded(sessionId) } private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { - notificationManager.cancel(null, summaryNotification.id) + notificationDisplayer.cancelNotification(null, summaryNotification.id) } } @@ -201,29 +184,9 @@ class DefaultNotificationDrawerManager( // We have an avatar and a display name, use it userFromCache } else { - client.getSafeUserProfile() + client.getUserProfile().getOrNull() ?: MatrixUser(sessionId) } - notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) } } - - private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser { - return tryOrNull( - onException = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, - operation = { - val profile = getUserProfile().getOrNull() - // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash - if (profile?.displayName.isNullOrEmpty()) { - profile?.copy(displayName = sessionId.value) - } else { - profile - } - } - ) ?: MatrixUser( - userId = sessionId, - displayName = sessionId.value, - avatarUrl = null - ) - } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index a3becd94c1..33b2df410c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import android.graphics.Typeface import android.text.style.StyleSpan -import androidx.annotation.ColorInt import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import coil3.ImageLoader @@ -19,8 +18,8 @@ import dev.zacsweers.metro.ContributesBinding 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.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -31,39 +30,37 @@ import io.element.android.services.toolbox.api.strings.StringProvider interface NotificationDataFactory { suspend fun toNotifications( messages: List, - currentUser: MatrixUser, imageLoader: ImageLoader, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List @JvmName("toNotificationInvites") @Suppress("INAPPLICABLE_JVM_NAME") fun toNotifications( invites: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List @JvmName("toNotificationSimpleEvents") @Suppress("INAPPLICABLE_JVM_NAME") fun toNotifications( simpleEvents: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List @JvmName("toNotificationFallbackEvents") @Suppress("INAPPLICABLE_JVM_NAME") fun toNotifications( fallback: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List fun createSummaryNotification( - currentUser: MatrixUser, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): SummaryNotification } @@ -77,9 +74,8 @@ class DefaultNotificationDataFactory( ) : NotificationDataFactory { override suspend fun toNotifications( messages: List, - currentUser: MatrixUser, imageLoader: ImageLoader, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } .groupBy { it.roomId } @@ -90,13 +86,12 @@ class DefaultNotificationDataFactory( eventsByThreadId.map { (threadId, events) -> val notification = roomGroupMessageCreator.createRoomMessage( - currentUser = currentUser, events = events, roomId = roomId, threadId = threadId, imageLoader = imageLoader, - existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId, threadId), - color = color, + existingNotification = getExistingNotificationForMessages(notificationAccountParams.user.userId, roomId, threadId), + notificationAccountParams = notificationAccountParams, ) RoomNotification( notification = notification, @@ -121,12 +116,12 @@ class DefaultNotificationDataFactory( @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( invites: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return invites.map { event -> OneShotNotification( - key = event.roomId.value, - notification = notificationCreator.createRoomInvitationNotification(event, color), + tag = event.roomId.value, + notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event), summaryLine = event.description, isNoisy = event.noisy, timestamp = event.timestamp @@ -138,12 +133,12 @@ class DefaultNotificationDataFactory( @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( simpleEvents: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return simpleEvents.map { event -> OneShotNotification( - key = event.eventId.value, - notification = notificationCreator.createSimpleEventNotification(event, color), + tag = event.eventId.value, + notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event), summaryLine = event.description, isNoisy = event.noisy, timestamp = event.timestamp @@ -155,12 +150,12 @@ class DefaultNotificationDataFactory( @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( fallback: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return fallback.map { event -> OneShotNotification( - key = event.eventId.value, - notification = notificationCreator.createFallbackNotification(event, color), + tag = event.eventId.value, + notification = notificationCreator.createFallbackNotification(notificationAccountParams, event), summaryLine = event.description.orEmpty(), isNoisy = false, timestamp = event.timestamp @@ -169,23 +164,21 @@ class DefaultNotificationDataFactory( } override fun createSummaryNotification( - currentUser: MatrixUser, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): SummaryNotification { return when { roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed else -> SummaryNotification.Update( summaryGroupMessageCreator.createSummaryNotification( - currentUser = currentUser, roomNotifications = roomNotifications, invitationNotifications = invitationNotifications, simpleNotifications = simpleNotifications, fallbackNotifications = fallbackNotifications, - color = color, + notificationAccountParams = notificationAccountParams, ) ) } @@ -254,7 +247,7 @@ data class RoomNotification( data class OneShotNotification( val notification: Notification, - val key: String, + val tag: String, val summaryLine: CharSequence, val isNoisy: Boolean, val timestamp: Long, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 4348c9bfb5..c1e7ba1d29 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -19,8 +19,8 @@ import io.element.android.libraries.di.annotations.ApplicationContext import timber.log.Timber interface NotificationDisplayer { - fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean - fun cancelNotificationMessage(tag: String?, id: Int) + fun showNotification(tag: String?, id: Int, notification: Notification): Boolean + fun cancelNotification(tag: String?, id: Int) fun displayDiagnosticNotification(notification: Notification): Boolean fun dismissDiagnosticNotification() } @@ -30,7 +30,7 @@ class DefaultNotificationDisplayer( @ApplicationContext private val context: Context, private val notificationManager: NotificationManagerCompat ) : NotificationDisplayer { - override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { + override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Timber.w("Not allowed to notify.") return false @@ -40,26 +40,28 @@ class DefaultNotificationDisplayer( return true } - override fun cancelNotificationMessage(tag: String?, id: Int) { + override fun cancelNotification(tag: String?, id: Int) { notificationManager.cancel(tag, id) } override fun displayDiagnosticNotification(notification: Notification): Boolean { - return showNotificationMessage( - tag = "DIAGNOSTIC", + return showNotification( + tag = TAG_DIAGNOSTIC, id = NOTIFICATION_ID_DIAGNOSTIC, notification = notification ) } override fun dismissDiagnosticNotification() { - cancelNotificationMessage( - tag = "DIAGNOSTIC", + cancelNotification( + tag = TAG_DIAGNOSTIC, id = NOTIFICATION_ID_DIAGNOSTIC ) } companion object { + private const val TAG_DIAGNOSTIC = "DIAGNOSTIC" + /* ========================================================================================== * IDs for notifications * ========================================================================================== */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt index 5248bce175..5da7389125 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.user.MatrixUser 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 import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -22,6 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.first import timber.log.Timber @@ -32,6 +34,7 @@ class NotificationRenderer( private val notificationDisplayer: NotificationDisplayer, private val notificationDataFactory: NotificationDataFactory, private val enterpriseService: EnterpriseService, + private val sessionStore: SessionStore, ) { suspend fun render( currentUser: MatrixUser, @@ -41,24 +44,29 @@ class NotificationRenderer( ) { val color = enterpriseService.brandColorsFlow(currentUser.userId).first()?.toArgb() ?: NotificationConfig.NOTIFICATION_ACCENT_COLOR + val numberOfAccounts = sessionStore.numberOfSessions() + val notificationAccountParams = NotificationAccountParams( + user = currentUser, + color = color, + showSessionId = numberOfAccounts > 1, + ) val groupedEvents = eventsToProcess.groupByType() - val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, currentUser, imageLoader, color) - val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, color) - val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, color) - val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, color) + val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams) + val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams) + val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams) + val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams) val summaryNotification = notificationDataFactory.createSummaryNotification( - currentUser = currentUser, roomNotifications = roomNotifications, invitationNotifications = invitationNotifications, simpleNotifications = simpleNotifications, fallbackNotifications = fallbackNotifications, - color = color, + notificationAccountParams = notificationAccountParams, ) // Remove summary first to avoid briefly displaying it after dismissing the last notification if (summaryNotification == SummaryNotification.Removed) { Timber.tag(loggerTag.value).d("Removing summary notification") - notificationDisplayer.cancelNotificationMessage( + notificationDisplayer.cancelNotification( tag = null, id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId) ) @@ -69,7 +77,7 @@ class NotificationRenderer( roomId = notificationData.roomId, threadId = notificationData.threadId ) - notificationDisplayer.showNotificationMessage( + notificationDisplayer.showNotification( tag = tag, id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), notification = notificationData.notification @@ -78,9 +86,9 @@ class NotificationRenderer( invitationNotifications.forEach { notificationData -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}") - notificationDisplayer.showNotificationMessage( - tag = notificationData.key, + Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.tag}") + notificationDisplayer.showNotification( + tag = notificationData.tag, id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), notification = notificationData.notification ) @@ -89,9 +97,9 @@ class NotificationRenderer( simpleNotifications.forEach { notificationData -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}") - notificationDisplayer.showNotificationMessage( - tag = notificationData.key, + Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.tag}") + notificationDisplayer.showNotification( + tag = notificationData.tag, id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId), notification = notificationData.notification ) @@ -101,7 +109,7 @@ class NotificationRenderer( // Show only the first fallback notification if (fallbackNotifications.isNotEmpty()) { Timber.tag(loggerTag.value).d("Showing fallback notification") - notificationDisplayer.showNotificationMessage( + notificationDisplayer.showNotification( tag = "FALLBACK", id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId), notification = fallbackNotifications.first().notification @@ -111,7 +119,7 @@ class NotificationRenderer( // Update summary last to avoid briefly displaying it before other notifications if (summaryNotification is SummaryNotification.Update) { Timber.tag(loggerTag.value).d("Updating summary notification") - notificationDisplayer.showNotificationMessage( + notificationDisplayer.showNotification( tag = null, id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId), notification = summaryNotification.notification diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index 853e7ffdfc..79121db5d7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -9,15 +9,14 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import android.graphics.Bitmap -import androidx.annotation.ColorInt import coil3.ImageLoader import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent @@ -25,13 +24,12 @@ import io.element.android.services.toolbox.api.strings.StringProvider interface RoomGroupMessageCreator { suspend fun createRoomMessage( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, events: List, roomId: RoomId, threadId: ThreadId?, imageLoader: ImageLoader, existingNotification: Notification?, - @ColorInt color: Int, ): Notification } @@ -42,13 +40,12 @@ class DefaultRoomGroupMessageCreator( private val notificationCreator: NotificationCreator, ) : RoomGroupMessageCreator { override suspend fun createRoomMessage( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, events: List, roomId: RoomId, threadId: ThreadId?, imageLoader: ImageLoader, existingNotification: Notification?, - @ColorInt color: Int, ): Notification { val lastKnownRoomEvent = events.last() val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" @@ -66,8 +63,9 @@ class DefaultRoomGroupMessageCreator( val smartReplyErrors = events.filter { it.isSmartReplyError() } val roomIsDm = !roomIsGroup return notificationCreator.createMessagesListNotification( + notificationAccountParams = notificationAccountParams, RoomEventGroupInfo( - sessionId = currentUser.userId, + sessionId = notificationAccountParams.user.userId, roomId = roomId, roomDisplayName = roomName, isDm = roomIsDm, @@ -80,11 +78,9 @@ class DefaultRoomGroupMessageCreator( largeIcon = largeBitmap, lastMessageTimestamp = lastMessageTimestamp, tickerText = tickerText, - currentUser = currentUser, existingNotification = existingNotification, imageLoader = imageLoader, events = events, - color = color, ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index 85947226f1..80bc92dc23 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -8,22 +8,20 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification -import androidx.annotation.ColorInt import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.services.toolbox.api.strings.StringProvider interface SummaryGroupMessageCreator { fun createSummaryNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, ): Notification } @@ -42,30 +40,25 @@ class DefaultSummaryGroupMessageCreator( private val notificationCreator: NotificationCreator, ) : SummaryGroupMessageCreator { override fun createSummaryNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, ): Notification { val summaryIsNoisy = roomNotifications.any { it.shouldBing } || invitationNotifications.any { it.isNoisy } || simpleNotifications.any { it.isNoisy } - val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp ?: invitationNotifications.lastOrNull()?.timestamp ?: simpleNotifications.last().timestamp - - // FIXME roomIdToEventMap.size is not correct, this is the number of rooms - val nbEvents = roomNotifications.size + simpleNotifications.size + val nbEvents = roomNotifications.size + invitationNotifications.size + simpleNotifications.size val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) return notificationCreator.createSummaryListNotification( - currentUser, + notificationAccountParams = notificationAccountParams, sumTitle, noisy = summaryIsNoisy, lastMessageTimestamp = lastMessageTimestamp, - color = color, ) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt new file mode 100644 index 0000000000..da122b6a2a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import androidx.annotation.ColorInt +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class NotificationAccountParams( + val user: MatrixUser, + @ColorInt val color: Int, + val showSessionId: Boolean, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index efe54bd3d9..d8ed2bb908 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo @@ -47,42 +48,40 @@ interface NotificationCreator { * Create a notification for a Room. */ suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, tickerText: String, - currentUser: MatrixUser, existingNotification: Notification?, imageLoader: ImageLoader, events: List, - @ColorInt color: Int, ): Notification fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, inviteNotifiableEvent: InviteNotifiableEvent, - @ColorInt color: Int, ): Notification fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, simpleNotifiableEvent: SimpleNotifiableEvent, - @ColorInt color: Int, ): Notification fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, fallbackNotifiableEvent: FallbackNotifiableEvent, - @ColorInt color: Int, ): Notification /** * Create the summary notification. */ fun createSummaryListNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, compatSummary: String, noisy: Boolean, lastMessageTimestamp: Long, - @ColorInt color: Int, ): Notification fun createDiagnosticNotification( @@ -118,16 +117,15 @@ class DefaultNotificationCreator( * Create a notification for a Room. */ override suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, tickerText: String, - currentUser: MatrixUser, existingNotification: Notification?, imageLoader: ImageLoader, events: List, - @ColorInt color: Int, ): Notification { // Build the pending intent for when the notification is clicked val eventId = events.firstOrNull()?.eventId @@ -135,7 +133,6 @@ class DefaultNotificationCreator( threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId) else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId) } - val smallIcon = CommonDrawables.ic_notification val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION } val channelId = if (containsMissedCall) { notificationChannels.getChannelForIncomingCall(false) @@ -159,9 +156,6 @@ class DefaultNotificationCreator( setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId)) } } - // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) - // devices and all Wear devices. But we want a custom grouping, so we specify the groupID - .setGroup(roomInfo.sessionId.value) .setGroupSummary(false) // In order to avoid notification making sound twice (due to the summary notification) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) @@ -171,8 +165,8 @@ class DefaultNotificationCreator( val messagingStyle = existingNotification?.let { MessagingStyle.extractMessagingStyleFromNotification(it) - } ?: messagingStyleFromCurrentUser( - user = currentUser, + } ?: createMessagingStyleFromCurrentUser( + user = notificationAccountParams.user, imageLoader = imageLoader, roomName = roomInfo.roomDisplayName, isThread = threadId != null, @@ -187,9 +181,7 @@ class DefaultNotificationCreator( .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. .setStyle(messagingStyle) - .setSmallIcon(smallIcon) - // Set primary color (important for Wear 2.0 Notifications). - .setColor(color) + .configureWith(notificationAccountParams) // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for // 'importance' which is set in the NotificationChannel. The integers representing // 'priority' are different from 'importance', so make sure you don't mix them. @@ -202,7 +194,7 @@ class DefaultNotificationCreator( setSound(it) } */ - setLights(color, 500, 500) + setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW } @@ -234,19 +226,16 @@ class DefaultNotificationCreator( } override fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, inviteNotifiableEvent: InviteNotifiableEvent, - @ColorInt color: Int, ): Notification { - val smallIcon = CommonDrawables.ic_notification val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy) return NotificationCompat.Builder(context, channelId) .setOnlyAlertOnce(true) .setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5)) .setContentText(inviteNotifiableEvent.description.annotateForDebug(6)) - .setGroup(inviteNotifiableEvent.sessionId.value) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) - .setSmallIcon(smallIcon) - .setColor(color) + .configureWith(notificationAccountParams) .apply { addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) @@ -261,7 +250,7 @@ class DefaultNotificationCreator( setSound(it) } */ - setLights(color, 500, 500) + setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW } @@ -277,19 +266,16 @@ class DefaultNotificationCreator( } override fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, simpleNotifiableEvent: SimpleNotifiableEvent, - @ColorInt color: Int, ): Notification { - val smallIcon = CommonDrawables.ic_notification val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy) return NotificationCompat.Builder(context, channelId) .setOnlyAlertOnce(true) .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) .setContentText(simpleNotifiableEvent.description.annotateForDebug(8)) - .setGroup(simpleNotifiableEvent.sessionId.value) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) - .setSmallIcon(smallIcon) - .setColor(color) + .configureWith(notificationAccountParams) .setAutoCancel(true) .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null)) .apply { @@ -301,7 +287,7 @@ class DefaultNotificationCreator( setSound(it) } */ - setLights(color, 500, 500) + setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW } @@ -310,19 +296,16 @@ class DefaultNotificationCreator( } override fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, fallbackNotifiableEvent: FallbackNotifiableEvent, - @ColorInt color: Int, ): Notification { - val smallIcon = CommonDrawables.ic_notification val channelId = notificationChannels.getChannelIdForMessage(false) return NotificationCompat.Builder(context, channelId) .setOnlyAlertOnce(true) .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) .setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8)) - .setGroup(fallbackNotifiableEvent.sessionId.value) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) - .setSmallIcon(smallIcon) - .setColor(color) + .configureWith(notificationAccountParams) .setAutoCancel(true) .setWhen(fallbackNotifiableEvent.timestamp) // Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite @@ -343,24 +326,21 @@ class DefaultNotificationCreator( * Create the summary notification. */ override fun createSummaryListNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, compatSummary: String, noisy: Boolean, lastMessageTimestamp: Long, - @ColorInt color: Int, ): Notification { - val smallIcon = CommonDrawables.ic_notification val channelId = notificationChannels.getChannelIdForMessage(noisy) + val userId = notificationAccountParams.user.userId return NotificationCompat.Builder(context, channelId) .setOnlyAlertOnce(true) // used in compat < N, after summary is built based on child notifications .setWhen(lastMessageTimestamp) .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setSmallIcon(smallIcon) - .setGroup(currentUser.userId.value) // set this notification as the summary for the group .setGroupSummary(true) - .setColor(color) + .configureWith(notificationAccountParams) .apply { if (noisy) { // Compat @@ -370,14 +350,14 @@ class DefaultNotificationCreator( setSound(it) } */ - setLights(color, 500, 500) + setLights(notificationAccountParams.color, 500, 500) } else { // compat priority = NotificationCompat.PRIORITY_LOW } } - .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId)) - .setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId)) + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId)) + .setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(userId)) .build() } @@ -431,34 +411,44 @@ class DefaultNotificationCreator( senderPerson ) else -> { - val message = MessagingStyle.Message( - event.body?.annotateForDebug(71), - event.timestamp, - senderPerson - ).also { message -> - event.imageUri?.let { - message.setData(event.imageMimeType ?: "image/", it) - } - message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value) - } - addMessage(message) - - // Add additional message for captions - if (event.imageUri != null && event.body != null) { - addMessage( - MessagingStyle.Message( - event.body, - event.timestamp, - senderPerson, - ) + if (event.imageMimeType != null && event.imageUri != null) { + // Image case + val message = MessagingStyle.Message( + // This text will not be rendered, but some systems does not render the image + // if the text is null + stringProvider.getString(CommonStrings.common_image), + event.timestamp, + senderPerson, ) + .setData(event.imageMimeType, event.imageUri) + message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value) + addMessage(message) + // Add additional message for captions + if (event.body != null) { + addMessage( + MessagingStyle.Message( + event.body.annotateForDebug(72), + event.timestamp, + senderPerson, + ) + ) + } + } else { + // Text case + val message = MessagingStyle.Message( + event.body?.annotateForDebug(71), + event.timestamp, + senderPerson + ) + message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value) + addMessage(message) } } } } } - private suspend fun messagingStyleFromCurrentUser( + private suspend fun createMessagingStyleFromCurrentUser( user: MatrixUser, imageLoader: ImageLoader, roomName: String, @@ -467,7 +457,8 @@ class DefaultNotificationCreator( ): MessagingStyle { return MessagingStyle( Person.Builder() - .setName(user.displayName?.annotateForDebug(50)) + // Note: name cannot be empty else NotificationCompat.MessagingStyle() will crash + .setName(user.getBestName().annotateForDebug(50)) .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader)) .setKey(user.userId.value) .build() @@ -487,4 +478,13 @@ class DefaultNotificationCreator( } } +private fun NotificationCompat.Builder.configureWith(notificationAccountParams: NotificationAccountParams) = apply { + setSmallIcon(CommonDrawables.ic_notification) + setColor(notificationAccountParams.color) + setGroup(notificationAccountParams.user.userId.value) + if (notificationAccountParams.showSessionId) { + setSubText(notificationAccountParams.user.userId.value) + } +} + fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt index 37d3b32c80..5be67606a4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt @@ -73,7 +73,7 @@ class DefaultOnRedactedEventReceived( oldMessage.person ) messagingStyle.messages[messageToRedactIndex] = newMessage - notificationDisplayer.showNotificationMessage( + notificationDisplayer.showNotification( statusBarNotification.tag, statusBarNotification.id, NotificationCompat.Builder(context, notification) diff --git a/libraries/push/impl/src/nightly/res/raw/message.mp3 b/libraries/push/impl/src/nightly/res/raw/message.mp3 new file mode 100644 index 0000000000..abc056786c Binary files /dev/null and b/libraries/push/impl/src/nightly/res/raw/message.mp3 differ diff --git a/libraries/push/impl/src/main/res/raw/message.mp3 b/libraries/push/impl/src/release/res/raw/message.mp3 similarity index 100% rename from libraries/push/impl/src/main/res/raw/message.mp3 rename to libraries/push/impl/src/release/res/raw/message.mp3 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 694319c9e7..f5acd8ddb4 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 @@ -13,17 +13,17 @@ import androidx.core.app.NotificationCompat import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.NotificationConfig import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.test.A_COLOR_INT 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.test.media.FakeImageLoader 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 import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.test.notifications.FakeImageLoader import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider @@ -44,23 +44,22 @@ class DefaultBaseRoomGroupMessageCreatorTest { val sut = createRoomGroupMessageCreator() val fakeImageLoader = FakeImageLoader() val result = sut.createRoomMessage( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( imageUriString = "aUri", ) ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) assertThat(result.number).isEqualTo(1) @Suppress("DEPRECATION") assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW) assertThat(result.`when`).isEqualTo(A_TIMESTAMP) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test @@ -68,21 +67,20 @@ class DefaultBaseRoomGroupMessageCreatorTest { val sut = createRoomGroupMessageCreator() val fakeImageLoader = FakeImageLoader() val result = sut.createRoomMessage( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( noisy = true, ) ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) @Suppress("DEPRECATION") assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test @@ -130,9 +128,11 @@ class DefaultBaseRoomGroupMessageCreatorTest { sdkIntProvider = FakeBuildVersionSdkIntProvider(api) ) val result = sut.createRoomMessage( - currentUser = aMatrixUser( - // Some user avatar - avatarUrl = A_USER_AVATAR_1, + notificationAccountParams = aNotificationAccountParams( + user = aMatrixUser( + // Some user avatar + avatarUrl = A_USER_AVATAR_1, + ) ), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( @@ -141,13 +141,12 @@ class DefaultBaseRoomGroupMessageCreatorTest { ) ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) assertThat(result.number).isEqualTo(1) - assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests) + assertThat(fakeImageLoader.getExecutedRequestsData()).containsExactlyElementsIn(expectedCoilRequests) } @Test @@ -155,16 +154,15 @@ class DefaultBaseRoomGroupMessageCreatorTest { val sut = createRoomGroupMessageCreator() val fakeImageLoader = FakeImageLoader() val result = sut.createRoomMessage( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP), aNotifiableMessageEvent(timestamp = A_TIMESTAMP + 10), ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) assertThat(result.number).isEqualTo(2) assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10) @@ -175,7 +173,7 @@ class DefaultBaseRoomGroupMessageCreatorTest { QUICK_REPLY_ACTION_TITLE.takeIf { NotificationConfig.SHOW_QUICK_REPLY_ACTION }, ) ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test @@ -183,7 +181,7 @@ class DefaultBaseRoomGroupMessageCreatorTest { val sut = createRoomGroupMessageCreator() val fakeImageLoader = FakeImageLoader() val result = sut.createRoomMessage( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( outGoingMessage = true, @@ -191,10 +189,9 @@ class DefaultBaseRoomGroupMessageCreatorTest { ), ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) val actionTitles = result.actions?.map { it.title } assertThat(actionTitles).isEqualTo( @@ -202,7 +199,7 @@ class DefaultBaseRoomGroupMessageCreatorTest { MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION } ) ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test @@ -210,21 +207,20 @@ class DefaultBaseRoomGroupMessageCreatorTest { val sut = createRoomGroupMessageCreator() val fakeImageLoader = FakeImageLoader() val result = sut.createRoomMessage( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), events = listOf( aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( roomIsDm = true, ), ), roomId = A_ROOM_ID, - imageLoader = fakeImageLoader.getImageLoader(), + imageLoader = fakeImageLoader, existingNotification = null, threadId = null, - color = A_COLOR_INT, ) assertThat(result.number).isEqualTo(1) assertThat(result.`when`).isEqualTo(A_TIMESTAMP) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } } 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 b2a4cc1c9b..0489b72dd3 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 @@ -8,10 +8,10 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification -import androidx.core.app.NotificationManagerCompat +import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -20,13 +20,17 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationIdProvider +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 +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState @@ -38,25 +42,19 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) class DefaultNotificationDrawerManagerTest { @Test fun `clearAllEvents should have no effect when queue is empty`() = runTest { val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() defaultNotificationDrawerManager.clearAllEvents(A_SESSION_ID) - defaultNotificationDrawerManager.destroy() } @Test @@ -64,8 +62,8 @@ class DefaultNotificationDrawerManagerTest { // For now just call all the API. Later, add more valuable tests. val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data") val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( - createRoomMessageResult = lambdaRecorder { user, _, roomId, _, _, existingNotification -> - assertThat(user).isEqualTo(matrixUser) + createRoomMessageResult = lambdaRecorder { notificationAccountParams, _, roomId, _, _, existingNotification -> + assertThat(notificationAccountParams.user).isEqualTo(matrixUser) assertThat(roomId).isEqualTo(A_ROOM_ID) assertThat(existingNotification).isNull() Notification() @@ -88,7 +86,6 @@ class DefaultNotificationDrawerManagerTest { defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) // Add the same Event again (will be ignored) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) - defaultNotificationDrawerManager.destroy() } @Test @@ -101,7 +98,7 @@ class DefaultNotificationDrawerManagerTest { ) ) val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow) - val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + createDefaultNotificationDrawerManager( appNavigationStateService = appNavigationStateService ) appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) @@ -117,17 +114,22 @@ class DefaultNotificationDrawerManagerTest { // Like a user sign out appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) runCurrent() - defaultNotificationDrawerManager.destroy() } @Test - fun `when MatrixClient has no cached user name a fallback one is used to render the notification`() = runTest { - val matrixClient = FakeMatrixClient(userDisplayName = null) + fun `when MatrixClient has no cached user name and avatar, the profile is loaded to render the notification`() = runTest { + val matrixClient = FakeMatrixClient( + userDisplayName = null, + userAvatarUrl = null, + ) val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) val messageCreator = FakeRoomGroupMessageCreator() val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( matrixClientProvider = matrixClientProvider, roomGroupMessageCreator = messageCreator, + enterpriseService = FakeEnterpriseService( + initialBrandColor = Color.Red, + ) ) // Gets a display name from MatrixClient.getUserProfile matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))) @@ -144,27 +146,41 @@ class DefaultNotificationDrawerManagerTest { messageCreator.createRoomMessageResult.assertions() .isCalledExactly(3) .withSequence( - listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any(), any()), - listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any(), any()), listOf( - value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))), + any(), + any(), + any(), + any(), + any(), + ), + listOf( + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = ""))), + any(), + any(), + any(), + any(), + any(), + ), + listOf( + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = null, avatarUrl = null))), + any(), any(), any(), any(), any(), - any() ), ) - - defaultNotificationDrawerManager.destroy() } @Test fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest { - val notificationManager = mockk { - every { cancel(any(), any()) } returns Unit - } + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val notificationDisplayer = FakeNotificationDisplayer( + cancelNotificationResult = cancelNotificationResult, + ) val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val roomMessageId = NotificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID) val activeNotificationsProvider = FakeActiveNotificationsProvider( getSummaryNotificationResult = { mockk { @@ -174,7 +190,7 @@ class DefaultNotificationDrawerManagerTest { countResult = { 1 }, ) val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( - notificationManager = notificationManager, + notificationDisplayer = notificationDisplayer, activeNotificationsProvider = activeNotificationsProvider, ) @@ -182,24 +198,26 @@ class DefaultNotificationDrawerManagerTest { defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) // Verify we asked to cancel the notification with summaryId - verify { notificationManager.cancel(null, summaryId) } - - defaultNotificationDrawerManager.destroy() + cancelNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(null), value(roomMessageId)), + listOf(value(null), value(summaryId)), + ) } private fun TestScope.createDefaultNotificationDrawerManager( - notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()), + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + sessionStore: SessionStore = InMemorySessionStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), ): DefaultNotificationDrawerManager { - val context = RuntimeEnvironment.getApplication() return DefaultNotificationDrawerManager( - notificationManager = notificationManager, + notificationDisplayer = notificationDisplayer, notificationRenderer = NotificationRenderer( - notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)), + notificationDisplayer = FakeNotificationDisplayer(), notificationDataFactory = DefaultNotificationDataFactory( notificationCreator = FakeNotificationCreator(), roomGroupMessageCreator = roomGroupMessageCreator, @@ -207,10 +225,11 @@ class DefaultNotificationDrawerManagerTest { activeNotificationsProvider = activeNotificationsProvider, stringProvider = FakeStringProvider(), ), - enterpriseService = FakeEnterpriseService(), + enterpriseService = enterpriseService, + sessionStore = sessionStore, ), appNavigationStateService = appNavigationStateService, - coroutineScope = this, + coroutineScope = backgroundScope, matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), activeNotificationsProvider = activeNotificationsProvider, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt index 8b668a7f77..f2b26b3437 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt @@ -7,7 +7,6 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.features.enterprise.test.FakeEnterpriseService 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 @@ -16,23 +15,19 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.aNotificationData +import io.element.android.libraries.matrix.ui.test.media.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver -import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) class DefaultOnMissedCallNotificationHandlerTest { @OptIn(ExperimentalCoroutinesApi::class) @Test @@ -52,11 +47,9 @@ class DefaultOnMissedCallNotificationHandlerTest { val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler( matrixClientProvider = matrixClientProvider, defaultNotificationDrawerManager = DefaultNotificationDrawerManager( - notificationManager = mockk(relaxed = true), - notificationRenderer = NotificationRenderer( - notificationDisplayer = FakeNotificationDisplayer(), + notificationDisplayer = FakeNotificationDisplayer(), + notificationRenderer = createNotificationRenderer( notificationDataFactory = dataFactory, - enterpriseService = FakeEnterpriseService(), ), appNavigationStateService = FakeAppNavigationStateService(), coroutineScope = backgroundScope, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt index e34ea0848c..f701dae6c7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -10,9 +10,8 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import androidx.core.app.NotificationCompat import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.test.A_COLOR_INT import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP @@ -34,7 +33,7 @@ class DefaultSummaryGroupMessageCreatorTest { ) val result = summaryCreator.createSummaryNotification( - currentUser = aMatrixUser(), + notificationAccountParams = aNotificationAccountParams(), roomNotifications = listOf( RoomNotification( notification = Notification(), @@ -49,12 +48,11 @@ class DefaultSummaryGroupMessageCreatorTest { invitationNotifications = emptyList(), simpleNotifications = emptyList(), fallbackNotifications = emptyList(), - color = A_COLOR_INT, ) notificationCreator.createSummaryListNotificationResult.assertions() .isCalledOnce() - .with(any(), nonNull(), any(), any()) + .with(any(), any(), nonNull(), any(), any()) // Set from the events included @Suppress("DEPRECATION") 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 f563106c14..6971fcbc41 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 @@ -11,9 +11,10 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.EventId 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_COLOR_INT 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.test.media.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 import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator @@ -21,7 +22,6 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGrou import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.libraries.push.test.notifications.FakeImageLoader import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.test.runTest import org.junit.Test @@ -51,16 +51,18 @@ class NotificationDataFactoryTest { @Test fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) { - val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( + aNotificationAccountParams(), + AN_INVITATION_EVENT, + ) val roomInvitation = listOf(AN_INVITATION_EVENT) - - val result = toNotifications(roomInvitation, A_COLOR_INT) + val result = toNotifications(roomInvitation, aNotificationAccountParams()) assertThat(result).isEqualTo( listOf( OneShotNotification( notification = expectedNotification, - key = A_ROOM_ID.value, + tag = A_ROOM_ID.value, summaryLine = AN_INVITATION_EVENT.description, isNoisy = AN_INVITATION_EVENT.noisy, timestamp = AN_INVITATION_EVENT.timestamp @@ -71,20 +73,18 @@ class NotificationDataFactoryTest { @Test fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { - val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) - val roomInvitation = listOf(A_SIMPLE_EVENT) - - val result = toNotifications(roomInvitation, A_COLOR_INT) - - assertThat(result).isEqualTo( - listOf( - OneShotNotification( - notification = expectedNotification, - key = AN_EVENT_ID.value, - summaryLine = A_SIMPLE_EVENT.description, - isNoisy = A_SIMPLE_EVENT.noisy, - timestamp = AN_INVITATION_EVENT.timestamp - ) + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( + aNotificationAccountParams(), + AN_INVITATION_EVENT, + ) + val result = toNotifications(listOf(A_SIMPLE_EVENT), aNotificationAccountParams()) + assertThat(result).containsExactly( + OneShotNotification( + notification = expectedNotification, + tag = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp ) ) } @@ -94,13 +94,14 @@ class NotificationDataFactoryTest { val events = listOf(A_MESSAGE_EVENT) val expectedNotification = RoomNotification( notification = fakeRoomGroupMessageCreator.createRoomMessage( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), events = events, roomId = A_ROOM_ID, threadId = null, - imageLoader = FakeImageLoader().getImageLoader(), + imageLoader = FakeImageLoader(), existingNotification = null, - color = A_COLOR_INT, ), roomId = A_ROOM_ID, summaryLine = "A room name: Bob Hello world!", @@ -109,35 +110,33 @@ class NotificationDataFactoryTest { shouldBing = events.any { it.noisy }, threadId = null, ) - val roomWithMessage = listOf(A_MESSAGE_EVENT) - val fakeImageLoader = FakeImageLoader() val result = toNotifications( - messages = roomWithMessage, - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - color = A_COLOR_INT, + messages = listOf(A_MESSAGE_EVENT), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, ) assertThat(result.size).isEqualTo(1) assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) { - val redactedRoom = listOf(A_MESSAGE_EVENT.copy(isRedacted = true)) - + val redactedRoom = A_MESSAGE_EVENT.copy(isRedacted = true) val fakeImageLoader = FakeImageLoader() val result = toNotifications( - messages = redactedRoom, - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - color = A_COLOR_INT, + messages = listOf(redactedRoom), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, ) - assertThat(result).isEmpty() - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } @Test @@ -151,13 +150,14 @@ class NotificationDataFactoryTest { val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) val expectedNotification = RoomNotification( notification = fakeRoomGroupMessageCreator.createRoomMessage( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), events = withRedactedRemoved, roomId = A_ROOM_ID, threadId = null, - imageLoader = FakeImageLoader().getImageLoader(), + imageLoader = FakeImageLoader(), existingNotification = null, - color = A_COLOR_INT, ), roomId = A_ROOM_ID, summaryLine = "A room name: Bob Hello world!", @@ -170,14 +170,15 @@ class NotificationDataFactoryTest { val fakeImageLoader = FakeImageLoader() val result = toNotifications( messages = roomWithRedactedMessage, - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - color = A_COLOR_INT, + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, ) assertThat(result.size).isEqualTo(1) assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() } } 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 589d7876f5..00ce37b09b 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 @@ -7,14 +7,17 @@ package io.element.android.libraries.push.impl.notifications +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService 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.test.media.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 +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator @@ -23,7 +26,8 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.test.notifications.FakeImageLoader +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -38,7 +42,7 @@ private const val USE_COMPLETE_NOTIFICATION_FORMAT = true private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) private val ONE_SHOT_NOTIFICATION = - OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) @RunWith(RobolectricTestRunner::class) class NotificationRendererTest { @@ -56,10 +60,9 @@ class NotificationRendererTest { ) private val notificationIdProvider = NotificationIdProvider - private val notificationRenderer = NotificationRenderer( + private val notificationRenderer = createNotificationRenderer( notificationDisplayer = notificationDisplayer, notificationDataFactory = notificationDataFactory, - enterpriseService = FakeEnterpriseService(), ) @Test @@ -75,7 +78,7 @@ class NotificationRendererTest { renderEventsAsNotifications(listOf(aNotifiableMessageEvent())) - notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) @@ -83,11 +86,11 @@ class NotificationRendererTest { @Test fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { - notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + notificationCreator.createSimpleNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification } renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))) - notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) @@ -95,11 +98,11 @@ class NotificationRendererTest { @Test fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { - notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification } renderEventsAsNotifications(listOf(anInviteNotifiableEvent())) - notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) @@ -110,7 +113,19 @@ class NotificationRendererTest { MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, eventsToProcess = events, - imageLoader = FakeImageLoader().getImageLoader(), + imageLoader = FakeImageLoader(), ) } } + +fun createNotificationRenderer( + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + notificationDataFactory: NotificationDataFactory = FakeNotificationDataFactory(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionStore: SessionStore = InMemorySessionStore(), +) = NotificationRenderer( + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, + enterpriseService = enterpriseService, + sessionStore = sessionStore, +) 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 d05966f9a6..82441883a7 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 @@ -20,9 +20,9 @@ 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.test.media.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.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import kotlinx.coroutines.ExperimentalCoroutinesApi 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 045cc0492d..419a348f08 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 @@ -22,6 +22,7 @@ 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.test.media.FakeImageLoader 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 @@ -36,7 +37,6 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.libraries.push.test.notifications.FakeImageLoader import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP @@ -65,6 +65,7 @@ class DefaultNotificationCreatorTest { fun `test createFallbackNotification`() { val sut = createNotificationCreator() val result = sut.createFallbackNotification( + notificationAccountParams = aNotificationAccountParams(), FallbackNotifiableEvent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -77,7 +78,6 @@ class DefaultNotificationCreatorTest { timestamp = A_FAKE_TIMESTAMP, cause = null, ), - color = A_COLOR_INT, ) result.commonAssertions( expectedCategory = null, @@ -88,6 +88,7 @@ class DefaultNotificationCreatorTest { fun `test createSimpleEventNotification`() { val sut = createNotificationCreator() val result = sut.createSimpleEventNotification( + notificationAccountParams = aNotificationAccountParams(), SimpleNotifiableEvent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -103,7 +104,6 @@ class DefaultNotificationCreatorTest { isRedacted = false, isUpdated = false, ), - color = A_COLOR_INT, ) result.commonAssertions( expectedCategory = null, @@ -114,6 +114,7 @@ class DefaultNotificationCreatorTest { fun `test createSimpleEventNotification noisy`() { val sut = createNotificationCreator() val result = sut.createSimpleEventNotification( + notificationAccountParams = aNotificationAccountParams(), SimpleNotifiableEvent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -129,7 +130,6 @@ class DefaultNotificationCreatorTest { isRedacted = false, isUpdated = false, ), - color = A_COLOR_INT, ) result.commonAssertions( expectedCategory = null, @@ -140,6 +140,7 @@ class DefaultNotificationCreatorTest { fun `test createRoomInvitationNotification`() { val sut = createNotificationCreator() val result = sut.createRoomInvitationNotification( + notificationAccountParams = aNotificationAccountParams(), InviteNotifiableEvent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -156,7 +157,6 @@ class DefaultNotificationCreatorTest { isUpdated = false, roomName = "roomName", ), - color = A_COLOR_INT, ) result.commonAssertions( expectedCategory = null, @@ -174,6 +174,7 @@ class DefaultNotificationCreatorTest { fun `test createRoomInvitationNotification noisy`() { val sut = createNotificationCreator() val result = sut.createRoomInvitationNotification( + notificationAccountParams = aNotificationAccountParams(), InviteNotifiableEvent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -190,7 +191,6 @@ class DefaultNotificationCreatorTest { isUpdated = false, roomName = "roomName", ), - color = A_COLOR_INT, ) result.commonAssertions( expectedCategory = null, @@ -202,11 +202,10 @@ class DefaultNotificationCreatorTest { val sut = createNotificationCreator() val matrixUser = aMatrixUser() val result = sut.createSummaryListNotification( - currentUser = matrixUser, + notificationAccountParams = aNotificationAccountParams(user = matrixUser), compatSummary = "compatSummary", noisy = false, lastMessageTimestamp = 123_456L, - color = A_COLOR_INT, ) result.commonAssertions( expectedGroup = matrixUser.userId.value, @@ -218,11 +217,10 @@ class DefaultNotificationCreatorTest { val sut = createNotificationCreator() val matrixUser = aMatrixUser() val result = sut.createSummaryListNotification( - currentUser = matrixUser, + notificationAccountParams = aNotificationAccountParams(user = matrixUser), compatSummary = "compatSummary", noisy = true, lastMessageTimestamp = 123_456L, - color = A_COLOR_INT, ) result.commonAssertions( expectedGroup = matrixUser.userId.value, @@ -232,8 +230,8 @@ class DefaultNotificationCreatorTest { @Test fun `test createMessagesListNotification`() = runTest { val sut = createNotificationCreator() - aMatrixUser() val result = sut.createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -247,11 +245,9 @@ class DefaultNotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", - currentUser = aMatrixUser(), existingNotification = null, - imageLoader = FakeImageLoader().getImageLoader(), + imageLoader = FakeImageLoader(), events = listOf(aNotifiableMessageEvent()), - color = A_COLOR_INT, ) result.commonAssertions() } @@ -259,8 +255,8 @@ class DefaultNotificationCreatorTest { @Test fun `test createMessagesListNotification should bing and thread`() = runTest { val sut = createNotificationCreator() - aMatrixUser() val result = sut.createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -274,17 +270,15 @@ class DefaultNotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", - currentUser = aMatrixUser(), existingNotification = null, - imageLoader = FakeImageLoader().getImageLoader(), + imageLoader = FakeImageLoader(), events = listOf(aNotifiableMessageEvent()), - color = A_COLOR_INT, ) result.commonAssertions() } private fun Notification.commonAssertions( - expectedGroup: String? = A_SESSION_ID.value, + expectedGroup: String? = aMatrixUser().userId.value, expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE, ) { assertThat(contentIntent).isNotNull() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt new file mode 100644 index 0000000000..eca910b07e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import androidx.annotation.ColorInt +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_COLOR_INT +import io.element.android.libraries.matrix.ui.components.aMatrixUser + +fun aNotificationAccountParams( + user: MatrixUser = aMatrixUser(), + @ColorInt color: Int = A_COLOR_INT, + showSessionId: Boolean = false, +) = NotificationAccountParams( + user = user, + color = color, + showSessionId = showSessionId, +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt index 1cc4468b15..3a4acb757f 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -12,81 +12,84 @@ import android.graphics.Bitmap import androidx.annotation.ColorInt import coil3.ImageLoader import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder -import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.lambdaAnyRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeNotificationCreator( var createMessagesListNotificationResult: LambdaListAnyParamsRecorder = lambdaAnyRecorder { A_NOTIFICATION }, - var createRoomInvitationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, - var createSimpleNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, - var createFallbackNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, - var createSummaryListNotificationResult: LambdaFourParamsRecorder = - lambdaRecorder { _, _, _, _ -> A_NOTIFICATION }, - var createDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { A_NOTIFICATION }, + var createRoomInvitationNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createSimpleNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createSummaryListNotificationResult: LambdaFiveParamsRecorder< + NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification + > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, + var createDiagnosticNotificationResult: LambdaOneParamRecorder = + lambdaRecorder { _ -> A_NOTIFICATION }, ) : NotificationCreator { override suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, tickerText: String, - currentUser: MatrixUser, existingNotification: Notification?, imageLoader: ImageLoader, events: List, - @ColorInt color: Int, ): Notification { return createMessagesListNotificationResult( - listOf(roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, currentUser, existingNotification, imageLoader, events) + listOf(notificationAccountParams, roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, existingNotification, imageLoader, events) ) } override fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, inviteNotifiableEvent: InviteNotifiableEvent, - @ColorInt color: Int, ): Notification { - return createRoomInvitationNotificationResult(inviteNotifiableEvent) + return createRoomInvitationNotificationResult(notificationAccountParams, inviteNotifiableEvent) } override fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, simpleNotifiableEvent: SimpleNotifiableEvent, - @ColorInt color: Int, ): Notification { - return createSimpleNotificationResult(simpleNotifiableEvent) + return createSimpleNotificationResult(notificationAccountParams, simpleNotifiableEvent) } override fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, fallbackNotifiableEvent: FallbackNotifiableEvent, - @ColorInt color: Int, ): Notification { - return createFallbackNotificationResult(fallbackNotifiableEvent) + return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent) } override fun createSummaryListNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, compatSummary: String, noisy: Boolean, lastMessageTimestamp: Long, - @ColorInt color: Int, ): Notification { - return createSummaryListNotificationResult(currentUser, compatSummary, noisy, lastMessageTimestamp) + return createSummaryListNotificationResult(notificationAccountParams, compatSummary, noisy, lastMessageTimestamp, notificationAccountParams) } override fun createDiagnosticNotification( @ColorInt color: Int, ): Notification { - return createDiagnosticNotificationResult() + return createDiagnosticNotificationResult(color) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt index 9a0a5fe7ef..a897dbfc09 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -7,13 +7,12 @@ package io.element.android.libraries.push.impl.notifications.fake -import androidx.annotation.ColorInt import coil3.ImageLoader -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.NotificationDataFactory import io.element.android.libraries.push.impl.notifications.OneShotNotification import io.element.android.libraries.push.impl.notifications.RoomNotification import io.element.android.libraries.push.impl.notifications.SummaryNotification +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -25,14 +24,15 @@ import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeNotificationDataFactory( - var messageEventToNotificationsResult: LambdaThreeParamsRecorder, MatrixUser, ImageLoader, List> = - lambdaRecorder { _, _, _ -> emptyList() }, + var messageEventToNotificationsResult: LambdaThreeParamsRecorder< + List, ImageLoader, NotificationAccountParams, List + > = lambdaRecorder { _, _, _ -> emptyList() }, var summaryToNotificationsResult: LambdaFiveParamsRecorder< - MatrixUser, List, List, List, List, + NotificationAccountParams, SummaryNotification > = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, @@ -42,18 +42,17 @@ class FakeNotificationDataFactory( ) : NotificationDataFactory { override suspend fun toNotifications( messages: List, - currentUser: MatrixUser, imageLoader: ImageLoader, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { - return messageEventToNotificationsResult(messages, currentUser, imageLoader) + return messageEventToNotificationsResult(messages, imageLoader, notificationAccountParams) } @JvmName("toNotificationInvites") @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( invites: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return inviteToNotificationsResult(invites) } @@ -62,7 +61,7 @@ class FakeNotificationDataFactory( @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( simpleEvents: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return simpleEventToNotificationsResult(simpleEvents) } @@ -71,25 +70,24 @@ class FakeNotificationDataFactory( @Suppress("INAPPLICABLE_JVM_NAME") override fun toNotifications( fallback: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): List { return fallbackEventToNotificationsResult(fallback) } override fun createSummaryNotification( - currentUser: MatrixUser, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, + notificationAccountParams: NotificationAccountParams, ): SummaryNotification { return summaryToNotificationsResult( - currentUser, roomNotifications, invitationNotifications, simpleNotifications, fallbackNotifications, + notificationAccountParams, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt index cd3d047e2e..d1c5de9ffb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -19,17 +19,17 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value class FakeNotificationDisplayer( - var showNotificationMessageResult: LambdaThreeParamsRecorder = lambdaRecorder { _, _, _ -> true }, - var cancelNotificationMessageResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, + var showNotificationResult: LambdaThreeParamsRecorder = lambdaRecorder { _, _, _ -> true }, + var cancelNotificationResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> }, ) : NotificationDisplayer { - override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { - return showNotificationMessageResult(tag, id, notification) + override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { + return showNotificationResult(tag, id, notification) } - override fun cancelNotificationMessage(tag: String?, id: Int) { - return cancelNotificationMessageResult(tag, id) + override fun cancelNotification(tag: String?, id: Int) { + return cancelNotificationResult(tag, id) } override fun displayDiagnosticNotification(notification: Notification): Boolean { @@ -41,7 +41,7 @@ class FakeNotificationDisplayer( } fun verifySummaryCancelled(times: Int = 1) { - cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence( + cancelNotificationResult.assertions().isCalledExactly(times).withSequence( listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID))) ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt index 351300937b..6bb5b63977 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -8,12 +8,11 @@ package io.element.android.libraries.push.impl.notifications.fake import android.app.Notification -import androidx.annotation.ColorInt import coil3.ImageLoader import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder @@ -22,18 +21,18 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder // We just can't make the param types fit @Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping") class FakeRoomGroupMessageCreator( - var createRoomMessageResult: LambdaSixParamsRecorder, RoomId, ThreadId?, ImageLoader, Notification?, Notification> = - lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } + var createRoomMessageResult: LambdaSixParamsRecorder< + NotificationAccountParams, List, RoomId, ThreadId?, ImageLoader, Notification?, Notification + > = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } ) : RoomGroupMessageCreator { override suspend fun createRoomMessage( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, events: List, roomId: RoomId, threadId: ThreadId?, imageLoader: ImageLoader, existingNotification: Notification?, - @ColorInt color: Int, ): Notification { - return createRoomMessageResult(currentUser, events, roomId, threadId, imageLoader, existingNotification) + return createRoomMessageResult(notificationAccountParams, events, roomId, threadId, imageLoader, existingNotification) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt index bc8a5515c9..8a1e8bb4ed 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -8,30 +8,28 @@ package io.element.android.libraries.push.impl.notifications.fake import android.app.Notification -import androidx.annotation.ColorInt -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.OneShotNotification import io.element.android.libraries.push.impl.notifications.RoomNotification import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeSummaryGroupMessageCreator( var createSummaryNotificationResult: LambdaFiveParamsRecorder< - MatrixUser, List, List, List, List, Notification> = + NotificationAccountParams, List, List, List, List, Notification> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } ) : SummaryGroupMessageCreator { override fun createSummaryNotification( - currentUser: MatrixUser, + notificationAccountParams: NotificationAccountParams, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, fallbackNotifications: List, - @ColorInt color: Int, ): Notification { return createSummaryNotificationResult( - currentUser, + notificationAccountParams, roomNotifications, invitationNotifications, simpleNotifications, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt index b27c96d8de..71ec7c3545 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt @@ -122,7 +122,7 @@ class DefaultOnRedactedEventReceivedTest { } ) }, - displayer = FakeNotificationDisplayer(showNotificationMessageResult = showNotificationLambda), + displayer = FakeNotificationDisplayer(showNotificationResult = showNotificationLambda), ) sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoader.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoader.kt deleted file mode 100644 index 067376cfbc..0000000000 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeImageLoader.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.test.notifications - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import coil3.ImageLoader -import coil3.test.FakeImageLoaderEngine -import coil3.test.intercept -import org.robolectric.RuntimeEnvironment - -class FakeImageLoader { - private val coilRequests = mutableListOf() - - private var cache: ImageLoader? = null - - fun getImageLoader(): ImageLoader { - return cache ?: ImageLoader.Builder(RuntimeEnvironment.getApplication()) - .components { - val engine = FakeImageLoaderEngine.Builder() - .intercept( - predicate = { - coilRequests.add(it) - true - }, - drawable = ColorDrawable(Color.BLUE) - ) - .build() - add(engine) - } - .build() - .also { - cache = it - } - } - - fun getCoilRequests(): List { - return coilRequests.toList() - } -} 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 ffd3fda5c0..841b955018 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 @@ -87,7 +87,7 @@ class RoomSelectPresenter( query = searchQuery, isSearchActive = isSearchActive, selectedRooms = selectedRooms, - eventSink = { handleEvents(it) } + eventSink = ::handleEvents, ) } } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 7900b4d90d..f3b66d73c0 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -50,6 +50,11 @@ interface SessionStore { */ suspend fun getAllSessions(): List + /** + * Get the number of sessions. + */ + suspend fun numberOfSessions(): Int + /** * Get the latest session, or null if no session exists. */ diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 0560d3076a..81353f5305 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -161,6 +161,15 @@ class DatabaseSessionStore( } } + override suspend fun numberOfSessions(): Int { + return sessionDataMutex.withLock { + database.sessionDataQueries.count() + .executeAsOneOrNull() + ?.toInt() + ?: 0 + } + } + override fun sessionsFlow(): Flow> { return database.sessionDataQueries.selectAll() .asFlow() diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 53d07bfba3..b61c746fb8 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -47,6 +47,9 @@ SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1; selectAll: SELECT * FROM SessionData ORDER BY lastUsageIndex DESC; +count: +SELECT count(*) FROM SessionData; + selectByUserId: SELECT * FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index d1fef8a39a..f28d9e21df 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -52,6 +52,7 @@ class DatabaseSessionStoreTest { assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData) assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) } @Test @@ -109,6 +110,7 @@ class DatabaseSessionStoreTest { assertThat(foundSession).isEqualTo(aSessionData) assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2) } @Test @@ -196,12 +198,16 @@ class DatabaseSessionStoreTest { position = 1, lastUsageIndex = 1, ) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) databaseSessionStore.addSession(secondSessionData.toApiModel()) assertThat(awaitItem().size).isEqualTo(2) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2) databaseSessionStore.removeSession(aSessionData.userId) assertThat(awaitItem().size).isEqualTo(1) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) databaseSessionStore.removeSession(secondSessionData.userId) assertThat(awaitItem()).isEmpty() + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(0) } } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index c8f3078e7a..00dd9ad9c0 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -67,6 +67,10 @@ class InMemorySessionStore( return sessionDataListFlow.value } + override suspend fun numberOfSessions(): Int { + return sessionDataListFlow.value.size + } + override suspend fun getLatestSession(): SessionData? { return sessionDataListFlow.value.firstOrNull() } diff --git a/libraries/ui-utils/build.gradle.kts b/libraries/ui-utils/build.gradle.kts index 95ce3d21a1..227bdd7f49 100644 --- a/libraries/ui-utils/build.gradle.kts +++ b/libraries/ui-utils/build.gradle.kts @@ -13,11 +13,11 @@ plugins { android { namespace = "io.element.android.libraries.ui.utils" - - dependencies { - implementation(projects.libraries.androidutils) - implementation(projects.services.toolbox.impl) - - testCommonDependencies(libs) - } +} + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.services.toolbox.impl) + + testCommonDependencies(libs) } diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt index c7811f6d17..74c6aae39a 100644 --- a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt @@ -84,7 +84,7 @@ class VoiceMessagePresenter( } } - fun eventSink(event: VoiceMessageEvents) { + fun handleEvent(event: VoiceMessageEvents) { when (event) { is VoiceMessageEvents.PlayPause -> { if (playerState.isPlaying) { @@ -119,7 +119,7 @@ class VoiceMessagePresenter( progress = progress, time = time, showCursor = showCursor, - eventSink = { eventSink(it) }, + eventSink = ::handleEvent, ) } } diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/SessionWellknownRetriever.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/SessionWellknownRetriever.kt index 1c5570db94..2a986fcc28 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/SessionWellknownRetriever.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/SessionWellknownRetriever.kt @@ -8,6 +8,5 @@ package io.element.android.libraries.wellknown.api interface SessionWellknownRetriever { - suspend fun getWellKnown(): WellknownRetrieverResult suspend fun getElementWellKnown(): WellknownRetrieverResult } diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellKnown.kt deleted file mode 100644 index 59f63d1655..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellKnown.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.wellknown.api - -data class WellKnown( - val homeServer: WellKnownBaseConfig?, - val identityServer: WellKnownBaseConfig?, -) - -data class WellKnownBaseConfig( - val baseURL: String? -) diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt index 4675a0cb18..5c146fbbad 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt @@ -8,6 +8,5 @@ package io.element.android.libraries.wellknown.api interface WellknownRetriever { - suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult } diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt index ad463bd2d2..a4b394a427 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt @@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.SessionWellknownRetriever -import io.element.android.libraries.wellknown.api.WellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import timber.log.Timber @@ -26,17 +25,6 @@ class DefaultSessionWellknownRetriever( ) : SessionWellknownRetriever { private val domain by lazy { matrixClient.userIdServerName() } - override suspend fun getWellKnown(): WellknownRetrieverResult { - val url = "https://$domain/.well-known/matrix/client" - return matrixClient - .getUrl(url) - .mapCatchingExceptions { - val data = String(it) - json().decodeFromString(data).map() - } - .toWellknownRetrieverResult() - } - override suspend fun getElementWellKnown(): WellknownRetrieverResult { val url = "https://$domain/.well-known/element/element.json" return matrixClient diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt index a6014f773c..1312436654 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt @@ -13,7 +13,6 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellKnown import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import retrofit2.HttpException @@ -24,27 +23,6 @@ import java.net.HttpURLConnection class DefaultWellknownRetriever( private val retrofitFactory: RetrofitFactory, ) : WellknownRetriever { - override suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult { - return buildWellknownApi(baseUrl) - .map { wellknownApi -> - try { - val result = wellknownApi.getWellKnown().map() - WellknownRetrieverResult.Success(result) - } catch (e: Exception) { - Timber.e(e, "Failed to retrieve well-known data for $baseUrl") - if ((e as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Error(e) - } - } - } - .fold( - onSuccess = { it }, - onFailure = { WellknownRetrieverResult.Error(it as Exception) } - ) - } - override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult { return buildWellknownApi(baseUrl) .map { wellknownApi -> diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index 169757caa9..3b705e09c8 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -8,8 +8,6 @@ package io.element.android.libraries.wellknown.impl import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellKnown -import io.element.android.libraries.wellknown.api.WellKnownBaseConfig internal fun InternalElementWellKnown.map() = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, @@ -17,12 +15,3 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( rageshakeUrl = rageshakeUrl, brandColor = brandColor, ) - -internal fun InternalWellKnown.map() = WellKnown( - homeServer = homeServer?.map(), - identityServer = identityServer?.map(), -) - -internal fun InternalWellKnownBaseConfig.map() = WellKnownBaseConfig( - baseURL = baseURL, -) diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index 12b961ff9e..63dca38aac 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -12,8 +12,6 @@ import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellKnown -import io.element.android.libraries.wellknown.api.WellKnownBaseConfig import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -21,142 +19,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test class DefaultSessionWellknownRetrieverTest { - @Test - fun `get empty wellknown`() = runTest { - val getUrlLambda = lambdaRecorder> { - Result.success("{}".toByteArray()) - } - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = getUrlLambda, - ) - assertThat(sut.getWellKnown()).isEqualTo( - WellknownRetrieverResult.Success( - WellKnown( - homeServer = null, - identityServer = null, - ) - ) - ) - getUrlLambda.assertions().isCalledOnce() - .with(value("https://user.domain.org/.well-known/matrix/client")) - } - - @Test - fun `get wellknown with full content`() = runTest { - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = { - Result.success( - """{ - "m.homeserver": { - "base_url": "https://example.org" - }, - "m.identity_server": { - "base_url": "https://identity.example.org" - } - }""".trimIndent().toByteArray() - ) - } - ) - assertThat(sut.getWellKnown()).isEqualTo( - WellknownRetrieverResult.Success( - WellKnown( - homeServer = WellKnownBaseConfig( - baseURL = "https://example.org", - ), - identityServer = WellKnownBaseConfig( - baseURL = "https://identity.example.org", - ), - ) - ) - ) - } - - @Test - fun `get wellknown with full content empty base_url`() = runTest { - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = { - Result.success( - """{ - "m.homeserver": { - "base_url": "https://example.org" - }, - "m.identity_server": {} - }""".trimIndent().toByteArray() - ) - } - ) - assertThat(sut.getWellKnown()).isEqualTo( - WellknownRetrieverResult.Success( - WellKnown( - homeServer = WellKnownBaseConfig( - baseURL = "https://example.org", - ), - identityServer = WellKnownBaseConfig( - baseURL = null, - ), - ) - ) - ) - } - - @Test - fun `get wellknown with unknown key`() = runTest { - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = { - Result.success( - """{ - "m.homeserver": { - "base_url": "https://example.org" - }, - "m.identity_server": { - "base_url": "https://identity.example.org" - }, - "other": true - }""".trimIndent().toByteArray() - ) - }, - ) - assertThat(sut.getWellKnown()).isEqualTo( - WellknownRetrieverResult.Success( - WellKnown( - homeServer = WellKnownBaseConfig( - baseURL = "https://example.org", - ), - identityServer = WellKnownBaseConfig( - baseURL = "https://identity.example.org", - ), - ) - ) - ) - } - - @Test - fun `get wellknown json error`() = runTest { - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = { - Result.success( - """{ - "m.homeserver": { - "base_url": "https://example.org" - }, - error - }""".trimIndent().toByteArray() - ) - } - ) - assertThat(sut.getWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get wellknown network error`() = runTest { - val sut = createDefaultSessionWellknownRetriever( - getUrlLambda = { - Result.failure(AN_EXCEPTION) - } - ) - assertThat(sut.getWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - @Test fun `get empty element wellknown`() = runTest { val getUrlLambda = lambdaRecorder> { diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeSessionWellknownRetriever.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeSessionWellknownRetriever.kt index d90a02ac96..5ab66701be 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeSessionWellknownRetriever.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeSessionWellknownRetriever.kt @@ -9,18 +9,12 @@ package io.element.android.features.wellknown.test import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.SessionWellknownRetriever -import io.element.android.libraries.wellknown.api.WellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.simulateLongTask class FakeSessionWellknownRetriever( - private val getWellKnownResult: () -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound }, private val getElementWellKnownResult: () -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound }, ) : SessionWellknownRetriever { - override suspend fun getWellKnown(): WellknownRetrieverResult = simulateLongTask { - getWellKnownResult() - } - override suspend fun getElementWellKnown(): WellknownRetrieverResult = simulateLongTask { getElementWellKnownResult() } diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt index 52bb1cfa4c..4c4a31a572 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt @@ -8,19 +8,13 @@ package io.element.android.features.wellknown.test import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellKnown import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.simulateLongTask class FakeWellknownRetriever( - private val getWellKnownResult: (String) -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound }, private val getElementWellKnownResult: (String) -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound }, ) : WellknownRetriever { - override suspend fun getWellKnown(baseUrl: String): WellknownRetrieverResult = simulateLongTask { - getWellKnownResult(baseUrl) - } - override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult = simulateLongTask { getElementWellKnownResult(baseUrl) } diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 819554ae55..d82e203e9b 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -44,7 +44,7 @@ private const val versionMonth = 11 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { /** diff --git a/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Day_3_en.png index c913d383ca..7866726430 100644 --- a/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c6ab936964323971e9b4927684e3ae2da96884b340569aebe6e6a711bd6c241 -size 8446 +oid sha256:9bd3fe043d7fffab7b19b5af9de7ef58ce8d2d97018a058b5b3c94cb55b277b9 +size 11273 diff --git a/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Night_3_en.png index 33799bf61d..ff6610c109 100644 --- a/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.forward.impl_ForwardMessagesView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd3611749f6ad60bc647236c5a10694b8ac9d1aa84e546740c1bf85df277c6f9 -size 7668 +oid sha256:97bc2d4c9621a21533da2d35abe90a5e1c1ef1bc356a6342736b61f7be8d8e3b +size 10357 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png new file mode 100644 index 0000000000..7bf8eee433 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd6ddafdb4bf56b2d79958fbff2cc6afb384d612bc3850a0fd4c0f4e9368abea +size 23346 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png new file mode 100644 index 0000000000..3ff036987d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cf3c48bc7e54774134d819053e2e63d7e0a0b053c2364d54a39452d3c77b794 +size 22896 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_0_en.png new file mode 100644 index 0000000000..24c00cb1a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a573dcac3bab78db152da81c0a1b7803fcf70c2392cc05a20ae533edfde76730 +size 21620 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_1_en.png new file mode 100644 index 0000000000..13d9ea54d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a17f1005bf296f04a2d631c6d19313bc864ebcd6832a788f8fa6909b7562dcb5 +size 17874 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_2_en.png new file mode 100644 index 0000000000..f408da9f38 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc34deb76678be310df0beb512d91fa3b6edf2d2c59a7daf46267f1c8012e12c +size 25422 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_3_en.png new file mode 100644 index 0000000000..189eb42ad0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14d4cabaf98490e35bb75a12c3d37e3c499158b6d6b639084059ee1bc999e831 +size 26018 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_0_en.png new file mode 100644 index 0000000000..d163b60818 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b13402b213b595b8a0a632eeb96263db7e8f7cbd3f56d8d235c38abf6df66128 +size 21235 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_1_en.png new file mode 100644 index 0000000000..77db74b048 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7031872033a6c12acd7328a5ae0820ac2dfa00340f8a8b6cac746c98004b285c +size 17444 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_2_en.png new file mode 100644 index 0000000000..9d45abb1c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:053e0d74fb1c01e5e434aa48606b5f0d24fb9a950b56555783b618679c4e7ad5 +size 24925 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_3_en.png new file mode 100644 index 0000000000..9c36d80cef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.settings_SpaceSettingsView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d201e92db03d2d0d1b7d786d472d5d0f3e946ba7b610b9769ceb597b4d827eb9 +size 25467 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 456de15c83..4f57a9a822 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -210,7 +210,8 @@ { "name" : ":features:space:impl", "includeRegex" : [ - "screen\\.leave_space\\..*" + "screen\\.leave_space\\..*", + "screen\\.space_settings\\..*" ] }, {