Merge branch 'release/25.10.0' into main

This commit is contained in:
Benoit Marty 2025-10-09 18:25:23 +02:00
commit 5246dc0aba
1593 changed files with 20683 additions and 4573 deletions

View file

@ -913,3 +913,8 @@ ij_yaml_sequence_on_new_line = false
ij_yaml_space_before_colon = false ij_yaml_space_before_colon = false
ij_yaml_spaces_within_braces = true ij_yaml_spaces_within_braces = true
ij_yaml_spaces_within_brackets = true ij_yaml_spaces_within_brackets = true
[**/generated/**]
generated_code = true
ij_formatter_enabled = false
ktlint = disabled

1
.gitattributes vendored
View file

@ -1,4 +1,5 @@
screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text
libraries/compound/screenshots/** filter=lfs diff=lfs merge=lfs -text
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text **/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text
libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text

View file

@ -36,7 +36,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs

View file

@ -44,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK

View file

@ -19,7 +19,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -34,7 +34,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK

View file

@ -27,7 +27,7 @@ jobs:
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: false cache-read-only: false
@ -67,7 +67,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@ -90,7 +90,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@ -130,7 +130,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@ -174,7 +174,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@ -214,7 +214,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
@ -254,7 +254,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Knit - name: Run Knit

View file

@ -40,7 +40,7 @@ jobs:
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -66,7 +66,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
- name: Create Enterprise app bundle - name: Create Enterprise app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@ -94,7 +94,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -56,6 +56,12 @@ echo "Deleting previous screenshots"
echo "Record screenshots" echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
echo "Deleting previous screenshots"
./gradlew removeOldScreenshots --stacktrace --warn $GRADLE_ARGS
echo "Record screenshots (Compound)"
./gradlew :libraries:compound:recordRoborazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn $GRADLE_ARGS
echo "Committing changes" echo "Committing changes"
git config http.sslVerify false git config http.sslVerify false

View file

@ -33,7 +33,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures

View file

@ -18,7 +18,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12

View file

@ -52,7 +52,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
@ -78,6 +78,7 @@ jobs:
name: tests-and-screenshot-tests-results name: tests-and-screenshot-tests-results
path: | path: |
**/build/paparazzi/failures/ **/build/paparazzi/failures/
**/build/roborazzi/failures/
**/build/reports/tests/*UnitTest/ **/build/reports/tests/*UnitTest/
# https://github.com/codecov/codecov-action # https://github.com/codecov/codecov-action

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.2.10" /> <option name="version" value="2.2.20" />
</component> </component>
</project> </project>

View file

@ -8,6 +8,6 @@ appId: ${MAESTRO_APP_ID}
- hideKeyboard - hideKeyboard
- tapOn: "Continue" - tapOn: "Continue"
- extendedWaitUntil: - extendedWaitUntil:
visible: "Verification complete" visible: "Device verified"
timeout: 30000 timeout: 30000
- tapOn: "Continue" - tapOn: "Continue"

View file

@ -1,3 +1,73 @@
Changes in Element X v25.09.2
=============================
## What's Changed
### ✨ Features
* Show progress dialog while we are sending invites in a room by @richvdh in https://github.com/element-hq/element-x-android/pull/5342
* Call: RTC decline event support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/5305
* Add room info to the thread's top app bar by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5374
### 🙌 Improvements
* Use the new RtcNotification event instead of the now deprecated CallNotify by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/5357
### 🐛 Bugfixes
* Increase Element Call audio init delay ensuring the right audio device is used by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5315
* Do not center the dialog title text for dialogs with no icon by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5332
* Media viewer: release the `ExoPlayers` when the hosting composables are disposed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5351
* Make PushData.clientSecret mandatory. by @bmarty in https://github.com/element-hq/element-x-android/pull/5369
* Cleanup ftue code and ensure verification confirmation is displayed by @bmarty in https://github.com/element-hq/element-x-android/pull/5379
* Change in clear cache behavior by @bmarty in https://github.com/element-hq/element-x-android/pull/5388
* fix (room navigation) : fix navigation when leaving room/space by @ganfra in https://github.com/element-hq/element-x-android/pull/5376
* fix (timeline) : forward pagination regression by @ganfra in https://github.com/element-hq/element-x-android/pull/5389
* When joining a call, wait for the `content_loaded` action by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5399
* Ensure the thread summary sender's display name won't wrap to the next line by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5403
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5349
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5385
### 🧱 Build
* Improve release script and the file Versions.kt by @bmarty in https://github.com/element-hq/element-x-android/pull/5318
* Dependency: extract the Matrix SDK and add instructions for upgrading the library by @bmarty in https://github.com/element-hq/element-x-android/pull/5363
* Add test on DefaultSpaceEntryPoint by @bmarty in https://github.com/element-hq/element-x-android/pull/5343
### 🚧 In development 🚧
* Space list by @bmarty in https://github.com/element-hq/element-x-android/pull/5320
* Feature : Join Space (WIP) by @ganfra in https://github.com/element-hq/element-x-android/pull/5378
### Dependency upgrades
* Update activity to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5324
* Update dependency com.google.truth:truth to v1.4.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5322
* Update dependency io.sentry:sentry-android to v8.21.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5310
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5323
* Update dependency androidx.sqlite:sqlite-ktx to v2.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5337
* Update camera to v1.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5336
* Update dependency com.posthog:posthog-android to v3.21.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5333
* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5341
* Upgrade Rust SDK bindings to v25.09.15 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5353
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.16 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5359
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5365
* Update telephoto to v0.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5350
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5377
* Update dependency com.google.firebase:firebase-bom to v34.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5367
* Upgrade Element Call embedded dependency to `v0.16.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5391
* Update dependencyAnalysis to v3 (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5194
* Update dependency org.maplibre.gl:android-sdk to v11.13.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5381
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5396
* Update plugin dependencycheck to v12.1.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5382
* Update dependency io.sentry:sentry-android to v8.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5397
### Others
* Cleanup nodes by @bmarty in https://github.com/element-hq/element-x-android/pull/5358
* Complete test on MediaGalleryPresenter by @bmarty in https://github.com/element-hq/element-x-android/pull/5361
* Remove dead code by @bmarty in https://github.com/element-hq/element-x-android/pull/5306
* Introduce BugReportFlowNode, and remove NavTarget.ViewLogs from RootFlowNode by @bmarty in https://github.com/element-hq/element-x-android/pull/5370
* When logging out from Pin code screen, logout from all the sessions. by @bmarty in https://github.com/element-hq/element-x-android/pull/5372
* Clean MatrixAuthenticationService and SessionStore API by @bmarty in https://github.com/element-hq/element-x-android/pull/5371
* Add logs to detect duplicates in the room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5364
* Add troubleshoot notification test about blocked users by @bmarty in https://github.com/element-hq/element-x-android/pull/5394
* Add thread decoration with latest event details by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5355
* Rework on messages view top bars by @bmarty in https://github.com/element-hq/element-x-android/pull/5401
* Put developer settings at the end of the view by @p1gp1g in https://github.com/element-hq/element-x-android/pull/5387
## New Contributors
* @p1gp1g made their first contribution in https://github.com/element-hq/element-x-android/pull/5387
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.09.1...v25.09.2
Changes in Element X v25.09.1 Changes in Element X v25.09.1
============================= =============================

View file

@ -8,7 +8,3 @@ plugins {
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
id("com.android.lint") id("com.android.lint")
} }
dependencies {
api(libs.inject)
}

View file

@ -33,7 +33,6 @@ import io.element.android.x.BuildConfig
import io.element.android.x.R import io.element.android.x.R
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import java.io.File import java.io.File
@ -107,11 +106,7 @@ object AppModule {
@Provides @Provides
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers { fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers( return CoroutineDispatchers.Default
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
} }
@Provides @Provides

View file

@ -9,15 +9,15 @@ package io.element.android.x.di
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@Inject @Inject
class DefaultRoomComponentFactory( class DefaultRoomGraphFactory(
private val sessionGraph: SessionGraph, private val sessionGraph: SessionGraph,
) : RoomComponentFactory { ) : RoomGraphFactory {
override fun create(room: JoinedRoom): Any { override fun create(room: JoinedRoom): Any {
return sessionGraph.roomGraphFactory return sessionGraph.roomGraphFactory
.create(room, room) .create(room, room)

View file

@ -7,18 +7,15 @@
package io.element.android.x.di package io.element.android.x.di
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
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.di.SessionScope
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 {
@ContributesTo(SessionScope::class)
@GraphExtension.Factory @GraphExtension.Factory
interface Factory { interface Factory {
fun create( fun create(

View file

@ -7,8 +7,6 @@
package io.element.android.x.di package io.element.android.x.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.NodeFactoriesBindings
@ -19,7 +17,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
interface SessionGraph : NodeFactoriesBindings { interface SessionGraph : NodeFactoriesBindings {
val roomGraphFactory: RoomGraph.Factory val roomGraphFactory: RoomGraph.Factory
@ContributesTo(AppScope::class)
@GraphExtension.Factory @GraphExtension.Factory
interface Factory { interface Factory {
fun create(@Provides matrixClient: MatrixClient): SessionGraph fun create(@Provides matrixClient: MatrixClient): SessionGraph

View file

@ -26,9 +26,11 @@ dependencies {
allFeaturesApi(project) allFeaturesApi(project)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.accountselect.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink.api) implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oidc.api) implementation(projects.libraries.oidc.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
@ -36,11 +38,13 @@ dependencies {
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api) implementation(projects.features.login.api)
implementation(libs.coil) implementation(libs.coil)
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.share.api) implementation(projects.features.share.api)

View file

@ -24,7 +24,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -41,7 +41,7 @@ import kotlinx.parcelize.Parcelize
* This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode]. * This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode].
*/ */
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@Inject @AssistedInject
class LoggedInAppScopeFlowNode( class LoggedInAppScopeFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -57,6 +57,7 @@ class LoggedInAppScopeFlowNode(
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onAddAccount()
} }
@Parcelize @Parcelize
@ -83,6 +84,10 @@ class LoggedInAppScopeFlowNode(
override fun onOpenBugReport() { override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}
} }
return createNode<LoggedInFlowNode>(buildContext, listOf(callback)) return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
} }

