Merge branch 'develop' into feature/fga/role_and_permissions_rework

This commit is contained in:
ganfra 2025-11-05 20:29:04 +01:00
commit 79cf41d59c
185 changed files with 1900 additions and 1125 deletions

View file

@ -53,7 +53,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-debug name: elementx-debug
path: | path: |
@ -61,7 +61,7 @@ jobs:
app/build/outputs/apk/fdroid/debug/*-universal-debug.apk app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
- name: Upload x86_64 APK for Maestro - name: Upload x86_64 APK for Maestro
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
path: | path: |

View file

@ -61,7 +61,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs - name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-enterprise-debug name: elementx-enterprise-debug
path: | path: |

View file

@ -44,7 +44,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact - name: Upload APK as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
path: | path: |
@ -69,7 +69,7 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Download APK artifact from previous job - name: Download APK artifact from previous job
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
- name: Enable KVM group perms - name: Enable KVM group perms
@ -102,7 +102,7 @@ jobs:
script: | script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
- name: Upload test results - name: Upload test results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: test-results name: test-results
path: | path: |

View file

@ -42,7 +42,7 @@ jobs:
- name: ✅ Upload kover report - name: ✅ Upload kover report
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: kover-results name: kover-results
path: | path: |
@ -74,7 +74,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis - name: Upload dependency analysis
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: dependency-analysis name: dependency-analysis
path: build/reports/dependency-check-report.html path: build/reports/dependency-check-report.html

View file

@ -97,7 +97,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: konsist-report name: konsist-report
path: | path: |
@ -174,7 +174,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: linting-report name: linting-report
path: | path: |
@ -214,7 +214,7 @@ jobs:
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: detekt-report name: detekt-report
path: | path: |
@ -254,7 +254,7 @@ jobs:
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: ktlint-report name: ktlint-report
path: | path: |
@ -317,7 +317,7 @@ jobs:
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Download reports from previous jobs - name: Download reports from previous jobs
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
- name: Prepare Danger - name: Prepare Danger
if: always() if: always()
run: | run: |

View file

@ -38,7 +38,7 @@ jobs:
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-app-gplay-bundle-unsigned name: elementx-app-gplay-bundle-unsigned
path: | path: |
@ -74,7 +74,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-enterprise-app-gplay-bundle-unsigned name: elementx-enterprise-app-gplay-bundle-unsigned
path: | path: |
@ -102,7 +102,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact - name: Upload apks as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: elementx-app-fdroid-apks-unsigned name: elementx-app-fdroid-apks-unsigned
path: | path: |

View file

@ -61,7 +61,7 @@ jobs:
- name: 🚫 Upload kover failed coverage reports - name: 🚫 Upload kover failed coverage reports
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: kover-error-report name: kover-error-report
path: | path: |
@ -73,7 +73,7 @@ jobs:
- name: 🚫 Upload test results on error - name: 🚫 Upload test results on error
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: tests-and-screenshot-tests-results name: tests-and-screenshot-tests-results
path: | path: |

View file

@ -1,3 +1,86 @@
Changes in Element X v25.11.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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 Changes in Element X v25.11.0
============================= =============================

View file

@ -9,13 +9,14 @@ package io.element.android.x.di
import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
import io.element.android.appnav.di.TimelineBindings
import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@GraphExtension(RoomScope::class) @GraphExtension(RoomScope::class)
interface RoomGraph : NodeFactoriesBindings { interface RoomGraph : NodeFactoriesBindings, TimelineBindings {
@GraphExtension.Factory @GraphExtension.Factory
interface Factory { interface Factory {
fun create( fun create(

View file

@ -349,7 +349,7 @@ class RootFlowNode(
} else { } else {
// wait for the current session to be restored // wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId) val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.getAllSessions().size > 1) { if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use // Several accounts, let the user choose which one to use
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
@ -379,7 +379,7 @@ class RootFlowNode(
is PermalinkData.FallbackLink -> Unit is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit is PermalinkData.RoomEmailInviteLink -> Unit
else -> { else -> {
if (sessionStore.getAllSessions().size > 1) { if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use // Several accounts, let the user choose which one to use
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(

View file

@ -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
}

View file

@ -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
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -70,7 +69,6 @@ class RoomFlowNode(
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val membershipObserver: RoomMembershipObserver, private val membershipObserver: RoomMembershipObserver,
private val spaceEntryPoint: SpaceEntryPoint,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -105,9 +103,6 @@ class RoomFlowNode(
@Parcelize @Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget data class JoinedRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class JoinedSpace(val spaceId: RoomId) : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
@ -209,15 +204,6 @@ class RoomFlowNode(
) )
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
is NavTarget.JoinedSpace -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SpaceEntryPoint.Inputs(roomId = navTarget.spaceId),
callback = spaceCallback,
)
}
} }
} }

View file

@ -22,10 +22,10 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory 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.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.BackstackView 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.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ -136,8 +134,8 @@ class JoinedRoomLoadedFlowNode(
callback.handlePermalinkClick(data, pushToBackstack) callback.handlePermalinkClick(data, pushToBackstack)
} }
override fun startForwardEventFlow(eventId: EventId) { override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId)) backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
} }
} }
return roomDetailsEntryPoint.createNode( return roomDetailsEntryPoint.createNode(
@ -169,7 +167,11 @@ class JoinedRoomLoadedFlowNode(
createSpaceNode(buildContext) createSpaceNode(buildContext)
} }
is NavTarget.ForwardEvent -> { 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 params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
@ -195,10 +197,6 @@ class JoinedRoomLoadedFlowNode(
callback.navigateToRoom(roomId, viaParameters) callback.navigateToRoom(roomId, viaParameters)
} }
override fun navigateToRoomDetails() {
backstack.push(NavTarget.RoomDetails)
}
override fun navigateToRoomMemberList() { override fun navigateToRoomMemberList() {
backstack.push(NavTarget.RoomMemberList) backstack.push(NavTarget.RoomMemberList)
} }
@ -228,8 +226,8 @@ class JoinedRoomLoadedFlowNode(
callback.handlePermalinkClick(data, pushToBackstack) callback.handlePermalinkClick(data, pushToBackstack)
} }
override fun forwardEvent(eventId: EventId) { override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId)) backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
} }
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId) {
@ -266,7 +264,7 @@ class JoinedRoomLoadedFlowNode(
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize @Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
@Parcelize @Parcelize
data object RoomNotificationSettings : NavTarget data object RoomNotificationSettings : NavTarget
@ -276,7 +274,7 @@ class JoinedRoomLoadedFlowNode(
val messageNode = waitForChildAttached<Node, NavTarget> { navTarget -> val messageNode = waitForChildAttached<Node, NavTarget> { navTarget ->
navTarget is NavTarget.Messages navTarget is NavTarget.Messages
} }
(messageNode as? MessagesEntryPointNode)?.attachThread(threadId, focusedEventId) (messageNode as? MessagesEntryPoint.NodeProxy)?.attachThread(threadId, focusedEventId)
} }
@Composable @Composable

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -93,6 +93,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixuiTest)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)

View file

@ -201,7 +201,7 @@ class CallScreenPresenter(
userAgent = userAgent, userAgent = userAgent,
isCallActive = isWidgetLoaded, isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) }, eventSink = ::handleEvents,
) )
} }

View file

@ -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.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider 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.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest

View file

@ -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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider 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.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
@ -415,6 +415,7 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(any()) } verify { notificationManagerCompat.cancel(any()) }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `IncomingCall - ignore expired ring lifetime`() = runTest { fun `IncomingCall - ignore expired ring lifetime`() = runTest {

View file

@ -24,11 +24,12 @@ class FakeEnterpriseService(
private val defaultHomeserverListResult: () -> List<String> = { emptyList() }, private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
initialBrandColor: Color? = null,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
private val brandColorState = MutableStateFlow<Color?>(null) private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors) private val semanticColorsState = MutableStateFlow(initialSemanticColors)
override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask { override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask {

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.roomselect.api) implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject @AssistedInject
class ForwardMessagesPresenter( class ForwardMessagesPresenter(
@ -54,7 +55,7 @@ class ForwardMessagesPresenter(
return ForwardMessagesState( return ForwardMessagesState(
forwardAction = forwardingActionState.value, forwardAction = forwardingActionState.value,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }
@ -63,7 +64,11 @@ class ForwardMessagesPresenter(
roomIds: List<RoomId>, roomIds: List<RoomId>,
) = launch { ) = launch {
suspend { suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow() timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds)
.onFailure {
Timber.e(it, "Error while forwarding event")
}
.getOrThrow()
roomIds roomIds
}.runCatchingUpdatingState(forwardingActionState) }.runCatchingUpdatingState(forwardingActionState)
} }

View file

@ -8,11 +8,13 @@
package io.element.android.features.forward.impl package io.element.android.features.forward.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun ForwardMessagesView( fun ForwardMessagesView(
@ -24,6 +26,9 @@ fun ForwardMessagesView(
onSuccess = { onSuccess = {
onForwardSuccess(it) onForwardSuccess(it)
}, },
errorMessage = {
stringResource(id = CommonStrings.error_unknown)
},
onErrorDismiss = { onErrorDismiss = {
state.eventSink(ForwardMessagesEvents.ClearError) state.eventSink(ForwardMessagesEvents.ClearError)
}, },

View file

@ -15,7 +15,9 @@ import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.logout.api.direct.DirectLogoutEvents 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.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@ -27,8 +29,33 @@ class ChooseSelfVerificationModePresenter(
@Composable @Composable
override fun present(): ChooseSelfVerificationModeState { override fun present(): ChooseSelfVerificationModeState {
val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val canEnterRecoveryKey by encryptionService.recoveryStateStateFlow
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } } .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() val directLogoutState = directLogoutPresenter.present()
@ -39,8 +66,7 @@ class ChooseSelfVerificationModePresenter(
} }
return ChooseSelfVerificationModeState( return ChooseSelfVerificationModeState(
canUseAnotherDevice = hasDevicesToVerifyAgainst, buttonsState = buttonsState,
canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
eventSink = ::eventHandler, eventSink = ::eventHandler,
) )

View file

@ -8,10 +8,15 @@
package io.element.android.features.ftue.impl.sessionverification.choosemode package io.element.android.features.ftue.impl.sessionverification.choosemode
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
data class ChooseSelfVerificationModeState( data class ChooseSelfVerificationModeState(
val canUseAnotherDevice: Boolean, val buttonsState: AsyncData<ButtonsState>,
val canEnterRecoveryKey: Boolean,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
) ) {
data class ButtonsState(
val canUseAnotherDevice: Boolean,
val canEnterRecoveryKey: Boolean,
)
}

View file

@ -9,23 +9,49 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
class ChooseSelfVerificationModeStateProvider : class ChooseSelfVerificationModeStateProvider :
PreviewParameterProvider<ChooseSelfVerificationModeState> { PreviewParameterProvider<ChooseSelfVerificationModeState> {
override val values = sequenceOf( override val values = sequenceOf(
aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = true), aChooseSelfVerificationModeState(
aChooseSelfVerificationModeState(canUseAnotherDevice = false, canEnterRecoveryKey = false), buttonsState = AsyncData.Success(
aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = true), aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = true),
aChooseSelfVerificationModeState(canUseAnotherDevice = true, canEnterRecoveryKey = false), ),
),
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( fun aChooseSelfVerificationModeState(
canUseAnotherDevice: Boolean = true, buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
canEnterRecoveryKey: Boolean = true,
) = ChooseSelfVerificationModeState( ) = ChooseSelfVerificationModeState(
canUseAnotherDevice = canUseAnotherDevice, buttonsState = buttonsState,
canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = aDirectLogoutState(), directLogoutState = aDirectLogoutState(),
eventSink = {}, eventSink = {},
) )
fun aButtonsState(
canUseAnotherDevice: Boolean = true,
canEnterRecoveryKey: Boolean = true,
) = ChooseSelfVerificationModeState.ButtonsState(
canUseAnotherDevice = canUseAnotherDevice,
canEnterRecoveryKey = canEnterRecoveryKey,
)

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R 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.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@ -50,7 +51,6 @@ fun ChooseSelfVerificationModeView(
BackHandler { BackHandler {
activity?.finish() activity?.finish()
} }
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -73,29 +73,12 @@ fun ChooseSelfVerificationModeView(
) )
}, },
footer = { footer = {
ButtonColumnMolecule( ChooseSelfVerificationModeButtons(
modifier = Modifier.padding(bottom = 16.dp) state = state,
) { onUseAnotherDevice = onUseAnotherDevice,
if (state.canUseAnotherDevice) { onUseRecoveryKey = onUseRecoveryKey,
Button( onResetKey = onResetKey,
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,
)
}
} }
) { ) {
Row( 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 @PreviewsDayNight
@Composable @Composable
internal fun ChooseSelfVerificationModeViewPreview( internal fun ChooseSelfVerificationModeViewPreview(

View file

@ -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.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState 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.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
@ -22,23 +23,92 @@ import org.junit.Test
class ChooseSessionVerificationModePresenterTest { class ChooseSessionVerificationModePresenterTest {
@Test @Test
fun `initial state - is relayed from EncryptionService`() = runTest { fun `present - initial state`() = runTest {
val encryptionService = FakeEncryptionService().apply { val presenter = createPresenter()
// Has device to verify against
emitHasDevicesToVerifyAgainst(false)
// Can enter recovery key
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val presenter = createPresenter(encryptionService = encryptionService)
presenter.test { presenter.test {
awaitItem().run { awaitItem().run {
assertThat(canUseAnotherDevice).isFalse() assertThat(buttonsState.isLoading()).isTrue()
assertThat(canEnterRecoveryKey).isTrue()
assertThat(directLogoutState.logoutAction.isUninitialized()).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 @Test
fun `sing out action triggers a direct logout`() = runTest { fun `sing out action triggers a direct logout`() = runTest {
val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {} val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {}
@ -49,8 +119,8 @@ class ChooseSessionVerificationModePresenterTest {
presenter.test { presenter.test {
val initial = awaitItem() val initial = awaitItem()
initial.eventSink(ChooseSelfVerificationModeEvent.SignOut) initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
logoutEventRecorder.assertions().isCalledOnce()
logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false))) .with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
} }
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R 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.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
@ -43,7 +44,7 @@ class ChooseSessionVerificationModeViewTest {
fun `clicking on use another device calls the callback`() { fun `clicking on use another device calls the callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(canUseAnotherDevice = true), aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))),
onUseAnotherDevice = callback, onUseAnotherDevice = callback,
) )
rule.clickOn(R.string.screen_identity_use_another_device) rule.clickOn(R.string.screen_identity_use_another_device)
@ -55,7 +56,7 @@ class ChooseSessionVerificationModeViewTest {
fun `clicking on enter recovery key calls the callback`() { fun `clicking on enter recovery key calls the callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( rule.setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(canEnterRecoveryKey = true), aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canEnterRecoveryKey = true))),
onEnterRecoveryKey = callback, onEnterRecoveryKey = callback,
) )
rule.clickOn(R.string.screen_session_verification_enter_recovery_key) rule.clickOn(R.string.screen_session_verification_enter_recovery_key)

View file

@ -13,5 +13,4 @@ data class AccountProvider(
val subtitle: String? = null, val subtitle: String? = null,
val isPublic: Boolean = false, val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false, val isMatrixOrg: Boolean = false,
val isValid: Boolean = false,
) )

View file

@ -15,7 +15,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
get() = sequenceOf( get() = sequenceOf(
anAccountProvider(), anAccountProvider(),
anAccountProvider().copy(subtitle = null), 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), anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
// Add other state here // Add other state here
) )
@ -26,11 +26,9 @@ fun anAccountProvider(
subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.", subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.",
isPublic: Boolean = true, isPublic: Boolean = true,
isMatrixOrg: Boolean = true, isMatrixOrg: Boolean = true,
isValid: Boolean = true,
) = AccountProvider( ) = AccountProvider(
url = url, url = url,
subtitle = subtitle, subtitle = subtitle,
isPublic = isPublic, isPublic = isPublic,
isMatrixOrg = isMatrixOrg, isMatrixOrg = isMatrixOrg,
isValid = isValid,
) )

View file

@ -10,6 +10,4 @@ package io.element.android.features.login.impl.resolver
data class HomeserverData( data class HomeserverData(
// The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
val homeserverUrl: String, 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,
) )

View file

@ -8,19 +8,16 @@
package io.element.android.features.login.impl.resolver package io.element.android.features.login.impl.resolver
import dev.zacsweers.metro.Inject 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.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap 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.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.wellknown.api.WellKnown import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.wellknown.api.WellknownRetriever
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import timber.log.Timber
import java.util.Collections import java.util.Collections
/** /**
@ -29,7 +26,7 @@ import java.util.Collections
@Inject @Inject
class HomeserverResolver( class HomeserverResolver(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val wellknownRetriever: WellknownRetriever, private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
) { ) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow { fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext() val flowContext = currentCoroutineContext()
@ -41,20 +38,14 @@ class HomeserverResolver(
// Run all the requests in parallel // Run all the requests in parallel
withContext(dispatchers.io) { withContext(dispatchers.io) {
list.parallelMap { url -> list.parallelMap { url ->
val wellKnown = tryOrNull { val isValid = homeServerLoginCompatibilityChecker.check(url)
withTimeout(5000) { .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
wellknownRetriever.getWellKnown(url) .getOrNull()
} ?: return@parallelMap
}
val isValid = wellKnown?.dataOrNull()?.isValid().orFalse() // Emit the list as soon as possible
if (isValid) { if (isValid) {
// Emit the list as soon as possible currentList.add(HomeserverData(homeserverUrl = url))
currentList.add(
HomeserverData(
homeserverUrl = url,
isWellknownValid = true,
)
)
withContext(flowContext) { withContext(flowContext) {
emit(currentList.toList()) 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 list is empty, and the user has entered an URL, do not block the user.
if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) { if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) {
emit( emit(listOf(HomeserverData(homeserverUrl = trimmedUserInput)))
listOf(
HomeserverData(
homeserverUrl = trimmedUserInput,
isWellknownValid = false,
)
)
)
} }
} }
@ -88,7 +72,3 @@ class HomeserverResolver(
} }
} }
} }
private fun WellKnown.isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}

View file

@ -37,7 +37,6 @@ class ChangeAccountProviderPresenter(
subtitle = null, subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
isValid = true,
) )
} }
.toImmutableList() .toImmutableList()

View file

@ -67,7 +67,6 @@ class ChooseAccountProviderPresenter(
subtitle = null, subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
isValid = true,
) )
} }
.toImmutableList() .toImmutableList()

View file

@ -90,7 +90,7 @@ class OnBoardingPresenter(
} }
val isAddingAccount by produceState(initialValue = false) { val isAddingAccount by produceState(initialValue = false) {
// We are adding an account if there is at least one session already stored // 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() val loginMode by loginHelper.collectLoginMode()

View file

@ -57,14 +57,14 @@ class SearchAccountProviderPresenter(
userInput = userInput, userInput = userInput,
userInputResult = data.value, userInputResult = data.value,
changeServerState = changeServerState, changeServerState = changeServerState,
eventSink = ::handleEvents eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<AsyncData<List<HomeserverData>>>) = launch { private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<AsyncData<List<HomeserverData>>>) = launch {
data.value = AsyncData.Uninitialized data.value = AsyncData.Uninitialized
// Debounce // Debounce
delay(300) delay(500)
data.value = AsyncData.Loading() data.value = AsyncData.Loading()
homeserverResolver.resolve(userInput).collect { homeserverResolver.resolve(userInput).collect {
data.value = AsyncData.Success(it) data.value = AsyncData.Success(it)

View file

@ -34,18 +34,14 @@ fun aSearchAccountProviderState(
fun aHomeserverDataList(): List<HomeserverData> { fun aHomeserverDataList(): List<HomeserverData> {
return listOf( return listOf(
aHomeserverData(isWellknownValid = true), aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL),
aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true), aHomeserverData(homeserverUrl = "https://no.sliding.sync"),
aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false), aHomeserverData(homeserverUrl = "https://invalid"),
) )
} }
fun aHomeserverData( fun aHomeserverData(
homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
isWellknownValid: Boolean = true,
): HomeserverData { ): HomeserverData {
return HomeserverData( return HomeserverData(homeserverUrl = homeserverUrl,)
homeserverUrl = homeserverUrl,
isWellknownValid = isWellknownValid,
)
} }

View file

@ -192,7 +192,6 @@ private fun HomeserverData.toAccountProvider(): AccountProvider {
// There is no need to know for other servers right now // There is no need to know for other servers right now
isPublic = isMatrixOrg, isPublic = isMatrixOrg,
isMatrixOrg = isMatrixOrg, isMatrixOrg = isMatrixOrg,
isValid = isWellknownValid,
) )
} }

View file

@ -33,7 +33,6 @@ class AccountProviderDataSourceTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = false,
) )
) )
} }
@ -55,7 +54,6 @@ class AccountProviderDataSourceTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = false,
) )
) )
} }
@ -77,7 +75,6 @@ class AccountProviderDataSourceTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = false,
) )
) )
} }
@ -98,7 +95,6 @@ class AccountProviderDataSourceTest {
subtitle = null, subtitle = null,
isPublic = false, isPublic = false,
isMatrixOrg = false, isMatrixOrg = false,
isValid = false,
) )
) )
sut.reset() sut.reset()

View file

@ -46,7 +46,6 @@ class ChangeAccountProviderPresenterTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = true,
) )
) )
) )
@ -76,7 +75,6 @@ class ChangeAccountProviderPresenterTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = true,
), ),
AccountProvider( AccountProvider(
url = "https://element.io", url = "https://element.io",
@ -84,7 +82,6 @@ class ChangeAccountProviderPresenterTest {
subtitle = null, subtitle = null,
isPublic = false, isPublic = false,
isMatrixOrg = false, isMatrixOrg = false,
isValid = true,
) )
) )
) )
@ -114,7 +111,6 @@ class ChangeAccountProviderPresenterTest {
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,
isValid = true,
) )
) )
) )

View file

@ -37,14 +37,12 @@ class ChooseAccountProviderPresenterTest {
subtitle = null, subtitle = null,
isPublic = false, isPublic = false,
isMatrixOrg = false, isMatrixOrg = false,
isValid = true,
) )
val accountProvider2 = AccountProvider( val accountProvider2 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(), url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(),
subtitle = null, subtitle = null,
isPublic = false, isPublic = false,
isMatrixOrg = false, isMatrixOrg = false,
isValid = true,
) )
} }

View file

@ -13,12 +13,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverResolver 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.architecture.AsyncData
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
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.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -33,9 +29,9 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val fakeWellknownRetriever = FakeWellknownRetriever() val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { 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 @Test
fun `present - enter text no result`() = runTest { fun `present - enter text no result`() = runTest {
val fakeWellknownRetriever = FakeWellknownRetriever() val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() } 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 @Test
fun `present - enter text one result with wellknown`() = runTest { fun `present - enter text one result with wellknown`() = runTest {
val getWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<WellKnown>> { val checkResult = lambdaRecorder<String, Result<Boolean>> {
when (it) { when (it) {
"https://test.org" -> WellknownRetrieverResult.NotFound "https://test.org" -> Result.success(false)
"https://test.com" -> WellknownRetrieverResult.NotFound "https://test.com" -> Result.success(false)
"https://test.io" -> WellknownRetrieverResult.Success(aWellKnown()) "https://test.io" -> Result.success(true)
"https://test" -> WellknownRetrieverResult.NotFound "https://test" -> Result.success(false)
else -> error("should not happen") else -> error("should not happen")
} }
} }
val fakeWellknownRetriever = FakeWellknownRetriever( val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
getWellKnownResult = getWellKnownResult,
)
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -127,7 +121,7 @@ class SearchAccountProviderPresenterTest {
) )
) )
) )
getWellKnownResult.assertions().isCalledExactly(4) checkResult.assertions().isCalledExactly(4)
.withSequence( .withSequence(
listOf(value("https://test.org")), listOf(value("https://test.org")),
listOf(value("https://test.com")), listOf(value("https://test.com")),
@ -139,20 +133,18 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - enter text two results with wellknown`() = runTest { fun `present - enter text two results with wellknown`() = runTest {
val getWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<WellKnown>> { val checkResult = lambdaRecorder<String, Result<Boolean>> {
when (it) { when (it) {
"https://test.org" -> WellknownRetrieverResult.Success(aWellKnown()) "https://test.org" -> Result.success(true)
"https://test.com" -> WellknownRetrieverResult.NotFound "https://test.com" -> Result.success(false)
"https://test.io" -> WellknownRetrieverResult.Success(aWellKnown()) "https://test.io" -> Result.success(true)
"https://test" -> WellknownRetrieverResult.NotFound "https://test" -> Result.success(false)
else -> error("should not happen") else -> error("should not happen")
} }
} }
val fakeWellknownRetriever = FakeWellknownRetriever( val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
getWellKnownResult = getWellKnownResult,
)
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -179,7 +171,7 @@ class SearchAccountProviderPresenterTest {
) )
) )
) )
getWellKnownResult.assertions().isCalledExactly(4) checkResult.assertions().isCalledExactly(4)
.withSequence( .withSequence(
listOf(value("https://test.org")), listOf(value("https://test.org")),
listOf(value("https://test.com")), 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
),
)
}
} }

View file

@ -35,7 +35,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun navigateToRoomDetails() fun navigateToRoomDetails()
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun forwardEvent(eventId: EventId) fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
fun navigateToRoom(roomId: RoomId) fun navigateToRoom(roomId: RoomId)
} }
@ -47,8 +47,8 @@ interface MessagesEntryPoint : FeatureEntryPoint {
params: Params, params: Params,
callback: Callback, callback: Callback,
): Node ): Node
}
interface MessagesEntryPointNode { interface NodeProxy {
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?)
}
} }

View file

@ -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

View file

@ -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.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode 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.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
@ -115,7 +114,7 @@ class MessagesFlowNode(
private val roomNamesCache: RoomNamesCache, private val roomNamesCache: RoomNamesCache,
private val mentionSpanUpdater: MentionSpanUpdater, private val mentionSpanUpdater: MentionSpanUpdater,
private val mentionSpanTheme: MentionSpanTheme, private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider,
private val timelineController: TimelineController, private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
@ -130,8 +129,7 @@ class MessagesFlowNode(
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), ), MessagesEntryPoint.NodeProxy {
MessagesEntryPointNode {
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data class Messages(val focusedEventId: EventId?) : NavTarget data class Messages(val focusedEventId: EventId?) : NavTarget
@ -315,9 +313,9 @@ class MessagesFlowNode(
this@MessagesFlowNode.viewInTimeline(eventId) 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 // Need to go to the parent because of the overlay
callback.forwardEvent(eventId) callback.forwardEvent(eventId, fromPinnedEvents)
} }
} }
mediaViewerEntryPoint.createNode( mediaViewerEntryPoint.createNode(

View file

@ -122,7 +122,7 @@ class DefaultActionListPresenter(
return ActionListState( return ActionListState(
target = target.value, target = target.value,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }

View file

@ -172,7 +172,7 @@ class DefaultMediaOptimizationSelectorPresenter(
selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(), selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(),
displayMediaSelectorViews = displayMediaSelectorViews, displayMediaSelectorViews = displayMediaSelectorViews,
displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog,
eventSink = { handleEvent(it) }, eventSink = ::handleEvent,
) )
} }

View file

@ -382,7 +382,7 @@ class MessageComposerPresenter(
suggestions = suggestions.toImmutableList(), suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
eventSink = { handleEvents(it) }, eventSink = ::handleEvents,
) )
} }

View file

@ -7,8 +7,9 @@
package io.element.android.features.messages.impl.pinned package io.element.android.features.messages.impl.pinned
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn 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.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.mapState 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.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService 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.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -29,12 +29,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
@Inject @ContributesBinding(RoomScope::class)
class PinnedEventsTimelineProvider( class DefaultPinnedEventsTimelineProvider(
private val room: JoinedRoom, private val room: JoinedRoom,
private val syncService: SyncService, private val syncService: SyncService,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) : TimelineProvider { ) : PinnedEventsTimelineProvider {
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
MutableStateFlow(AsyncData.Uninitialized) MutableStateFlow(AsyncData.Uninitialized)

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject 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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.onEach
class PinnedMessagesBannerPresenter( class PinnedMessagesBannerPresenter(
private val room: BaseRoom, private val room: BaseRoom,
private val itemFactory: PinnedMessagesBannerItemFactory, private val itemFactory: PinnedMessagesBannerItemFactory,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider, private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider,
) : Presenter<PinnedMessagesBannerState> { ) : Presenter<PinnedMessagesBannerState> {
private val pinnedItems = mutableStateOf<AsyncData<ImmutableList<PinnedMessagesBannerItem>>>(AsyncData.Uninitialized) private val pinnedItems = mutableStateOf<AsyncData<ImmutableList<PinnedMessagesBannerItem>>>(AsyncData.Uninitialized)

View file

@ -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.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.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.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
@ -66,7 +66,7 @@ class PinnedMessagesListPresenter(
@Assisted private val navigator: PinnedMessagesListNavigator, @Assisted private val navigator: PinnedMessagesListNavigator,
private val room: JoinedRoom, private val room: JoinedRoom,
timelineItemsFactoryCreator: TimelineItemsFactory.Creator, timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider, private val timelineProvider: DefaultPinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val linkPresenter: Presenter<LinkState>, private val linkPresenter: Presenter<LinkState>,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,

View file

@ -289,7 +289,7 @@ class TimelinePresenter(
messageShield = messageShield.value, messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }

View file

@ -71,7 +71,7 @@ class CustomReactionPresenter(
target = target.value, target = target.value,
selectedEmoji = selectedEmoji, selectedEmoji = selectedEmoji,
recentEmojis = recentEmojis, recentEmojis = recentEmojis,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }
} }

View file

@ -48,7 +48,7 @@ class ReactionSummaryPresenter(
} }
return ReactionSummaryState( return ReactionSummaryState(
target = targetWithAvatars.value, target = targetWithAvatars.value,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }

View file

@ -36,7 +36,7 @@ class ReadReceiptBottomSheetPresenter : Presenter<ReadReceiptBottomSheetState> {
return ReadReceiptBottomSheetState( return ReadReceiptBottomSheetState(
selectedEvent = selectedEvent, selectedEvent = selectedEvent,
eventSink = { handleEvent(it) }, eventSink = ::handleEvent,
) )
} }
} }

View file

@ -56,7 +56,7 @@ class TimelineProtectionPresenter(
return TimelineProtectionState( return TimelineProtectionState(
protectionState = protectionState, protectionState = protectionState,
eventSink = { event -> handleEvent(event) } eventSink = ::handleEvent,
) )
} }
} }

View file

@ -90,7 +90,7 @@ class DefaultMessagesEntryPointTest {
override fun navigateToRoomDetails() = lambdaError() override fun navigateToRoomDetails() = lambdaError()
override fun navigateToRoomMemberDetails(userId: UserId) = lambdaError() override fun navigateToRoomMemberDetails(userId: UserId) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = 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() override fun navigateToRoom(roomId: RoomId) = lambdaError()
} }
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)

View file

@ -8,7 +8,7 @@
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat 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.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
@ -195,7 +195,7 @@ class PinnedMessagesBannerPresenterTest {
internal fun TestScope.createPinnedEventsTimelineProvider( internal fun TestScope.createPinnedEventsTimelineProvider(
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
syncService: SyncService = FakeSyncService(), syncService: SyncService = FakeSyncService(),
) = PinnedEventsTimelineProvider( ) = DefaultPinnedEventsTimelineProvider(
room = room, room = room,
syncService = syncService, syncService = syncService,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),

View file

@ -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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator 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.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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -300,7 +300,7 @@ class PinnedMessagesListPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
): PinnedMessagesListPresenter { ): PinnedMessagesListPresenter {
val timelineProvider = PinnedEventsTimelineProvider( val timelineProvider = DefaultPinnedEventsTimelineProvider(
room = room, room = room,
syncService = syncService, syncService = syncService,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),

View file

@ -143,7 +143,7 @@ class EditUserProfilePresenter(
saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
eventSink = { handleEvents(it) }, eventSink = ::handleEvents,
) )
} }

View file

@ -165,7 +165,7 @@ class DefaultBugReporter(
} }
} }
val sessionData = sessionStore.getLatestSession() val sessionData = sessionStore.getLatestSession()
val numberOfAccounts = sessionStore.getAllSessions().size val numberOfAccounts = sessionStore.numberOfSessions()
val deviceId = sessionData?.deviceId ?: "undefined" val deviceId = sessionData?.deviceId ?: "undefined"
val userId = sessionData?.userId?.let { UserId(it) } val userId = sessionData?.userId?.let { UserId(it) }
// build the multi part request // build the multi part request

View file

@ -124,7 +124,7 @@ class ChangeRoomPermissionsPresenter(
hasChanges = hasChanges, hasChanges = hasChanges,
saveAction = saveAction, saveAction = saveAction,
confirmExitAction = confirmExitAction, confirmExitAction = confirmExitAction,
eventSink = { handleEvent(it) } eventSink = ::handleEvent,
) )
} }

View file

@ -100,7 +100,7 @@ class RolesAndPermissionsPresenter(
canDemoteSelf = canDemoteSelf.value, canDemoteSelf = canDemoteSelf.value,
changeOwnRoleAction = changeOwnRoleAction.value, changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value, resetPermissionsAction = resetPermissionsAction.value,
eventSink = { handleEvent(it) }, eventSink = ::handleEvent,
) )
} }

View file

@ -40,7 +40,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun navigateToGlobalNotificationSettings() fun navigateToGlobalNotificationSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>) fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun startForwardEventFlow(eventId: EventId) fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)
} }
fun createNode( fun createNode(

View file

@ -302,7 +302,7 @@ class RoomDetailsFlowNode(
// Cannot happen // Cannot happen
} }
override fun forwardEvent(eventId: EventId) { override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen // Cannot happen
} }
} }
@ -334,8 +334,8 @@ class RoomDetailsFlowNode(
callback.handlePermalinkClick(permalinkData, pushToBackstack = false) callback.handlePermalinkClick(permalinkData, pushToBackstack = false)
} }
override fun forward(eventId: EventId) { override fun forward(eventId: EventId, fromPinnedEvents: Boolean) {
callback.startForwardEventFlow(eventId) callback.startForwardEventFlow(eventId, fromPinnedEvents)
} }
} }
mediaGalleryEntryPoint.createNode( mediaGalleryEntryPoint.createNode(
@ -361,8 +361,8 @@ class RoomDetailsFlowNode(
callback.handlePermalinkClick(data, pushToBackstack) callback.handlePermalinkClick(data, pushToBackstack)
} }
override fun forwardEvent(eventId: EventId) { override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
callback.startForwardEventFlow(eventId) callback.startForwardEventFlow(eventId, fromPinnedEvents)
} }
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId) {

View file

@ -179,7 +179,7 @@ class RoomMemberListPresenter(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
canInvite = canInvite, canInvite = canInvite,
moderationState = roomModerationState, moderationState = roomModerationState,
eventSink = { handleEvents(it) }, eventSink = ::handleEvents,
) )
} }

View file

@ -135,7 +135,7 @@ class RoomNotificationSettingsPresenter(
setNotificationSettingAction = setNotificationSettingAction.value, setNotificationSettingAction = setNotificationSettingAction.value,
restoreDefaultAction = restoreDefaultAction.value, restoreDefaultAction = restoreDefaultAction.value,
displayMentionsOnlyDisclaimer = shouldDisplayMentionsOnlyDisclaimer, displayMentionsOnlyDisclaimer = shouldDisplayMentionsOnlyDisclaimer,
eventSink = { handleEvents(it) }, eventSink = ::handleEvents,
) )
} }

View file

@ -64,7 +64,7 @@ class DefaultRoomDetailsEntryPointTest {
override fun navigateToGlobalNotificationSettings() = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError() override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = 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( val params = RoomDetailsEntryPoint.Params(
initialElement = RoomDetailsEntryPoint.InitialTarget.RoomDetails, initialElement = RoomDetailsEntryPoint.InitialTarget.RoomDetails,

View file

@ -140,7 +140,7 @@ class RoomMemberModerationPresenter(
kickUserAsyncAction = kickUserAsyncAction.value, kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value, banUserAsyncAction = banUserAsyncAction.value,
unbanUserAsyncAction = unbanUserAsyncAction.value, unbanUserAsyncAction = unbanUserAsyncAction.value,
eventSink = { handleEvent(it) }, eventSink = ::handleEvent,
) )
} }

View file

@ -63,7 +63,7 @@ class SharePresenter(
return ShareState( return ShareState(
shareAction = shareActionState.value, shareAction = shareActionState.value,
eventSink = { handleEvents(it) } eventSink = ::handleEvents,
) )
} }

View file

@ -58,9 +58,11 @@ class SignedOutPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.signedOutSession).isEqualTo(aSessionData) assertThat(initialState.signedOutSession).isEqualTo(aSessionData)
assertThat(sessionStore.getAllSessions()).isNotEmpty() assertThat(sessionStore.getAllSessions()).isNotEmpty()
assertThat(sessionStore.numberOfSessions()).isEqualTo(1)
initialState.eventSink(SignedOutEvents.SignInAgain) initialState.eventSink(SignedOutEvents.SignInAgain)
assertThat(awaitItem().signedOutSession).isNull() assertThat(awaitItem().signedOutSession).isNull()
assertThat(sessionStore.getAllSessions()).isEmpty() assertThat(sessionStore.getAllSessions()).isEmpty()
assertThat(sessionStore.numberOfSessions()).isEqualTo(0)
} }
} }
} }

View file

@ -28,7 +28,6 @@ interface SpaceEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) fun navigateToRoom(roomId: RoomId, viaParameters: List<String>)
fun navigateToRoomDetails()
fun navigateToRoomMemberList() fun navigateToRoomMemberList()
} }
} }

View file

@ -18,6 +18,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject 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.di.SpaceFlowGraph
import io.element.android.features.space.impl.leave.LeaveSpaceNode 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.root.SpaceNode
import io.element.android.features.space.impl.settings.SpaceSettingsNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode 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.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -42,6 +44,7 @@ import kotlinx.parcelize.Parcelize
class SpaceFlowNode( class SpaceFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
room: JoinedRoom,
spaceService: SpaceService, spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory, graphFactory: SpaceFlowGraph.Factory,
) : BaseFlowNode<SpaceFlowNode.NavTarget>( ) : BaseFlowNode<SpaceFlowNode.NavTarget>(
@ -52,15 +55,17 @@ class SpaceFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback: SpaceEntryPoint.Callback = callback() 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) override val graph = graphFactory.create(spaceRoomList)
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Root : NavTarget data object Root : NavTarget
@Parcelize
data object Settings : NavTarget
@Parcelize @Parcelize
data object Leave : NavTarget data object Leave : NavTarget
} }
@ -77,7 +82,16 @@ class SpaceFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Leave -> { NavTarget.Leave -> {
createNode<LeaveSpaceNode>(buildContext, listOf(inputs)) val callback = object : LeaveSpaceNode.Callback {
override fun closeLeaveSpaceFlow() {
backstack.pop()
}
override fun navigateToRolesAndPermissions() {
// TODO
}
}
createNode<LeaveSpaceNode>(buildContext, listOf(callback))
} }
NavTarget.Root -> { NavTarget.Root -> {
val callback = object : SpaceNode.Callback { val callback = object : SpaceNode.Callback {
@ -85,8 +99,8 @@ class SpaceFlowNode(
callback.navigateToRoom(roomId, viaParameters) callback.navigateToRoom(roomId, viaParameters)
} }
override fun navigateToRoomDetails() { override fun navigateToSpaceSettings() {
callback.navigateToRoomDetails() backstack.push(NavTarget.Settings)
} }
override fun navigateToRoomMemberList() { override fun navigateToRoomMemberList() {
@ -97,7 +111,35 @@ class SpaceFlowNode(
backstack.push(NavTarget.Leave) backstack.push(NavTarget.Leave)
} }
} }
createNode<SpaceNode>(buildContext, listOf(inputs, callback)) createNode<SpaceNode>(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<SpaceSettingsNode>(buildContext, listOf(callback))
} }
} }
} }

View file

@ -16,10 +16,10 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.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.MatrixClient
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesNode(SpaceFlowScope::class) @ContributesNode(SpaceFlowScope::class)
@AssistedInject @AssistedInject
@ -27,12 +27,19 @@ class LeaveSpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
matrixClient: MatrixClient, matrixClient: MatrixClient,
room: JoinedRoom,
presenterFactory: LeaveSpacePresenter.Factory, presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs() interface Callback : Plugin {
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId) fun closeLeaveSpaceFlow()
fun navigateToRolesAndPermissions()
}
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId)
private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle) private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle)
private val callback: Callback = callback()
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -47,7 +54,8 @@ class LeaveSpaceNode(
val state = presenter.present() val state = presenter.present()
LeaveSpaceView( LeaveSpaceView(
state = state, state = state,
onCancel = ::navigateUp, onCancel = callback::closeLeaveSpaceFlow,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
modifier = modifier modifier = modifier
) )
} }

View file

@ -69,6 +69,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LeaveSpaceView( fun LeaveSpaceView(
state: LeaveSpaceState, state: LeaveSpaceState,
onCancel: () -> Unit, onCancel: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -130,6 +131,9 @@ fun LeaveSpaceView(
state.eventSink(LeaveSpaceEvents.LeaveSpace) state.eventSink(LeaveSpaceEvents.LeaveSpace)
}, },
onCancel = onCancel, onCancel = onCancel,
// TODO enable when navigation is ready
showRolesAndPermissionsButton = false, // state.isLastAdmin,
onRolesAndPermissionsClick = onRolesAndPermissionsClick,
) )
} }
} }
@ -210,6 +214,8 @@ private fun LeaveSpaceButtons(
showLeaveButton: Boolean, showLeaveButton: Boolean,
selectedRoomsCount: Int, selectedRoomsCount: Int,
onLeaveSpace: () -> Unit, onLeaveSpace: () -> Unit,
showRolesAndPermissionsButton: Boolean,
onRolesAndPermissionsClick: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
ButtonColumnMolecule( ButtonColumnMolecule(
@ -229,8 +235,14 @@ private fun LeaveSpaceButtons(
destructive = true, destructive = true,
) )
} }
// TODO For least admin space, add a button to open the settings. if (showRolesAndPermissionsButton) {
// See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600 Button(
text = stringResource(CommonStrings.action_go_to_roles_and_permissions),
onClick = onRolesAndPermissionsClick,
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Settings()),
)
}
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel), text = stringResource(CommonStrings.action_cancel),
@ -345,5 +357,6 @@ internal fun LeaveSpaceViewPreview(
LeaveSpaceView( LeaveSpaceView(
state = state, state = state,
onCancel = {}, onCancel = {},
onRolesAndPermissionsClick = {},
) )
} }

View file

@ -42,7 +42,7 @@ class SpaceNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) fun navigateToRoom(roomId: RoomId, viaParameters: List<String>)
fun navigateToRoomDetails() fun navigateToSpaceSettings()
fun navigateToRoomMemberList() fun navigateToRoomMemberList()
fun startLeaveSpaceFlow() fun startLeaveSpaceFlow()
} }
@ -80,7 +80,7 @@ class SpaceNode(
callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via) callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via)
}, },
onDetailsClick = { onDetailsClick = {
callback.navigateToRoomDetails() callback.navigateToSpaceSettings()
}, },
onShareSpace = { onShareSpace = {
onShareRoom(context) onShareRoom(context)

View file

@ -328,7 +328,7 @@ private fun SpaceViewTopBar(
}, },
text = { text = {
Text( Text(
text = stringResource(id = CommonStrings.action_leave), text = stringResource(id = CommonStrings.action_leave_space),
color = ElementTheme.colors.textCriticalPrimary, color = ElementTheme.colors.textCriticalPrimary,
) )
}, },

View file

@ -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

View file

@ -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<Plugin>,
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,
)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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<SpaceSettingsState> {
@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 = {},
)
}
}

View file

@ -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
)

View file

@ -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<SpaceSettingsState> {
override val values: Sequence<SpaceSettingsState>
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,
)

View file

@ -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,
)
}

View file

@ -10,4 +10,7 @@
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string> <string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string> <string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
<string name="screen_space_settings_leave_space">"Leave space"</string>
<string name="screen_space_settings_roles_and_permissions">"Roles &amp; permissions"</string>
<string name="screen_space_settings_security_and_privacy">"Security &amp; privacy"</string>
</resources> </resources>

View file

@ -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.features.space.impl.di.FakeSpaceFlowGraph
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@ -40,12 +41,12 @@ class DefaultSpaceEntryPointTest {
spaceService = FakeSpaceService( spaceService = FakeSpaceService(
spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) } spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) }
), ),
room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory graphFactory = FakeSpaceFlowGraph.Factory
) )
} }
val callback = object : SpaceEntryPoint.Callback { val callback = object : SpaceEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError() override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError()
override fun navigateToRoomDetails() = lambdaError()
override fun navigateToRoomMemberList() = lambdaError() override fun navigateToRoomMemberList() = lambdaError()
} }
val result = entryPoint.createNode( val result = entryPoint.createNode(

View file

@ -103,7 +103,7 @@ class UserProfileFlowNode(
// Cannot happen // Cannot happen
} }
override fun forwardEvent(eventId: EventId) { override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen // Cannot happen
} }
} }

View file

@ -33,7 +33,7 @@ accompanist = "0.37.3"
# Test # Test
test_core = "1.7.0" test_core = "1.7.0"
roborazzi = "1.50.0" roborazzi = "1.51.0"
# Jetbrain # Jetbrain
datetime = "0.7.1" datetime = "0.7.1"
@ -52,7 +52,7 @@ haze = "1.6.10"
dependencyAnalysis = "3.4.1" dependencyAnalysis = "3.4.1"
# DI # DI
metro = "0.7.3" metro = "0.7.4"
# Auto service # Auto service
autoservice = "1.1.1" 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 # 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 # 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. # 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 # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } 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" } metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call # 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 # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }

View file

@ -18,12 +18,12 @@ android {
testOptions { testOptions {
unitTests.isIncludeAndroidResources = true unitTests.isIncludeAndroidResources = true
} }
}
dependencies {
implementation(libs.showkase) dependencies {
testCommonDependencies(libs) implementation(libs.showkase)
testImplementation(libs.test.roborazzi) testCommonDependencies(libs)
testImplementation(libs.test.roborazzi.compose) testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.junit) testImplementation(libs.test.roborazzi.compose)
} testImplementation(libs.test.roborazzi.junit)
} }

View file

@ -11,8 +11,8 @@ plugins {
android { android {
namespace = "io.element.android.libraries.cryptography.test" namespace = "io.element.android.libraries.cryptography.test"
}
dependencies {
api(projects.libraries.cryptography.api) dependencies {
} api(projects.libraries.cryptography.api)
} }

View file

@ -13,8 +13,8 @@ plugins {
android { android {
namespace = "io.element.android.libraries.dateformatter.api" namespace = "io.element.android.libraries.dateformatter.api"
}
dependencies {
testCommonDependencies(libs) dependencies {
} testCommonDependencies(libs)
} }

View file

@ -30,19 +30,19 @@ android {
) )
} }
} }
}
dependencies {
implementation(projects.libraries.core) dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.core)
implementation(projects.libraries.di) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.di)
implementation(projects.services.toolbox.api) implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime) api(projects.libraries.dateformatter.api)
api(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test) testCommonDependencies(libs, true)
testImplementation(projects.services.toolbox.test) testImplementation(projects.libraries.dateformatter.test)
} testImplementation(projects.services.toolbox.test)
} }

View file

@ -11,9 +11,9 @@ plugins {
android { android {
namespace = "io.element.android.libraries.dateformatter.test" namespace = "io.element.android.libraries.dateformatter.test"
}
dependencies {
api(projects.libraries.dateformatter.api) dependencies {
api(libs.datetime) api(projects.libraries.dateformatter.api)
} api(libs.datetime)
} }

View file

@ -25,24 +25,24 @@ android {
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
} }
} }
}
dependencies {
api(projects.libraries.compound) dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.coil.compose) implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.vanniktech.blurhash) implementation(libs.coil.compose)
implementation(projects.libraries.androidutils) implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.architecture)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.core)
implementation(projects.libraries.testtags) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase) ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
} testCommonDependencies(libs)
} }

View file

@ -11,11 +11,11 @@ plugins {
android { android {
namespace = "io.element.android.libraries.featureflag.test" namespace = "io.element.android.libraries.featureflag.test"
}
dependencies {
api(projects.libraries.featureflag.api) dependencies {
implementation(projects.libraries.core) api(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.core)
implementation(libs.coroutines.core) implementation(projects.libraries.matrix.test)
} implementation(libs.coroutines.core)
} }

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.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<Boolean>
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api.encryption 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.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -17,7 +18,7 @@ interface EncryptionService {
val recoveryStateStateFlow: StateFlow<RecoveryState> val recoveryStateStateFlow: StateFlow<RecoveryState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress> val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
val isLastDevice: StateFlow<Boolean> val isLastDevice: StateFlow<Boolean>
val hasDevicesToVerifyAgainst: StateFlow<Boolean> val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>>
suspend fun enableBackups(): Result<Unit> suspend fun enableBackups(): Result<Unit>

View file

@ -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<Boolean> = 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()
}
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.encryption 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.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.mapFailure 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.EnableRecoveryProgressListener
import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.UserIdentity import org.matrix.rustcomponents.sdk.UserIdentity
import timber.log.Timber
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException 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 * TODO This is a temporary workaround, when we will have a way to observe
* the sessions, this code will have to be updated. * the sessions, this code will have to be updated.
*/ */
override val hasDevicesToVerifyAgainst: StateFlow<Boolean> = flow { override val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>> = flow {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
val result = hasDevicesToVerifyAgainst().getOrDefault(false) val result = hasDevicesToVerifyAgainst()
emit(result) result
.onSuccess {
emit(AsyncData.Success(it))
}
.onFailure {
Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...")
}
delay(5_000) delay(5_000)
} }
} }
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized)
override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) { override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions { runCatchingExceptions {

View file

@ -39,6 +39,7 @@ class NotificationMapper(
isDirect = item.roomInfo.isDirect, isDirect = item.roomInfo.isDirect,
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
) )
val timestamp = item.timestamp() ?: clock.epochMillis()
NotificationData( NotificationData(
sessionId = sessionId, sessionId = sessionId,
eventId = eventId, eventId = eventId,
@ -53,8 +54,8 @@ class NotificationMapper(
isDm = isDm, isDm = isDm,
isEncrypted = item.roomInfo.isEncrypted.orFalse(), isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = item.isNoisy.orFalse(), isNoisy = item.isNoisy.orFalse(),
timestamp = item.timestamp() ?: clock.epochMillis(), timestamp = timestamp,
content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(), content = notificationContentMapper.map(item.event).getOrThrow(),
hasMention = item.hasMention.orFalse(), hasMention = item.hasMention.orFalse(),
) )
} }

View file

@ -25,8 +25,9 @@ class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> { fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return runCatchingExceptions { return runCatchingExceptions {
timelineEvent.use { timelineEvent.use {
val senderId = UserId(timelineEvent.senderId())
timelineEvent.eventType().use { eventType -> timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = UserId(timelineEvent.senderId())) eventType.toContent(senderId = senderId)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show more