View file

@ -31,9 +31,17 @@ class LoggedInEventProcessor(
observingJob = roomMembershipObserver.updates observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom } .filter { !it.isUserInRoom }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach { roomMemberShipUpdate ->
when (it.change) { when (roomMemberShipUpdate.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room) MembershipChange.LEFT -> {
displayMessage(
if (roomMemberShipUpdate.isSpace) {
CommonStrings.common_current_user_left_space
} else {
CommonStrings.common_current_user_left_room
}
)
}
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite) MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock) MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit else -> Unit

View file

@ -36,9 +36,8 @@ 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 com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
@ -75,13 +74,13 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
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.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.ui.common.nodes.emptyNode
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.first import kotlinx.coroutines.flow.first
@ -100,7 +99,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class LoggedInFlowNode( class LoggedInFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -138,11 +137,12 @@ class LoggedInFlowNode(
) { ) {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onAddAccount()
} }
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
matrixClient.roomMembershipObserver(), roomMembershipObserver = matrixClient.roomMembershipObserver,
) )
private val verificationListener = object : SessionVerificationServiceListener { private val verificationListener = object : SessionVerificationServiceListener {
@ -189,7 +189,7 @@ class LoggedInFlowNode(
// TODO We do not support Space yet, so directly navigate to main space // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener) matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration() mediaPreviewConfigMigration()
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
@ -218,7 +218,7 @@ class LoggedInFlowNode(
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService().setListener(null) matrixClient.sessionVerificationService.setListener(null)
} }
) )
setupSendingQueue() setupSendingQueue()
@ -281,7 +281,7 @@ class LoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext) NavTarget.Placeholder -> emptyNode(buildContext)
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
val callback = object : LoggedInNode.Callback { val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() { override fun navigateToNotificationTroubleshoot() {
@ -366,8 +366,8 @@ class LoggedInFlowNode(
} }
} }
val spaceCallback = object : SpaceEntryPoint.Callback { val spaceCallback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames = viaParameters))
} }
} }
val inputs = RoomFlowNode.Inputs( val inputs = RoomFlowNode.Inputs(
@ -392,6 +392,10 @@ class LoggedInFlowNode(
} }
is NavTarget.Settings -> { is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}
override fun onOpenBugReport() { override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
@ -404,11 +408,7 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
} }
override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) { override fun navigateTo(roomId: RoomId, eventId: EventId) {
// We do not check the sessionId, but it will have to be done at some point (multi account)
if (sessionId != matrixClient.sessionId) {
Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId")
}
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId))) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
} }
} }
@ -548,13 +548,6 @@ class LoggedInFlowNode(
} }
} }
@ContributesNode(AppScope::class)
@Inject
class PlaceholderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
@Parcelize @Parcelize
private class AttachRoomOperation( private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room, val roomTarget: LoggedInFlowNode.NavTarget.Room,

View file

@ -22,7 +22,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
@ -36,7 +36,7 @@ import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactor
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@Inject @AssistedInject
class NotLoggedInFlowNode( class NotLoggedInFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -9,23 +9,23 @@ package io.element.android.appnav
import android.content.Intent import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.animation.core.Spring
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext 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.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.MutableSavedStateMap
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.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.di.MatrixSessionCache
@ -34,18 +34,22 @@ import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView import io.element.android.appnav.root.RootView
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
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.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -53,14 +57,16 @@ import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@Inject @AssistedInject
class RootFlowNode( class RootFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -71,9 +77,12 @@ class RootFlowNode(
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val bugReporter: BugReporter, private val bugReporter: BugReporter,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.SplashScreen,
@ -95,27 +104,24 @@ class RootFlowNode(
} }
private fun observeNavState() { private fun observeNavState() {
navStateFlowFactory.create(buildContext.savedStateMap) navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState ->
.distinctUntilChanged() Timber.v("navState=$navState")
.onEach { navState -> when (navState.loggedInState) {
Timber.v("navState=$navState") is LoggedInState.LoggedIn -> {
when (navState.loggedInState) { if (navState.loggedInState.isTokenValid) {
is LoggedInState.LoggedIn -> { tryToRestoreLatestSession(
if (navState.loggedInState.isTokenValid) { onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
tryToRestoreLatestSession( onFailure = { switchToNotLoggedInFlow(null) }
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, )
onFailure = { switchToNotLoggedInFlow(null) } } else {
) switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
} }
} }
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
}
} }
.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
} }
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
@ -137,20 +143,17 @@ class RootFlowNode(
onFailure: () -> Unit, onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit, onSuccess: (SessionId) -> Unit,
) { ) {
matrixSessionCache.getOrRestore(sessionId) matrixSessionCache.getOrRestore(sessionId).onSuccess {
.onSuccess { Timber.v("Succeed to restore session $sessionId")
Timber.v("Succeed to restore session $sessionId") onSuccess(sessionId)
onSuccess(sessionId) }.onFailure {
} Timber.e(it, "Failed to restore session $sessionId")
.onFailure { onFailure()
Timber.e(it, "Failed to restore session $sessionId") }
onFailure()
}
} }
private suspend fun tryToRestoreLatestSession( private suspend fun tryToRestoreLatestSession(
onSuccess: (SessionId) -> Unit, onSuccess: (SessionId) -> Unit, onFailure: () -> Unit
onFailure: () -> Unit
) { ) {
val latestSessionId = sessionStore.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
@ -172,45 +175,64 @@ class RootFlowNode(
modifier = modifier, modifier = modifier,
onOpenBugReport = this::onOpenBugReport, onOpenBugReport = this::onOpenBugReport,
) { ) {
BackstackView() val backstackSlider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val backstackFader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
when (navTarget) {
is NavTarget.SplashScreen,
is NavTarget.LoggedInFlow -> backstackFader
else -> backstackSlider
}
}
BackstackView(transitionHandler = transitionHandler)
announcementService.Render(Modifier)
} }
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize data object SplashScreen : NavTarget
data object SplashScreen : NavTarget
@Parcelize @Parcelize data class AccountSelect(
data class NotLoggedInFlow( val currentSessionId: SessionId,
val intent: Intent?,
val permalinkData: PermalinkData?,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
val params: LoginParams? val params: LoginParams?
) : NavTarget ) : NavTarget
@Parcelize @Parcelize data class LoggedInFlow(
data class LoggedInFlow( val sessionId: SessionId, val navId: Int
val sessionId: SessionId,
val navId: Int
) : NavTarget ) : NavTarget
@Parcelize @Parcelize data class SignedOutFlow(
data class SignedOutFlow(
val sessionId: SessionId val sessionId: SessionId
) : NavTarget ) : NavTarget
@Parcelize @Parcelize data object BugReport : NavTarget
data object BugReport : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.LoggedInFlow -> { is NavTarget.LoggedInFlow -> {
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId)
Timber.w("Couldn't find any session, go through SplashScreen") ?: return emptyNode(buildContext).also {
} Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInAppScopeFlowNode.Callback { val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport) backstack.push(NavTarget.BugReport)
} }
override fun onAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
} }
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
@ -226,32 +248,46 @@ class RootFlowNode(
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback)) createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
} }
is NavTarget.SignedOutFlow -> { is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext) signedOutEntryPoint.nodeBuilder(this, buildContext).params(
.params( SignedOutEntryPoint.Params(
SignedOutEntryPoint.Params( sessionId = navTarget.sessionId
sessionId = navTarget.sessionId
)
) )
.build() ).build()
} }
NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.SplashScreen -> emptyNode(buildContext)
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onDone() { override fun onDone() {
backstack.pop() backstack.pop()
} }
} }
bugReportEntryPoint bugReportEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
} }
} is NavTarget.AccountSelect -> {
} val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback {
override fun onSelectAccount(sessionId: SessionId) {
lifecycleScope.launch {
if (sessionId == navTarget.currentSessionId) {
// Ensure that the account selection Node is removed from the backstack
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.intent != null) {
attachIncomingShare(navTarget.intent)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
}
}
private fun splashNode(buildContext: BuildContext) = node(buildContext) { override fun onCancel() {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { backstack.pop()
CircularProgressIndicator() }
}
accountSelectEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
}
} }
} }
@ -267,19 +303,29 @@ class RootFlowNode(
} }
private suspend fun onLoginLink(params: LoginParams) { private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already? if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
val latestSessionId = sessionStore.getLatestSessionId() // Is there a session already?
if (latestSessionId == null) { val sessions = sessionStore.getAllSessions()
// No session, open login if (sessions.isNotEmpty()) {
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
switchToNotLoggedInFlow(params) val loginHintMatrixId = params.loginHint?.removePrefix("mxid:")
val existingAccount = sessions.find { it.userId == loginHintMatrixId }
if (existingAccount != null) {
// We have an existing account matching the login hint, ensure this is the current session
sessionStore.setLatestSession(existingAccount.userId)
} else {
val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId
attachSession(SessionId(latestSessionId))
backstack.push(NavTarget.NotLoggedInFlow(params))
}
} else {
Timber.w("Login link ignored, multi account is disabled")
}
} else { } else {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver") switchToNotLoggedInFlow(params)
switchToNotLoggedInFlow(null)
} }
} else { } else {
// Just ignore the login link if we already have a session Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
Timber.w("Login link ignored, we already have a session")
} }
} }
@ -290,56 +336,95 @@ class RootFlowNode(
// No session, open login // No session, open login
switchToNotLoggedInFlow(null) switchToNotLoggedInFlow(null)
} else { } else {
attachSession(latestSessionId) // wait for the current session to be restored
.attachIncomingShare(intent) val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.getAllSessions().size > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
intent = intent,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(intent)
}
} }
} }
private suspend fun navigateTo(permalinkData: PermalinkData) { private suspend fun navigateTo(permalinkData: PermalinkData) {
Timber.d("Navigating to $permalinkData") Timber.d("Navigating to $permalinkData")
attachSession(null) // Is there a session already?
.apply { val latestSessionId = sessionStore.getLatestSessionId()
when (permalinkData) { if (latestSessionId == null) {
is PermalinkData.FallbackLink -> Unit // No session, open login
is PermalinkData.RoomEmailInviteLink -> Unit switchToNotLoggedInFlow(null)
is PermalinkData.RoomLink -> { } else {
attachRoom( // wait for the current session to be restored
roomIdOrAlias = permalinkData.roomIdOrAlias, val loggedInFlowNode = attachSession(latestSessionId)
trigger = JoinedRoom.Trigger.MobilePermalink, when (permalinkData) {
serverNames = permalinkData.viaParameters, is PermalinkData.FallbackLink -> Unit
eventId = permalinkData.eventId, is PermalinkData.RoomEmailInviteLink -> Unit
clearBackstack = true else -> {
if (sessionStore.getAllSessions().size > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
intent = null,
permalinkData = permalinkData,
)
) )
} } else {
is PermalinkData.UserLink -> { // Only one account, directly attach the room or the user node.
attachUser(permalinkData.userId) loggedInFlowNode.attachPermalinkData(permalinkData)
} }
} }
} }
}
}
private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) {
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
clearBackstack = true
)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
}
}
} }
private suspend fun navigateTo(deeplinkData: DeeplinkData) { private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId) attachSession(deeplinkData.sessionId).apply {
.apply { when (deeplinkData) {
when (deeplinkData) { is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
}
} }
}
} }
private fun onOidcAction(oidcAction: OidcAction) { private fun onOidcAction(oidcAction: OidcAction) {
oidcActionFlow.post(oidcAction) oidcActionFlow.post(oidcAction)
} }
// [sessionId] will be null for permalink. private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode { // Ensure that the session is the latest one
// TODO handle multi-session sessionStore.setLatestSession(sessionId.value)
return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget -> return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId) navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
} }.attachSession()
.attachSession()
} }
} }

View file

@ -109,7 +109,10 @@ class MatrixSessionCache(
} }
private fun onNewMatrixClient(matrixClient: MatrixClient) { private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient) val syncOrchestrator = syncOrchestratorFactory.create(
syncService = matrixClient.syncService,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient, matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator, syncOrchestrator = syncOrchestrator,

View file

@ -9,6 +9,6 @@ package io.element.android.appnav.di
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
fun interface RoomComponentFactory { fun interface RoomGraphFactory {
fun create(room: JoinedRoom): Any fun create(room: JoinedRoom): Any
} }

View file

@ -10,14 +10,15 @@ package io.element.android.appnav.di
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
@ -30,23 +31,25 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @AssistedInject
class SyncOrchestrator( class SyncOrchestrator(
@Assisted matrixClient: MatrixClient, @Assisted private val syncService: SyncService,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
dispatchers: CoroutineDispatchers, dispatchers: CoroutineDispatchers,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(matrixClient: MatrixClient): SyncOrchestrator fun create(
syncService: SyncService,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
} }
private val syncService = matrixClient.syncService()
private val tag = "SyncOrchestrator" private val tag = "SyncOrchestrator"
private val coroutineScope = matrixClient.sessionCoroutineScope.childScope(dispatchers.io, tag) private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)

View file

@ -14,12 +14,12 @@ 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.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class LoggedInNode( class LoggedInNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -22,7 +22,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
@ -64,7 +64,7 @@ import java.util.Optional
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class RoomFlowNode( class RoomFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -25,7 +25,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class JoinedRoomFlowNode( class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -18,9 +18,9 @@ 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.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
@ -45,7 +45,7 @@ import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class JoinedRoomLoadedFlowNode( class JoinedRoomLoadedFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -56,7 +56,7 @@ class JoinedRoomLoadedFlowNode(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory, roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) { initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) {
@ -83,7 +83,7 @@ class JoinedRoomLoadedFlowNode(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>() private val callbacks = plugins.filterIsInstance<Callback>()
override val graph = roomComponentFactory.create(inputs.room) override val graph = roomGraphFactory.create(inputs.room)
init { init {
lifecycle.subscribe( lifecycle.subscribe(

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -70,7 +70,7 @@ class JoinedRoomLoadedFlowNodeTest {
} }
} }
private class FakeRoomComponentFactory : RoomComponentFactory { private class FakeRoomGraphFactory : RoomGraphFactory {
override fun create(room: JoinedRoom): Any { override fun create(room: JoinedRoom): Any {
return Unit return Unit
} }
@ -110,7 +110,7 @@ class JoinedRoomLoadedFlowNodeTest {
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = this, sessionCoroutineScope = this,
roomComponentFactory = FakeRoomComponentFactory(), roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
) )

View file

@ -11,7 +11,6 @@ import io.element.android.appnav.di.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -385,7 +384,8 @@ class SyncOrchestratorTest {
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator( ) = SyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService, sessionCoroutineScope = backgroundScope), syncService = syncService,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),

View file

@ -10,12 +10,13 @@ package io.element.android.appnav.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -117,9 +118,13 @@ class MatrixSessionCacheTest {
} }
private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory { private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
override fun create(matrixClient: MatrixClient): SyncOrchestrator { override fun create(
syncService: SyncService,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator( return SyncOrchestrator(
matrixClient, syncService = syncService,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),

View file

@ -111,7 +111,7 @@ class IntentResolverTest {
@Test @Test
fun `test resolve oidc`() { fun `test resolve oidc`() {
val sut = createIntentResolver( val sut = createIntentResolver(
oidcIntentResolverResult = { OidcAction.GoBack }, oidcIntentResolverResult = { OidcAction.GoBack() },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
@ -120,7 +120,7 @@ class IntentResolverTest {
val result = sut.resolve(intent) val result = sut.resolve(intent)
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
ResolvedIntent.Oidc( ResolvedIntent.Oidc(
oidcAction = OidcAction.GoBack oidcAction = OidcAction.GoBack()
) )
) )
} }

View file

@ -15,6 +15,7 @@ plugins {
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.dependencycheck) apply false alias(libs.plugins.dependencycheck) apply false
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.dependencyanalysis) alias(libs.plugins.dependencyanalysis)
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
@ -192,6 +193,21 @@ subprojects {
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
} }
// Make sure to delete old snapshot before recording new ones
subprojects {
val screenshotsDir = File("${project.projectDir}/screenshots")
val removeOldScreenshotsTask = tasks.register("removeOldScreenshots") {
onlyIf { screenshotsDir.exists() }
doFirst {
println("Delete previous screenshots located at $screenshotsDir\n")
screenshotsDir.deleteRecursively()
}
}
tasks.findByName("recordRoborazzi")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordRoborazziDebug")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordRoborazziRelease")?.dependsOn(removeOldScreenshotsTask)
}
subprojects { subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions { compilerOptions {

View file

@ -35,6 +35,7 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.IntoMap
import dev.zacsweers.metro.Origin
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.FqName
@ -71,14 +72,16 @@ class ContributesNodeProcessor(
val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType
val modulePackage = ksClass.packageName.asString() val modulePackage = ksClass.packageName.asString()
val moduleClassName = "${ksClass.simpleName.asString()}_Module" val moduleClassName = "${ksClass.simpleName.asString()}_Module"
val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString())
val content = FileSpec.builder( val content = FileSpec.builder(
packageName = modulePackage, packageName = modulePackage,
fileName = moduleClassName, fileName = moduleClassName,
) )
.addType( .addType(
TypeSpec.interfaceBuilder(moduleClassName) TypeSpec.interfaceBuilder(moduleClassName)
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember(CLASS_PLACEHOLDER, nodeClassName).build())
.addAnnotation(BindingContainer::class) .addAnnotation(BindingContainer::class)
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.toTypeName()).build()) .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember(CLASS_PLACEHOLDER, scope.toTypeName()).build())
.addFunction( .addFunction(
FunSpec.builder("bind${ksClass.simpleName.asString()}Factory") FunSpec.builder("bind${ksClass.simpleName.asString()}Factory")
.addModifiers(KModifier.ABSTRACT) .addModifiers(KModifier.ABSTRACT)
@ -88,7 +91,7 @@ class ContributesNodeProcessor(
.addAnnotation(IntoMap::class) .addAnnotation(IntoMap::class)
.addAnnotation( .addAnnotation(
AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember( AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
"%T::class", CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString()) ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).build() ).build()
) )
@ -115,7 +118,7 @@ class ContributesNodeProcessor(
val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) } val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) }
if (assistedParameters.size != 2) { if (assistedParameters.size != 2) {
error( error(
"${ksClass.qualifiedName?.asString()} must have an @Inject constructor with 2 @Assisted parameters. Found: ${assistedParameters.size}", "${ksClass.qualifiedName?.asString()} must have a constructor with 2 @Assisted parameters. Found: ${assistedParameters.size}",
) )
} }
val contextAssistedParam = assistedParameters[0] val contextAssistedParam = assistedParameters[0]
@ -138,6 +141,7 @@ class ContributesNodeProcessor(
.addType( .addType(
TypeSpec.interfaceBuilder(assistedFactoryClassName) TypeSpec.interfaceBuilder(assistedFactoryClassName)
.addSuperinterface(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(nodeClassName)) .addSuperinterface(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(nodeClassName))
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember("%T::class", nodeClassName).build())
.addAnnotation(AssistedFactory::class) .addAnnotation(AssistedFactory::class)
.addFunction( .addFunction(
FunSpec.builder("create") FunSpec.builder("create")
@ -161,6 +165,7 @@ class ContributesNodeProcessor(
} }
companion object { companion object {
private const val CLASS_PLACEHOLDER = "%T::class"
private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory") private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory")
private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey") private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey")
} }

@ -1 +1 @@
Subproject commit 95789d40119499eba8a79284df9dd2306405b099 Subproject commit ffc02b8d0f35188c3ef8a876dc1532bfe3e533da

View file

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

View file

@ -16,14 +16,14 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.AnalyticsConfig import io.element.android.appconfig.AnalyticsConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@Inject @AssistedInject
class AnalyticsOptInNode( class AnalyticsOptInNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.announcement.api"
}

View file

@ -5,8 +5,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.announcement.api
sealed interface SpaceEvents { enum class Announcement {
data object LoadMore : SpaceEvents Space,
NewNotificationSound,
} }

View file

@ -0,0 +1,28 @@
/*
* 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.announcement.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
interface AnnouncementService {
suspend fun showAnnouncement(announcement: Announcement)
suspend fun onAnnouncementDismissed(announcement: Announcement)
fun announcementsToShowFlow(): Flow<List<Announcement>>
/**
* Use this composable to render the announcement UI in Fullscreen.
*/
@Composable
fun Render(
modifier: Modifier,
)
}

View file

@ -0,0 +1,37 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.announcement.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
api(projects.features.announcement.api)
implementation(libs.androidx.datastore.preferences)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
}

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.features.announcement.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.map
@Inject
class AnnouncementPresenter(
private val announcementStore: AnnouncementStore,
) : Presenter<AnnouncementState> {
@Composable
override fun present(): AnnouncementState {
val showSpaceAnnouncement by remember {
announcementStore.announcementStatusFlow(Announcement.Space).map {
it == AnnouncementStatus.Show
}
}.collectAsState(false)
return AnnouncementState(
showSpaceAnnouncement = showSpaceAnnouncement,
)
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.announcement.impl
data class AnnouncementState(
val showSpaceAnnouncement: Boolean,
)
fun anAnnouncementState(
showSpaceAnnouncement: Boolean = false,
) = AnnouncementState(
showSpaceAnnouncement = showSpaceAnnouncement,
)

View file

@ -0,0 +1,90 @@
/*
* 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.announcement.impl
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ContributesBinding(AppScope::class)
@Inject
class DefaultAnnouncementService(
private val announcementStore: AnnouncementStore,
private val announcementPresenter: Presenter<AnnouncementState>,
private val spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState>,
) : AnnouncementService {
override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) {
Announcement.Space -> showSpaceAnnouncement()
Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
}
}
}
override suspend fun onAnnouncementDismissed(announcement: Announcement) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Shown)
}
override fun announcementsToShowFlow(): Flow<List<Announcement>> {
return combine(
announcementStore.announcementStatusFlow(Announcement.Space),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { spaceAnnouncementStatus, newNotificationSoundStatus ->
buildList {
if (spaceAnnouncementStatus == AnnouncementStatus.Show) {
add(Announcement.Space)
}
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound)
}
}
}
}
private suspend fun showSpaceAnnouncement() {
val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first()
if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
}
}
@Composable
override fun Render(modifier: Modifier) {
val announcementState = announcementPresenter.present()
Box(modifier = modifier.fillMaxSize()) {
AnimatedVisibility(
visible = announcementState.showSpaceAnnouncement,
enter = fadeIn(),
exit = fadeOut(),
) {
val spaceAnnouncementState = spaceAnnouncementPresenter.present()
SpaceAnnouncementView(
state = spaceAnnouncementState,
)
}
}
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.announcement.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.announcement.impl.AnnouncementPresenter
import io.element.android.features.announcement.impl.AnnouncementState
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementPresenter
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
interface AnnouncementModule {
@Binds
fun bindAnnouncementPresenter(presenter: AnnouncementPresenter): Presenter<AnnouncementState>
@Binds
fun bindSpaceAnnouncementPresenter(presenter: SpaceAnnouncementPresenter): Presenter<SpaceAnnouncementState>
}

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.announcement.impl.spaces
sealed interface SpaceAnnouncementEvents {
data object Continue : SpaceAnnouncementEvents
}

View file

@ -0,0 +1,39 @@
/*
* 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.announcement.impl.spaces
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
@Inject
class SpaceAnnouncementPresenter(
private val announcementStore: AnnouncementStore,
) : Presenter<SpaceAnnouncementState> {
@Composable
override fun present(): SpaceAnnouncementState {
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: SpaceAnnouncementEvents) {
when (event) {
SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch {
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
}
}
}
return SpaceAnnouncementState(
eventSink = ::handleEvents
)
}
}

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.announcement.impl.spaces
data class SpaceAnnouncementState(
val eventSink: (SpaceAnnouncementEvents) -> Unit
)

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.announcement.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class SpaceAnnouncementStateProvider : PreviewParameterProvider<SpaceAnnouncementState> {
override val values: Sequence<SpaceAnnouncementState>
get() = sequenceOf(
aSpaceAnnouncementState(),
)
}
fun aSpaceAnnouncementState(
eventSink: (SpaceAnnouncementEvents) -> Unit = {},
) = SpaceAnnouncementState(
eventSink = eventSink,
)

View file

@ -0,0 +1,157 @@
/*
* 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.announcement.impl.spaces
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.announcement.impl.R
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.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4593-40181
*/
@Composable
fun SpaceAnnouncementView(
state: SpaceAnnouncementState,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
fun onContinue() {
eventSink(SpaceAnnouncementEvents.Continue)
}
BackHandler(onBack = ::onContinue)
HeaderFooterPage(
modifier = modifier,
isScrollable = true,
contentPadding = PaddingValues(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 24.dp),
header = {
SpaceAnnouncementHeader()
},
content = {
SpaceAnnouncementContent(
modifier = Modifier.padding(horizontal = 8.dp),
)
},
footer = {
SpaceAnnouncementFooter(
onContinue = ::onContinue,
)
}
)
}
@Composable
private fun SpaceAnnouncementHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 16.dp, bottom = 16.dp),
title = stringResource(id = R.string.screen_space_announcement_title),
showBetaLabel = true,
subTitle = stringResource(id = R.string.screen_space_announcement_subtitle),
iconStyle = BigIcon.Style.Default(
vectorIcon = CompoundIcons.WorkspaceSolid(),
usePrimaryTint = true,
),
)
}
@Composable
private fun SpaceAnnouncementContent(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
) {
InfoListOrganism(
modifier = Modifier.fillMaxWidth(),
items = persistentListOf(
InfoListItem(
message = stringResource(id = R.string.screen_space_announcement_item1),
iconVector = CompoundIcons.VisibilityOn(),
),
InfoListItem(
message = stringResource(id = R.string.screen_space_announcement_item2),
iconVector = CompoundIcons.Email(),
),
InfoListItem(
message = stringResource(id = R.string.screen_space_announcement_item3),
iconVector = CompoundIcons.Search(),
),
InfoListItem(
message = stringResource(id = R.string.screen_space_announcement_item4),
iconVector = CompoundIcons.Explore(),
),
InfoListItem(
message = stringResource(id = R.string.screen_space_announcement_item5),
iconVector = CompoundIcons.Leave(),
),
),
textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.iconSecondary,
iconSize = 24.dp
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
text = stringResource(id = R.string.screen_space_announcement_notice),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun SpaceAnnouncementFooter(
onContinue: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 8.dp)
) {
Button(
text = stringResource(id = CommonStrings.action_continue),
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
)
}
}
@PreviewsDayNight
@Composable
internal fun SpaceAnnouncementViewPreview(@PreviewParameter(SpaceAnnouncementStateProvider::class) state: SpaceAnnouncementState) = ElementPreview {
SpaceAnnouncementView(
state = state,
)
}

View file

@ -0,0 +1,14 @@
/*
* 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.announcement.impl.store
enum class AnnouncementStatus {
NeverShown,
Show,
Shown,
}

View file

@ -0,0 +1,24 @@
/*
* 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.announcement.impl.store
import io.element.android.features.announcement.api.Announcement
import kotlinx.coroutines.flow.Flow
interface AnnouncementStore {
suspend fun setAnnouncementStatus(
announcement: Announcement,
status: AnnouncementStatus,
)
fun announcementStatusFlow(
announcement: Announcement,
): Flow<AnnouncementStatus>
suspend fun reset()
}

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.announcement.impl.store
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
@ContributesBinding(AppScope::class)
@Inject
class DefaultAnnouncementStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AnnouncementStore {
private val store = preferenceDataStoreFactory.create("elementx_announcement")
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
val key = announcement.toKey()
store.edit { prefs ->
prefs[key] = status.ordinal
}
}
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey()
// For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08)
val defaultStatus = when (announcement) {
Announcement.Space -> AnnouncementStatus.NeverShown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
}
return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal
AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus }
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}
private fun Announcement.toKey() = when (this) {
Announcement.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Se klynger, du har oprettet eller tilmeldt dig"</string>
<string name="screen_space_announcement_item2">"Acceptere eller afvise invitationer til klynger"</string>
<string name="screen_space_announcement_item3">"Finde alle rum, du kan deltage i, i dine klynger"</string>
<string name="screen_space_announcement_item4">"Deltage i offentlige klynger"</string>
<string name="screen_space_announcement_item5">"Forlade de klynger, du har tilsluttet dig"</string>
<string name="screen_space_announcement_notice">"Oprettelse og administration af klynger kommer snart."</string>
<string name="screen_space_announcement_subtitle">"Velkommen til betaversionen af Klynger! Med denne første version kan du:"</string>
<string name="screen_space_announcement_title">"Introduktion til Klynger"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Von dir erstellte oder beigetretene Spaces anzeigen"</string>
<string name="screen_space_announcement_item2">"Einladungen zu Spaces annehmen oder ablehnen"</string>
<string name="screen_space_announcement_item3">"Chats innerhalb deiner Spaces entdecken, um ihnen beizutreten"</string>
<string name="screen_space_announcement_item4">"Öffentlichen Spaces beitreten"</string>
<string name="screen_space_announcement_item5">"Spaces verlassen, bei denen du Mitglied bist"</string>
<string name="screen_space_announcement_notice">"Das Erstellen und Verwalten von Spaces ist bald verfügbar."</string>
<string name="screen_space_announcement_subtitle">"Willkommen bei der Beta-Version von Spaces! Mit dieser ersten Version kannst du:"</string>
<string name="screen_space_announcement_title">"Einführung in Spaces"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Nähdä luomasi tai liittymäsi tilat"</string>
<string name="screen_space_announcement_item2">"Hyväksyä tai hylätä kutsuja tiloihin"</string>
<string name="screen_space_announcement_item3">"Löytää kaikki huoneet, joihin voit liittyä tiloissasi"</string>
<string name="screen_space_announcement_item4">"Liittyä julkisiin tiloihin"</string>
<string name="screen_space_announcement_item5">"Poistua mistä tahansa tilasta, johon olet liittynyt"</string>
<string name="screen_space_announcement_notice">"Tilojen luominen ja hallinta on tulossa pian."</string>
<string name="screen_space_announcement_subtitle">"Tervetuloa tilojen beetaversioon! Tämän ensimmäisen version avulla voit:"</string>
<string name="screen_space_announcement_title">"Esittelyssä tilat"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Voir les espaces que vous avez créés ou rejoints"</string>
<string name="screen_space_announcement_item2">"Accepter ou refuser les invitations aux espaces"</string>
<string name="screen_space_announcement_item3">"Découvrir les salons que vous pouvez joindre depuis vos espaces"</string>
<string name="screen_space_announcement_item4">"Rejoindre les espaces publics"</string>
<string name="screen_space_announcement_item5">"Quitter les espaces dont vous êtes membre."</string>
<string name="screen_space_announcement_notice">"La création et la gestion des espaces seront bientôt disponibles."</string>
<string name="screen_space_announcement_subtitle">"Bienvenue dans la version bêta des espaces! Avec cette première version, vous pourrez :"</string>
<string name="screen_space_announcement_title">"Ajout des espaces"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Se områder du har opprettet eller blitt med i"</string>
<string name="screen_space_announcement_item2">"Godta eller avslå invitasjoner til områder"</string>
<string name="screen_space_announcement_item3">"Oppdag alle rom du kan bli med i i dine områder"</string>
<string name="screen_space_announcement_item4">"Bli med i offentlige områder"</string>
<string name="screen_space_announcement_item5">"Forlat områder du har blitt med i"</string>
<string name="screen_space_announcement_notice">"Oppretting og administrasjon av områder kommer snart."</string>
<string name="screen_space_announcement_subtitle">"Velkommen til betaversjonen av Områder! Med denne første versjonen kan du:"</string>
<string name="screen_space_announcement_title">"Vi introduserer Områder"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Vizualizați spațiile pe care le-ați creat sau la care v-ați alăturat"</string>
<string name="screen_space_announcement_item2">"Acceptați sau refuzați invitațiile la spații"</string>
<string name="screen_space_announcement_item3">"Descoperiți toate camerele la care vă puteți alătura în spațiile dumneavoastră."</string>
<string name="screen_space_announcement_item4">"Alăturați-vă spațiilor publice"</string>
<string name="screen_space_announcement_item5">"Părăsiți spațiile la care v-ați alăturat."</string>
<string name="screen_space_announcement_notice">"Crearea și gestionarea spațiilor vor fi disponibile în curând."</string>
<string name="screen_space_announcement_subtitle">"Bun venit la versiunea beta a Spațiilor! Cu această primă versiune puteți:"</string>
<string name="screen_space_announcement_title">"Vă prezentăm Spații"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Просмотр пространств, которые вы создали или к которым присоединились"</string>
<string name="screen_space_announcement_item2">"Принимать или отклонять приглашения в пространства"</string>
<string name="screen_space_announcement_item3">"Откройте для себя все комнаты, к которым вы можете присоединиться в своих пространствах."</string>
<string name="screen_space_announcement_item4">"Присоединиться к публичному пространству"</string>
<string name="screen_space_announcement_item5">"Покинуть все пространства, к которым вы присоединились"</string>
<string name="screen_space_announcement_notice">"Создание и управление пространствами станет доступно в ближайшее время."</string>
<string name="screen_space_announcement_subtitle">"Добро пожаловать в бета-версию Spaces! В этой первой версии вы сможете:"</string>
<string name="screen_space_announcement_title">"Знакомство с пространствами"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"查看您创建或加入的空间"</string>
<string name="screen_space_announcement_item2">"接受或拒绝空间邀请"</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"View spaces you\'ve created or joined"</string>
<string name="screen_space_announcement_item2">"Accept or decline invites to spaces"</string>
<string name="screen_space_announcement_item3">"Discover any rooms you can join in your spaces"</string>
<string name="screen_space_announcement_item4">"Join public spaces"</string>
<string name="screen_space_announcement_item5">"Leave any spaces youve joined"</string>
<string name="screen_space_announcement_notice">"Filtering, creating and managing spaces is coming soon."</string>
<string name="screen_space_announcement_subtitle">"Welcome to the beta version of Spaces! With this first version you can:"</string>
<string name="screen_space_announcement_title">"Introducing Spaces"</string>
</resources>

View file

@ -0,0 +1,52 @@
/*
* 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.announcement.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AnnouncementPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createAnnouncementPresenter()
presenter.test {
val state = awaitItem()
assertThat(state.showSpaceAnnouncement).isFalse()
}
}
@Test
fun `present - showSpaceAnnouncement value depends on the value in the store`() = runTest {
val store = InMemoryAnnouncementStore()
val presenter = createAnnouncementPresenter(
announcementStore = store,
)
presenter.test {
val state = awaitItem()
assertThat(state.showSpaceAnnouncement).isFalse()
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
val updatedState = awaitItem()
assertThat(updatedState.showSpaceAnnouncement).isTrue()
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
val finalState = awaitItem()
assertThat(finalState.showSpaceAnnouncement).isFalse()
}
}
}
private fun createAnnouncementPresenter(
announcementStore: AnnouncementStore = InMemoryAnnouncementStore(),
) = AnnouncementPresenter(
announcementStore = announcementStore,
)

View file

@ -0,0 +1,84 @@
/*
* 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.announcement.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState
import io.element.android.features.announcement.impl.spaces.aSpaceAnnouncementState
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultAnnouncementServiceTest {
@Test
fun `when showing Space announcement, space announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.Space)
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.Space)
// Entering again the space tab should not change the value
sut.showAnnouncement(Announcement.Space)
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `when showing NewNotificationSound announcement, announcement is set to show even if it was already shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.NewNotificationSound)
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.NewNotificationSound)
// Calling again showAnnouncement should set it back to Show
sut.showAnnouncement(Announcement.NewNotificationSound)
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
}
@Test
fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty()
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.Space)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.Space, Announcement.NewNotificationSound)
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty()
}
}
private fun createDefaultAnnouncementService(
announcementStore: AnnouncementStore = InMemoryAnnouncementStore(),
announcementPresenter: Presenter<AnnouncementState> = Presenter { anAnnouncementState() },
spaceAnnouncementPresenter: Presenter<SpaceAnnouncementState> = Presenter { aSpaceAnnouncementState() },
) = DefaultAnnouncementService(
announcementStore = announcementStore,
announcementPresenter = announcementPresenter,
spaceAnnouncementPresenter = spaceAnnouncementPresenter,
)
}

View file

@ -0,0 +1,40 @@
/*
* 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.announcement.impl.spaces
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.store.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SpaceAnnouncementPresenterTest {
@Test
fun `present - when user continues, the store is updated`() = runTest {
val store = InMemoryAnnouncementStore()
val presenter = createSpaceAnnouncementPresenter(
announcementStore = store,
)
presenter.test {
assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
val state = awaitItem()
state.eventSink(SpaceAnnouncementEvents.Continue)
assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown)
}
}
}
private fun createSpaceAnnouncementPresenter(
announcementStore: AnnouncementStore = InMemoryAnnouncementStore(),
) = SpaceAnnouncementPresenter(
announcementStore = announcementStore,
)

View file

@ -0,0 +1,60 @@
/*
* 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.announcement.impl.spaces
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SpaceAnnouncementViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back sends a SpaceAnnouncementEvents`() {
val eventsRecorder = EventsRecorder<SpaceAnnouncementEvents>()
rule.setSpaceAnnouncementView(
aSpaceAnnouncementState(
eventSink = eventsRecorder,
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue)
}
@Test
fun `clicking on Continue sends a SpaceAnnouncementEvents`() {
val eventsRecorder = EventsRecorder<SpaceAnnouncementEvents>()
rule.setSpaceAnnouncementView(
aSpaceAnnouncementState(
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceAnnouncementView(
state: SpaceAnnouncementState,
) {
setContent {
SpaceAnnouncementView(
state = state,
)
}
}

View file

@ -0,0 +1,39 @@
/*
* 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.announcement.impl.store
import io.element.android.features.announcement.api.Announcement
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore(
initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore {
private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
}
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
return announcement.toMutableStateFlow().asStateFlow()
}
override suspend fun reset() {
spaceAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
}
private fun Announcement.toMutableStateFlow() = when (this) {
Announcement.Space -> spaceAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
}
}

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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.announcement.test"
}
dependencies {
implementation(projects.features.announcement.api)
implementation(libs.coroutines.core)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.test.logs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAnnouncementService(
initialAnnouncementsToShowFlowValue: List<Announcement> = emptyList(),
val showAnnouncementResult: (Announcement) -> Unit = { lambdaError() },
val onAnnouncementDismissedResult: (Announcement) -> Unit = { lambdaError() },
val renderResult: (Modifier) -> Unit = { lambdaError() },
) : AnnouncementService {
private val announcementsToShowFlowValue = MutableStateFlow(initialAnnouncementsToShowFlowValue)
override suspend fun showAnnouncement(announcement: Announcement) {
showAnnouncementResult(announcement)
}
override suspend fun onAnnouncementDismissed(announcement: Announcement) {
onAnnouncementDismissedResult(announcement)
}
override fun announcementsToShowFlow(): Flow<List<Announcement>> {
return announcementsToShowFlowValue.asStateFlow()
}
fun emitAnnouncementsToShow(value: List<Announcement>) {
announcementsToShowFlowValue.value = value
}
@Composable
override fun Render(modifier: Modifier) {
renderResult(modifier)
}
}

View file

@ -19,7 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
@ -49,7 +49,7 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @AssistedInject
class CallScreenPresenter( class CallScreenPresenter(
@Assisted private val callType: CallType, @Assisted private val callType: CallType,
@Assisted private val navigator: CallScreenNavigator, @Assisted private val navigator: CallScreenNavigator,
@ -242,7 +242,7 @@ class CallScreenPresenter(
} }
coroutineScope.launch { coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}") Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService().syncState client.syncService.syncState
.collect { state -> .collect { state ->
if (state != SyncState.Running) { if (state != SyncState.Running) {
appForegroundStateService.updateIsInCallState(true) appForegroundStateService.updateIsInCallState(true)

View file

@ -133,6 +133,7 @@ class WebViewWidgetMessageInterceptor(
return assetLoader.shouldInterceptRequest(request.url) return assetLoader.shouldInterceptRequest(request.url)
} }
@Suppress("OVERRIDE_DEPRECATION")
override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? { override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(url.toUri()) return assetLoader.shouldInterceptRequest(url.toUri())
} }

View file

@ -15,7 +15,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 dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@Inject @AssistedInject
class ChangeRolesNode( class ChangeRolesNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -20,7 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -37,17 +37,15 @@ import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @AssistedInject
class ChangeRolesPresenter( class ChangeRolesPresenter(
@Assisted private val role: RoomMember.Role, @Assisted private val role: RoomMember.Role,
private val room: JoinedRoom, private val room: JoinedRoom,
@ -73,11 +71,11 @@ class ChangeRolesPresenter(
} }
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState(initialValue = persistentListOf()) { val usersWithRole = produceState<ImmutableList<MatrixUser>>(initialValue = persistentListOf()) {
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } } room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
.onEach { users -> .onEach { users ->
val previous: PersistentList<MatrixUser> = value val previous = value
value = users.toPersistentList() value = users.toImmutableList()
// Users who were selected but didn't have the role, so their role change was pending // Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } } val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role // Users who no longer have the role

View file

@ -17,9 +17,9 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -32,11 +32,11 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class ChangeRoomMemberRolesRootNode( class ChangeRoomMemberRolesRootNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
roomComponentFactory: RoomComponentFactory, roomGraphFactory: RoomGraphFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>( ) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@ -54,7 +54,7 @@ class ChangeRoomMemberRolesRootNode(
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
override val graph = roomComponentFactory.create(inputs.joinedRoom) override val graph = roomGraphFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return createNode<ChangeRolesNode>( return createNode<ChangeRolesNode>(

View file

@ -17,6 +17,7 @@
<string name="screen_room_change_role_administrators_title">"Rediger administratorer"</string> <string name="screen_room_change_role_administrators_title">"Rediger administratorer"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Du vil ikke kunne angre denne handlingen. Du forfremmer brukeren til å ha samme rettighetsnivå som deg."</string> <string name="screen_room_change_role_confirm_add_admin_description">"Du vil ikke kunne angre denne handlingen. Du forfremmer brukeren til å ha samme rettighetsnivå som deg."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Legg til administrator?"</string> <string name="screen_room_change_role_confirm_add_admin_title">"Legg til administrator?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Du kan ikke angre denne handlingen. Du overfører eierskapet til de valgte brukerne. Når du forlater siden, vil dette være permanent."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Overføre eierskapet?"</string> <string name="screen_room_change_role_confirm_change_owners_title">"Overføre eierskapet?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Degradere"</string> <string name="screen_room_change_role_confirm_demote_self_action">"Degradere"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Du vil ikke kunne angre denne endringen ettersom du degraderer deg selv, og hvis du er den siste privilegerte brukeren i rommet, vil det være umulig å få tilbake privilegiene."</string> <string name="screen_room_change_role_confirm_demote_self_description">"Du vil ikke kunne angre denne endringen ettersom du degraderer deg selv, og hvis du er den siste privilegerte brukeren i rommet, vil det være umulig å få tilbake privilegiene."</string>

View file

@ -32,8 +32,8 @@ import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -103,7 +103,7 @@ class ChangeRolesPresenterTest {
// Owner - creator // Owner - creator
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true)) aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
) )
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toPersistentList())) givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList()))
} }
val presenter = createChangeRolesPresenter(room = room) val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -124,7 +124,7 @@ class ChangeRolesPresenterTest {
val creatorUserId = UserId("@creator:matrix.org") val creatorUserId = UserId("@creator:matrix.org")
val memberList = aRoomMemberList() val memberList = aRoomMemberList()
.plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId)) .plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId))
.toPersistentList() .toImmutableList()
givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId))) givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId)))
givenRoomMembersState(RoomMembersState.Ready(memberList)) givenRoomMembersState(RoomMembersState.Ready(memberList))
} }
@ -203,7 +203,7 @@ class ChangeRolesPresenterTest {
assertThat(initialResults?.moderators).hasSize(1) assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1) assertThat(initialResults?.admins).hasSize(1)
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList())) room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList().take(1).toImmutableList()))
skipItems(1) skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
@ -552,7 +552,7 @@ class ChangeRolesPresenterTest {
private fun roomPowerLevelsWithRoles(vararg pairs: Pair<UserId, RoomMember.Role>): RoomPowerLevels { private fun roomPowerLevelsWithRoles(vararg pairs: Pair<UserId, RoomMember.Role>): RoomPowerLevels {
return RoomPowerLevels( return RoomPowerLevels(
values = defaultRoomPowerLevelValues(), values = defaultRoomPowerLevelValues(),
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap() users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toImmutableMap()
) )
} }
} }

View file

@ -26,7 +26,7 @@ class DefaultChangeRoomMemberRolesEntyPointTest {
ChangeRoomMemberRolesRootNode( ChangeRoomMemberRolesRootNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
roomComponentFactory = { }, roomGraphFactory = { },
) )
} }
val room = FakeJoinedRoom() val room = FakeJoinedRoom()

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class CreateRoomFlowNode( class CreateRoomFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -14,7 +14,7 @@ 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.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.invitepeople.api.InvitePeoplePresenter import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleRenderer import io.element.android.features.invitepeople.api.InvitePeopleRenderer
@ -24,7 +24,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class AddPeopleNode( class AddPeopleNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -15,7 +15,7 @@ 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.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class ConfigureRoomNode( class ConfigureRoomNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -13,12 +13,12 @@ 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 dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class AccountDeactivationNode( class AccountDeactivationNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -8,7 +8,7 @@
<string name="screen_deactivate_account_list_item_1">"%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)."</string> <string name="screen_deactivate_account_list_item_1">"%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Véglegesen letiltja"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"Véglegesen letiltja"</string>
<string name="screen_deactivate_account_list_item_2">"Eltávolításra kerül az összes csevegőszobából."</string> <string name="screen_deactivate_account_list_item_2">"Eltávolításra kerül az összes csevegőszobából."</string>
<string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai a személyazonosító kiszolgálónkról."</string> <string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai az azonosítási kiszolgálónkról."</string>
<string name="screen_deactivate_account_list_item_4">"Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."</string> <string name="screen_deactivate_account_list_item_4">"Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."</string>
<string name="screen_deactivate_account_title">"Fiók deaktiválása"</string> <string name="screen_deactivate_account_title">"Fiók deaktiválása"</string>
</resources> </resources>

View file

@ -13,7 +13,7 @@ android {
} }
dependencies { dependencies {
implementation(libs.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }

View file

@ -18,7 +18,7 @@ android {
setupDependencyInjection() setupDependencyInjection()
dependencies { dependencies {
implementation(libs.compound) implementation(projects.libraries.compound)
api(projects.features.enterprise.api) api(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)

View file

@ -14,7 +14,7 @@ android {
dependencies { dependencies {
api(projects.features.enterprise.api) api(projects.features.enterprise.api)
implementation(libs.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }

View file

@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)

View file

@ -8,10 +8,7 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -20,9 +17,8 @@ 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.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
@ -34,15 +30,15 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
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.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class FtueFlowNode( class FtueFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -87,7 +83,7 @@ class FtueFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) emptyNode(buildContext)
} }
is NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
@ -146,17 +142,3 @@ class FtueFlowNode(
BackstackView() BackstackView()
} }
} }
@ContributesNode(AppScope::class)
@Inject
class PlaceholderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

View file

@ -14,13 +14,13 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@Inject @AssistedInject
class NotificationsOptInNode( class NotificationsOptInNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
@ -25,7 +25,7 @@ import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @AssistedInject
class NotificationsOptInPresenter( class NotificationsOptInPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback, @Assisted private val callback: NotificationsOptInNode.Callback,

View file

@ -21,7 +21,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop 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.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
@ -37,7 +37,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class FtueSessionVerificationFlowNode( class FtueSessionVerificationFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -14,14 +14,14 @@ 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.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class ChooseSelfVerificationModeNode( class ChooseSelfVerificationModeNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -26,7 +26,7 @@ class ChooseSelfVerificationModePresenter(
) : Presenter<ChooseSelfVerificationModeState> { ) : Presenter<ChooseSelfVerificationModeState> {
@Composable @Composable
override fun present(): ChooseSelfVerificationModeState { override fun present(): ChooseSelfVerificationModeState {
val isLastDevice by encryptionService.isLastDevice.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } } val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
@ -39,7 +39,7 @@ class ChooseSelfVerificationModePresenter(
} }
return ChooseSelfVerificationModeState( return ChooseSelfVerificationModeState(
isLastDevice = isLastDevice, canUseAnotherDevice = hasDevicesToVerifyAgainst,
canEnterRecoveryKey = canEnterRecoveryKey, canEnterRecoveryKey = canEnterRecoveryKey,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
eventSink = ::eventHandler, eventSink = ::eventHandler,

View file

@ -10,7 +10,7 @@ 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
data class ChooseSelfVerificationModeState( data class ChooseSelfVerificationModeState(
val isLastDevice: Boolean, val canUseAnotherDevice: Boolean,
val canEnterRecoveryKey: Boolean, val canEnterRecoveryKey: Boolean,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,

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