Merge branch 'release/25.11.2' into main

This commit is contained in:
Benoit Marty 2025-11-05 11:53:44 +01:00
commit 9507ae3551
1157 changed files with 9536 additions and 7182 deletions

View file

@ -26,7 +26,11 @@ ktlint_standard_annotation = disabled
ktlint_standard_parameter-list-wrapping = disabled
ktlint_standard_indent = disabled
ktlint_standard_blank-line-before-declaration = disabled
ktlint_function_naming_ignore_when_annotated_with=Composable
ktlint_function_naming_ignore_when_annotated_with = Composable
# Added when upgrading to 1.7.1
ktlint_standard_function-expression-body = disabled
ktlint_standard_chain-method-continuation = disabled
ktlint_standard_class-signature = disabled
[*.java]
ij_java_align_consecutive_assignments = false

View file

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

View file

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

View file

@ -20,7 +20,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@bdccecb77e0144055fbaea9224f10cf8b1229b68 # 13.0.4
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,72 @@
Changes in Element X v25.11.0
=============================
Hotfix release.
Includes https://github.com/element-hq/element-x-android/pull/5615, which fixes an issue that prevented Element Call notifications from being displayed sometimes.
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.10.1...v25.11.0
Changes in Element X v25.10.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.10.1 -->
## What's Changed
### ✨ Features
* Sync notifications using WorkManager by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5545
### 🙌 Improvements
* Sort feature flags by @bmarty in https://github.com/element-hq/element-x-android/pull/5557
### 🐛 Bugfixes
* Makes sure images are loaded when cancelling multiaccount flow by @ganfra in https://github.com/element-hq/element-x-android/pull/5502
* Fix 'test push loop back' notification check by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5541
* Display 'join anyway' button on room preview when the state can't be loaded by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/5514
* Fix media viewer not being dismissed with reduced motion enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5555
* Keep the cursor position in room list search when going back by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5570
* Make sure declining a call stops observing the ringing call state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5563
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5515
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5562
### 🧱 Build
* Do some cleanup on our immutable annotation usage by @bmarty in https://github.com/element-hq/element-x-android/pull/5503
* `interface TestParameterValuesProvider` is deprecated. by @bmarty in https://github.com/element-hq/element-x-android/pull/5568
### Dependency upgrades
* fix(deps): update metro to v0.6.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5480
* fix(deps): update dependency org.unifiedpush.android:connector to v3.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5443
* fix(deps): update wysiwyg to v2.40.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5400
* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5510
* fix(deps): update camera to v1.5.1 - autoclosed by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5509
* chore(deps): update plugin dependencycheck to v12.1.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5518
* chore(deps): update plugin licensee to v1.14.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5477
* chore(deps): update dependency python to 3.14 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5475
* fix(deps): update metro to v0.6.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5520
* fix(deps): update dependency org.unifiedpush.android:connector to v3.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5519
* chore(deps): update plugin gms_google_services to v4.4.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5507
* fix(deps): update dependency com.google.firebase:firebase-bom to v34.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5522
* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5524
* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5525
* fix(deps): update dependencyanalysis to v3.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5523
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5527
* chore(deps): update plugin dependencycheck to v12.1.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5531
* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5533
* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5548
* fix(deps): update metro to v0.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5554
* fix(deps): update dependency com.posthog:posthog-android to v3.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5564
* chore(deps): update plugin sonarqube to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5535
### Others
* Import Compound tokens - fixed icons by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5506
* Replace Uri by String in States that are used in Composable function. by @bmarty in https://github.com/element-hq/element-x-android/pull/5508
* Let room filters follow the design. by @bmarty in https://github.com/element-hq/element-x-android/pull/5526
* Allow uploading notification push rules in bug reports by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5538
* Add number of accounts info in the rageshake data. by @bmarty in https://github.com/element-hq/element-x-android/pull/5532
* design(space): match figma for Space views by @ganfra in https://github.com/element-hq/element-x-android/pull/5540
* Extract console message logger and mutualize instance of Json by @bmarty in https://github.com/element-hq/element-x-android/pull/5552
* Improve colors customization by @bmarty in https://github.com/element-hq/element-x-android/pull/5542
* Fix test warning by @bmarty in https://github.com/element-hq/element-x-android/pull/5558
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.10.0...v25.10.1
Changes in Element X v25.10.0
=============================

View file

@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -25,6 +28,7 @@ import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
@ -61,9 +65,13 @@ class MainActivity : NodeActivity() {
@Composable
private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present()
val colors by remember {
appBindings.enterpriseService().semanticColorsFlow(sessionId = null)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appBindings.preferencesStore(),
enterpriseService = appBindings.enterpriseService(),
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = appBindings.buildMeta()
) {
CompositionLocalProvider(

View file

@ -18,8 +18,6 @@ import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ApplicationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
@ -29,6 +27,8 @@ import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
import io.element.android.libraries.recentemojis.impl.DefaultEmojibaseProvider
import io.element.android.x.BuildConfig
import io.element.android.x.R
import kotlinx.coroutines.CoroutineName

View file

@ -8,13 +8,11 @@
package io.element.android.x.di
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesBinding(SessionScope::class)
@Inject
class DefaultRoomGraphFactory(
private val sessionGraph: SessionGraph,
) : RoomGraphFactory {

View file

@ -9,12 +9,10 @@ package io.element.android.x.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.matrix.api.MatrixClient
@ContributesBinding(AppScope::class)
@Inject
class DefaultSessionGraphFactory(
private val appGraph: AppGraph
) : SessionGraphFactory {

View file

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

View file

@ -12,9 +12,9 @@ import android.content.Intent
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.deeplink.api.DeepLinkCreator
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
@ -22,7 +22,6 @@ import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.x.MainActivity
@ContributesBinding(AppScope::class)
@Inject
class DefaultIntentProvider(
@ApplicationContext private val context: Context,
private val deepLinkCreator: DeepLinkCreator,
@ -31,10 +30,11 @@ class DefaultIntentProvider(
sessionId: SessionId,
roomId: RoomId?,
threadId: ThreadId?,
eventId: EventId?,
): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri()
data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri()
}
}
}

View file

@ -9,13 +9,11 @@ package io.element.android.x.oidc
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.x.R
@ContributesBinding(AppScope::class)
@Inject
class DefaultOidcRedirectUrlProvider(
private val stringProvider: StringProvider,
) : OidcRedirectUrlProvider {

View file

@ -13,9 +13,11 @@ import android.content.Context
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeepLinkCreator
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -31,14 +33,15 @@ import org.robolectric.RuntimeEnvironment
class DefaultIntentProviderTest {
@Test
fun `test getViewRoomIntent with data`() {
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, String> { _, _, _ -> "deepLinkCreatorResult" }
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, EventId?, String> { _, _, _, _ -> "deepLinkCreatorResult" }
val sut = createDefaultIntentProvider(
deepLinkCreator = { sessionId, roomId, threadId -> deepLinkCreator.invoke(sessionId, roomId, threadId) },
deepLinkCreator = { sessionId, roomId, threadId, eventId -> deepLinkCreator.invoke(sessionId, roomId, threadId, eventId) },
)
val result = sut.getViewRoomIntent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
result.commonAssertions()
assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult")
@ -46,11 +49,12 @@ class DefaultIntentProviderTest {
value(A_SESSION_ID),
value(A_ROOM_ID),
value(A_THREAD_ID),
value(AN_EVENT_ID),
)
}
private fun createDefaultIntentProvider(
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _ -> "" },
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _, _ -> "" },
): DefaultIntentProvider {
return DefaultIntentProvider(
context = RuntimeEnvironment.getApplication() as Context,

View file

@ -7,8 +7,8 @@
package io.element.android.appconfig
import android.graphics.Color
import androidx.annotation.ColorInt
import androidx.core.graphics.toColorInt
object NotificationConfig {
/**
@ -27,5 +27,5 @@ object NotificationConfig {
const val SHOW_QUICK_REPLY_ACTION = true
@ColorInt
val NOTIFICATION_ACCENT_COLOR: Int = Color.parseColor("#FF0DBD8B")
val NOTIFICATION_ACCENT_COLOR: Int = "#FF0DBD8B".toColorInt()
}

View file

@ -59,6 +59,7 @@ dependencies {
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.test)

View file

@ -21,13 +21,13 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
@ -56,10 +56,12 @@ class LoggedInAppScopeFlowNode(
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
fun navigateToBugReport()
fun navigateToAddAccount()
}
private val callback: Callback = callback()
@Parcelize
object NavTarget : Parcelable
@ -81,12 +83,12 @@ class LoggedInAppScopeFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : LoggedInFlowNode.Callback {
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.PermanentChild
@ -23,7 +24,6 @@ import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.BackStack.State.ACTIVE
import com.bumble.appyx.navmodel.backstack.BackStack.State.CREATED
@ -46,6 +46,8 @@ import io.element.android.appnav.loggedin.SendQueues
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
@ -53,19 +55,23 @@ import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
@ -77,8 +83,10 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
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
@ -122,6 +130,10 @@ class LoggedInFlowNode(
private val sessionEnterpriseService: SessionEnterpriseService,
private val networkMonitor: NetworkMonitor,
private val notificationConversationService: NotificationConversationService,
private val syncService: SyncService,
private val enterpriseService: EnterpriseService,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@ -136,10 +148,11 @@ class LoggedInFlowNode(
plugins = plugins
) {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
fun navigateToBugReport()
fun navigateToAddAccount()
}
private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
@ -244,7 +257,7 @@ class LoggedInFlowNode(
val serverNames: List<String> = emptyList(),
val trigger: JoinedRoom.Trigger? = null,
val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(),
val targetId: UUID = UUID.randomUUID(),
) : NavTarget
@ -270,7 +283,7 @@ class LoggedInFlowNode(
data object Ftue : NavTarget
@Parcelize
data object RoomDirectorySearch : NavTarget
data object RoomDirectory : NavTarget
@Parcelize
data class IncomingShare(val intent: Intent) : NavTarget
@ -292,50 +305,47 @@ class LoggedInFlowNode(
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
override fun onRoomClick(roomId: RoomId) {
override fun navigateToRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
override fun onSettingsClick() {
override fun navigateToSettings() {
backstack.push(NavTarget.Settings())
}
override fun onStartChatClick() {
override fun navigateToCreateRoom() {
backstack.push(NavTarget.CreateRoom)
}
override fun onSetUpRecoveryClick() {
override fun navigateToSetUpRecovery() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
override fun onSessionConfirmRecoveryKeyClick() {
override fun navigateToEnterRecoveryKey() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
override fun onRoomSettingsClick(roomId: RoomId) {
override fun navigateToRoomSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details))
}
override fun onReportBugClick() {
plugins<Callback>().forEach { it.onOpenBugReport() }
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
}
homeEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
homeEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.Room -> {
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
sessionCoroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) {
is PermalinkData.UserLink -> {
// Should not happen (handled by MessagesNode)
@ -346,7 +356,7 @@ class LoggedInFlowNode(
roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters,
trigger = JoinedRoom.Trigger.Timeline,
initialElement = RoomNavigationTarget.Messages(data.eventId),
initialElement = RoomNavigationTarget.Root(data.eventId),
)
if (pushToBackstack) {
backstack.push(target)
@ -361,15 +371,10 @@ class LoggedInFlowNode(
}
}
override fun onOpenGlobalNotificationSettings() {
override fun navigateToGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
}
}
val spaceCallback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames = viaParameters))
}
}
val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription),
@ -377,80 +382,89 @@ class LoggedInFlowNode(
trigger = Optional.ofNullable(navTarget.trigger),
initialElement = navTarget.initialElement
)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback))
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback))
}
is NavTarget.UserProfile -> {
val callback = object : UserProfileEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
override fun navigateToRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
userProfileEntryPoint.nodeBuilder(this, buildContext)
.params(UserProfileEntryPoint.Params(userId = navTarget.userId))
.callback(callback)
.build()
userProfileEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = UserProfileEntryPoint.Params(userId = navTarget.userId),
callback = callback,
)
}
is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun onSecureBackupClick() {
override fun navigateToSecureBackup() {
backstack.push(NavTarget.SecureBackup())
}
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}
override fun navigateTo(roomId: RoomId, eventId: EventId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
override fun navigateToEvent(roomId: RoomId, eventId: EventId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Root(eventId)))
}
}
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
preferencesEntryPoint.nodeBuilder(this, buildContext)
.params(inputs)
.callback(callback)
.build()
preferencesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = inputs,
callback = callback,
)
}
NavTarget.CreateRoom -> {
val callback = object : StartChatEntryPoint.Callback {
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
}
override fun onOpenRoomDirectory() {
backstack.push(NavTarget.RoomDirectorySearch)
override fun navigateToRoomDirectory() {
backstack.push(NavTarget.RoomDirectory)
}
}
startChatEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
startChatEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.callback(object : SecureBackupEntryPoint.Callback {
secureBackupEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement),
callback = object : SecureBackupEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
})
.build()
},
)
}
NavTarget.Ftue -> {
ftueEntryPoint.createNode(this, buildContext)
}
NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback {
override fun onResultClick(roomDescription: RoomDescription) {
NavTarget.RoomDirectory -> {
roomDirectoryEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = object : RoomDirectoryEntryPoint.Callback {
override fun navigateToRoom(roomDescription: RoomDescription) {
backstack.push(
NavTarget.Room(
roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(),
@ -459,32 +473,35 @@ class LoggedInFlowNode(
)
)
}
})
.build()
},
)
}
is NavTarget.IncomingShare -> {
shareEntryPoint.nodeBuilder(this, buildContext)
.callback(object : ShareEntryPoint.Callback {
shareEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = ShareEntryPoint.Params(intent = navTarget.intent),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias()))
roomIds.singleOrNull()?.let { roomId ->
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
})
.params(ShareEntryPoint.Params(intent = navTarget.intent))
.build()
},
)
}
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
.callback(object : IncomingVerificationEntryPoint.Callback {
incomingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = IncomingVerificationEntryPoint.Params(navTarget.data),
callback = object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
})
.build()
},
)
}
}
}
@ -495,7 +512,7 @@ class LoggedInFlowNode(
trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
) {
): RoomFlowNode {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
@ -504,12 +521,19 @@ class LoggedInFlowNode(
roomIdOrAlias = roomIdOrAlias,
serverNames = serverNames,
trigger = trigger,
initialElement = RoomNavigationTarget.Messages(
focusedEventId = eventId
)
initialElement = RoomNavigationTarget.Root(eventId = eventId)
)
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
}
// If we don't do this check, we might be returning while a previous node with the same type is still displayed
// This means we may attach some new nodes to that one, which will be quickly replaced by the one instantiated above
return waitForChildAttached<RoomFlowNode, NavTarget> {
it is NavTarget.Room &&
it.roomIdOrAlias == roomIdOrAlias &&
it.initialElement is RoomNavigationTarget.Root &&
it.initialElement.eventId == eventId
}
}
suspend fun attachUser(userId: UserId) {
@ -538,11 +562,27 @@ class LoggedInFlowNode(
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
val ftueState by ftueService.state.collectAsState()
BackstackView()
if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val isOnline by syncService.isOnline.collectAsState()
ConnectivityIndicatorContainer(
isOnline = isOnline,
modifier = modifier,
) { contentModifier ->
Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState()
BackstackView()
if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
}
}
}
}

View file

@ -18,7 +18,6 @@ import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
@ -29,6 +28,7 @@ import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
@ -55,9 +55,10 @@ class NotLoggedInFlowNode(
) : NodeInputs
interface Callback : Plugin {
fun onOpenBugReport()
fun navigateToBugReport()
}
private val callback: Callback = callback()
private val inputs = inputs<Params>()
override fun onBuilt() {
@ -78,20 +79,19 @@ class NotLoggedInFlowNode(
return when (navTarget) {
NavTarget.Root -> {
val callback = object : LoginEntryPoint.Callback {
override fun onReportProblem() {
plugins<Callback>().forEach { it.onOpenBugReport() }
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
}
loginEntryPoint
.nodeBuilder(this, buildContext)
.params(
LoginEntryPoint.Params(
accountProvider = inputs.loginParams?.accountProvider,
loginHint = inputs.loginParams?.loginHint,
)
)
.callback(callback)
.build()
loginEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = LoginEntryPoint.Params(
accountProvider = inputs.loginParams?.accountProvider,
loginHint = inputs.loginParams?.loginHint,
),
callback = callback,
)
}
}
}

View file

@ -31,6 +31,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
@ -38,7 +39,6 @@ import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
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.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.BackstackView
@ -50,7 +50,10 @@ import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
@ -80,7 +83,6 @@ class RootFlowNode(
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val bugReporter: BugReporter,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<RootFlowNode.NavTarget>(
@ -130,7 +132,6 @@ class RootFlowNode(
private fun switchToNotLoggedInFlow(params: LoginParams?) {
matrixSessionCache.removeAll()
bugReporter.setLogDirectorySubfolder(null)
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
@ -226,11 +227,11 @@ class RootFlowNode(
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun onOpenBugReport() {
override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport)
}
override fun onAddAccount() {
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
}
@ -238,7 +239,7 @@ class RootFlowNode(
}
is NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback {
override fun onOpenBugReport() {
override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport)
}
}
@ -248,11 +249,13 @@ class RootFlowNode(
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
}
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext).params(
SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId
)
).build()
signedOutEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId,
),
)
}
NavTarget.SplashScreen -> emptyNode(buildContext)
NavTarget.BugReport -> {
@ -261,11 +264,15 @@ class RootFlowNode(
backstack.pop()
}
}
bugReportEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
bugReportEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.AccountSelect -> {
val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback {
override fun onSelectAccount(sessionId: SessionId) {
override fun onAccountSelected(sessionId: SessionId) {
lifecycleScope.launch {
if (sessionId == navTarget.currentSessionId) {
// Ensure that the account selection Node is removed from the backstack
@ -286,7 +293,11 @@ class RootFlowNode(
backstack.pop()
}
}
accountSelectEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
accountSelectEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
}
}
@ -338,7 +349,7 @@ class RootFlowNode(
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.getAllSessions().size > 1) {
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
@ -368,7 +379,7 @@ class RootFlowNode(
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
else -> {
if (sessionStore.getAllSessions().size > 1) {
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
@ -391,13 +402,19 @@ class RootFlowNode(
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
// If there is a thread id, focus on it in the main timeline
val focusedEventId = if (permalinkData.threadId != null) {
permalinkData.threadId?.asEventId()
} else {
permalinkData.eventId
}
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
eventId = focusedEventId,
clearBackstack = true
)
).maybeAttachThread(permalinkData.threadId, permalinkData.eventId)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
@ -405,12 +422,24 @@ class RootFlowNode(
}
}
private suspend fun RoomFlowNode.maybeAttachThread(threadId: ThreadId?, focusedEventId: EventId?) {
if (threadId != null) {
attachThread(threadId, focusedEventId)
}
}
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId).apply {
attachSession(deeplinkData.sessionId).let { loggedInFlowNode ->
when (deeplinkData) {
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 -> {
loggedInFlowNode.attachRoom(
roomIdOrAlias = deeplinkData.roomId.toRoomIdOrAlias(),
eventId = if (deeplinkData.threadId != null) deeplinkData.threadId?.asEventId() else deeplinkData.eventId,
clearBackstack = true,
).maybeAttachThread(deeplinkData.threadId, deeplinkData.eventId)
}
}
}
}

View file

@ -12,7 +12,6 @@ import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
@ -33,7 +32,6 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
class MatrixSessionCache(
private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.di
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
interface TimelineBindings {
val timelineProvider: TimelineProvider
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
}

View file

@ -12,10 +12,10 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@ -32,18 +32,14 @@ class LoggedInNode(
fun navigateToNotificationTroubleshoot()
}
private fun navigateToNotificationTroubleshoot() {
plugins<Callback>().forEach {
it.navigateToNotificationTroubleshoot()
}
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
navigateToNotificationTroubleshoot = ::navigateToNotificationTroubleshoot,
navigateToNotificationTroubleshoot = callback::navigateToNotificationTroubleshoot,
modifier = modifier
)
}

View file

@ -134,7 +134,7 @@ class LoggedInPresenter(
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) {
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
val currentPushProvider = pushService.getCurrentPushProvider()
val currentPushProvider = pushService.getCurrentPushProvider(matrixClient.sessionId)
val result = if (currentPushProvider == null) {
Timber.tag(pusherTag.value).d("Register with the first available push provider with at least one distributor")
val pushProvider = pushService.getAvailablePushProviders()

View file

@ -9,8 +9,6 @@ package io.element.android.appnav.room
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
@ -38,24 +36,24 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
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.ThreadId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -71,7 +69,6 @@ class RoomFlowNode(
private val client: MatrixClient,
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val syncService: SyncService,
private val membershipObserver: RoomMembershipObserver,
private val spaceEntryPoint: SpaceEntryPoint,
) : BaseFlowNode<RoomFlowNode.NavTarget>(
@ -133,7 +130,6 @@ class RoomFlowNode(
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val roomInfoFlow = client.getRoomInfoFlow(roomId)
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
// This observes the local membership changes for the room
val membershipUpdateFlow = membershipObserver.updates
@ -146,14 +142,10 @@ class RoomFlowNode(
.map { it.getOrNull()?.currentUserMembership }
.distinctUntilChanged()
.withPreviousValue()
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
currentMembershipFlow.onEach { (previousMembership, membership) ->
Timber.d("Room membership: $membership")
if (membership == CurrentUserMembership.JOINED) {
if (isSpace) {
backstack.newRoot(NavTarget.JoinedSpace(spaceId = roomId))
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
backstack.newRoot(NavTarget.JoinedRoom(roomId))
} else {
val leavingFromCurrentDevice =
membership == CurrentUserMembership.LEFT &&
@ -188,10 +180,12 @@ class RoomFlowNode(
}
}
val params = Params(navTarget.roomAlias)
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.params(params)
.build()
roomAliasResolverEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
is NavTarget.JoinRoom -> {
val inputs = JoinRoomEntryPoint.Inputs(
@ -201,7 +195,11 @@ class RoomFlowNode(
serverNames = navTarget.serverNames,
trigger = navTarget.trigger,
)
joinRoomEntryPoint.createNode(this, buildContext, inputs)
joinRoomEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = inputs,
)
}
is NavTarget.JoinedRoom -> {
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
@ -213,19 +211,24 @@ class RoomFlowNode(
}
is NavTarget.JoinedSpace -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.nodeBuilder(this, buildContext)
.inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId))
.callback(spaceCallback)
.build()
spaceEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SpaceEntryPoint.Inputs(roomId = navTarget.spaceId),
callback = spaceCallback,
)
}
}
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomFlowNode>()
.attachThread(threadId, focusedEventId)
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline,
onBackClick = { navigateUp() },
modifier = modifier,
)

View file

@ -13,7 +13,9 @@ import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : RoomNavigationTarget
data class Root(
val eventId: EventId? = null,
) : RoomNavigationTarget
@Parcelize
data object Details : RoomNavigationTarget

View file

@ -34,8 +34,9 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged
@ -50,7 +51,6 @@ class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
private val syncService: SyncService,
) :
BaseFlowNode<JoinedRoomFlowNode.NavTarget>(
backstack = BackStack(
@ -116,15 +116,18 @@ class JoinedRoomFlowNode(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,
modifier = modifier,
onBackClick = onBackClick
onBackClick = onBackClick,
modifier = modifier
)
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomLoadedFlowNode>()
.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(

View file

@ -16,24 +16,31 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.di.TimelineBindings
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -51,6 +58,8 @@ class JoinedRoomLoadedFlowNode(
@Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val spaceEntryPoint: SpaceEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
@ -59,21 +68,16 @@ class JoinedRoomLoadedFlowNode(
roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) {
is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
},
initialElement = initialElement(plugins),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, serverNames: List<String>)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings()
}
data class Inputs(
@ -82,7 +86,7 @@ class JoinedRoomLoadedFlowNode(
) : NodeInputs
private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room)
init {
@ -118,26 +122,28 @@ class JoinedRoomLoadedFlowNode(
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback {
override fun onOpenGlobalNotificationSettings() {
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
override fun navigateToGlobalNotificationSettings() {
callback.navigateToGlobalNotificationSettings()
}
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
callbacks.forEach { it.onOpenRoom(roomId, serverNames) }
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
callback.navigateToRoom(roomId, serverNames)
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callback.handlePermalinkClick(data, pushToBackstack)
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
}
}
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
.params(RoomDetailsEntryPoint.Params(initialTarget))
.callback(callback)
.build()
return roomDetailsEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = RoomDetailsEntryPoint.Params(initialTarget),
callback = callback,
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -154,55 +160,144 @@ class JoinedRoomLoadedFlowNode(
NavTarget.RoomNotificationSettings -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
}
NavTarget.RoomMemberList -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberList)
}
NavTarget.Space -> {
createSpaceNode(buildContext)
}
is NavTarget.ForwardEvent -> {
val timelineProvider = if (navTarget.fromPinnedEvents) {
(graph as TimelineBindings).pinnedEventsTimelineProvider
} else {
(graph as TimelineBindings).timelineProvider
}
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callback.navigateToRoom(roomId, emptyList())
}
}
}
forwardEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
}
}
private fun createSpaceNode(buildContext: BuildContext): Node {
val callback = object : SpaceEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) {
callback.navigateToRoom(roomId, viaParameters)
}
override fun navigateToRoomDetails() {
backstack.push(NavTarget.RoomDetails)
}
override fun navigateToRoomMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
}
return spaceEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SpaceEntryPoint.Inputs(roomId = inputs.room.roomId),
callback = callback,
)
}
private fun createMessagesNode(
buildContext: BuildContext,
navTarget: NavTarget.Messages,
): Node {
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClick() {
override fun navigateToRoomDetails() {
backstack.push(NavTarget.RoomDetails)
}
override fun onUserDataClick(userId: UserId) {
override fun navigateToRoomMemberDetails(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callback.handlePermalinkClick(data, pushToBackstack)
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
}
override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList())
}
}
val params = MessagesEntryPoint.Params(
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId)
)
return messagesEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
return messagesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : NavTarget
data object Space : NavTarget
@Parcelize
data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
@Parcelize
data object RoomDetails : NavTarget
@Parcelize
data object RoomMemberList : NavTarget
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
@Parcelize
data object RoomNotificationSettings : NavTarget
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
val messageNode = waitForChildAttached<Node, NavTarget> { navTarget ->
navTarget is NavTarget.Messages
}
(messageNode as? MessagesEntryPoint.NodeProxy)?.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
private fun initialElement(plugins: List<Plugin>): JoinedRoomLoadedFlowNode.NavTarget {
val input = plugins.filterIsInstance<JoinedRoomLoadedFlowNode.Inputs>().single()
return when (input.initialElement) {
is RoomNavigationTarget.Root -> {
if (input.room.roomInfoFlow.value.isSpace) {
JoinedRoomLoadedFlowNode.NavTarget.Space
} else {
JoinedRoomLoadedFlowNode.NavTarget.Messages(input.initialElement.eventId)
}
}
RoomNavigationTarget.Details -> JoinedRoomLoadedFlowNode.NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> JoinedRoomLoadedFlowNode.NavTarget.RoomNotificationSettings
}
}

View file

@ -8,8 +8,6 @@
package io.element.android.appnav.room.joined
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -21,9 +19,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -31,6 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
import io.element.android.libraries.ui.strings.CommonStrings
@ -38,17 +34,13 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoadingRoomNodeView(
state: LoadingRoomState,
hasNetworkConnection: Boolean,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
Column {
ConnectivityIndicatorView(isOnline = hasNetworkConnection)
LoadingRoomTopBar(onBackClick)
}
LoadingRoomTopBar(onBackClick)
},
content = { padding ->
Box(
@ -66,7 +58,9 @@ fun LoadingRoomNodeView(
style = ElementTheme.typography.fontBodyMdRegular,
)
} else {
CircularProgressIndicator()
DelayedVisibility {
CircularProgressIndicator()
}
}
}
},
@ -83,9 +77,7 @@ private fun LoadingRoomTopBar(
BackButton(onClick = onBackClick)
},
title = {
IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp)
},
windowInsets = WindowInsets(0.dp),
)
}
@ -94,7 +86,6 @@ private fun LoadingRoomTopBar(
internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview {
LoadingRoomNodeView(
state = state,
onBackClick = {},
hasNetworkConnection = false
onBackClick = {}
)
}

View file

@ -19,22 +19,30 @@ import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom
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.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class JoinedRoomLoadedFlowNodeTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@ -42,29 +50,20 @@ class JoinedRoomLoadedFlowNodeTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
var buildContext: BuildContext? = null
private class FakeMessagesEntryPoint : MessagesEntryPoint {
var nodeId: String? = null
var parameters: MessagesEntryPoint.Params? = null
var callback: MessagesEntryPoint.Callback? = null
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
this.buildContext = buildContext
return this
}
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: MessagesEntryPoint.Params,
callback: MessagesEntryPoint.Callback,
): Node {
parameters = params
return this
}
override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
this.callback = callback
return this
}
override fun build(): Node {
return node(buildContext!!) {}.also {
return node(buildContext) {}.also {
nodeId = it.id
}
}
@ -79,22 +78,26 @@ class JoinedRoomLoadedFlowNodeTest {
private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint {
var nodeId: String? = null
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
return object : RoomDetailsEntryPoint.NodeBuilder {
override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
return this
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: RoomDetailsEntryPoint.Params,
callback: RoomDetailsEntryPoint.Callback,
) = node(buildContext) {}.also {
nodeId = it.id
}
}
override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
return this
}
private class FakeSpaceEntryPoint : SpaceEntryPoint {
var nodeId: String? = null
override fun build(): Node {
return node(buildContext) {}.also {
nodeId = it.id
}
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: SpaceEntryPoint.Inputs,
callback: SpaceEntryPoint.Callback,
) = node(buildContext) {}.also {
nodeId = it.id
}
}
@ -102,27 +105,32 @@ class JoinedRoomLoadedFlowNodeTest {
plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
matrixClient: FakeMatrixClient = FakeMatrixClient(),
) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins,
messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint,
spaceEntryPoint = spaceEntryPoint,
forwardEntryPoint = forwardEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = this,
sessionCoroutineScope = backgroundScope,
roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = FakeMatrixClient(),
matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder,
)
@Test
fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
fun `given a room flow node when initialized then it loads messages entry point if room is not space`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = false)))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
)
// WHEN
@ -135,21 +143,41 @@ class JoinedRoomLoadedFlowNodeTest {
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
}
@Test
fun `given a room flow node when initialized then it loads space entry point if room is space`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = true)))
val spaceEntryPoint = FakeSpaceEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
spaceEntryPoint = spaceEntryPoint,
)
// WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Space)
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Space, Lifecycle.State.CREATED)
val spaceNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Space)!!
assertThat(spaceNode.id).isEqualTo(spaceEntryPoint.nodeId)
}
@Test
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN
fakeMessagesEntryPoint.callback?.onRoomDetailsClick()
fakeMessagesEntryPoint.callback?.navigateToRoomDetails()
// THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
@ -162,10 +190,10 @@ class JoinedRoomLoadedFlowNodeTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = ActiveRoomsHolder()
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,
@ -185,12 +213,12 @@ class JoinedRoomLoadedFlowNodeTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = ActiveRoomsHolder().apply {
addRoom(room)
}
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,

View file

@ -17,6 +17,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -67,6 +68,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -79,6 +81,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
)
@ -91,6 +94,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -103,6 +107,59 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
)
}
@Test
fun `test resolve navigation intent event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
)
}
@Test
fun `test resolve navigation intent thread and event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
)

View file

@ -487,7 +487,7 @@ class LoggedInPresenterTest {
Result.success(Unit)
},
selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() },
currentPushProvider: () -> PushProvider? = { null },
currentPushProvider: (SessionId) -> PushProvider? = { null },
setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
): PushService {
return FakePushService(

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.appnav.room.joined
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.lambda.lambdaError
class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
}

View file

@ -1,3 +1,5 @@
import org.gradle.accessors.dm.LibrariesForLibs
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -27,6 +29,8 @@ tasks.register<Delete>("clean").configure {
delete(rootProject.layout.buildDirectory)
}
private val ktLintVersion = the<LibrariesForLibs>().versions.ktlint.get()
allprojects {
// Detekt
apply {
@ -56,14 +60,12 @@ allprojects {
// See https://github.com/JLLeitschuh/ktlint-gradle#configuration
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
// See https://github.com/pinterest/ktlint/releases/
// TODO Regularly check for new version here ^
version.set("1.1.1")
android.set(true)
ignoreFailures.set(false)
enableExperimentalRules.set(true)
version = ktLintVersion
android = true
ignoreFailures = false
enableExperimentalRules = true
// display the corresponding rule
verbose.set(true)
verbose = true
reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
// To have XML report for Danger

@ -1 +1 @@
Subproject commit c5465c95792004409e0eaa7342171e1cd652914a
Subproject commit 19d78b589dfbca08b1e8306bff1a236fa2cdf528

View file

@ -0,0 +1,2 @@
Main changes in this version: fixes an issue that prevented Element Call notifications from being displayed sometimes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

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

View file

@ -11,12 +11,10 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultAnalyticsEntryPoint : AnalyticsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<AnalyticsOptInNode>(buildContext)

View file

@ -16,7 +16,6 @@ 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
@ -29,7 +28,6 @@ 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>,

View file

@ -11,7 +11,6 @@ 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
@ -21,7 +20,6 @@ private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
@ContributesBinding(AppScope::class)
@Inject
class DefaultAnnouncementStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AnnouncementStore {

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">"Zobrazit prostory, které jste vytvořili nebo ke kterým jste se připojili"</string>
<string name="screen_space_announcement_item2">"Přijmout nebo odmítnout pozvánky do prostorů"</string>
<string name="screen_space_announcement_item3">"Objevte všechny místnosti, do kterých můžete vstoupit ve svých prostorech"</string>
<string name="screen_space_announcement_item4">"Připojit se k veřejným prostorům"</string>
<string name="screen_space_announcement_item5">"Opustit všechny prostory, ke kterým jste se připojili"</string>
<string name="screen_space_announcement_notice">"Filtrování, vytváření a správa prostorů bude brzy k dispozici."</string>
<string name="screen_space_announcement_subtitle">"Vítejte v beta verzi prostorů! S touto první verzí můžete:"</string>
<string name="screen_space_announcement_title">"Představujeme prostory"</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">"Zobraziť priestory, ktoré ste vytvorili alebo ku ktorým ste sa pripojili"</string>
<string name="screen_space_announcement_item2">"Prijímať alebo odmietať pozvánky do priestorov"</string>
<string name="screen_space_announcement_item3">"Objaviť všetky miestnosti, do ktorých sa môžete pripojiť vo svojich priestoroch"</string>
<string name="screen_space_announcement_item4">"Pripojiť sa k verejnému priestoru"</string>
<string name="screen_space_announcement_item5">"Opustiť akékoľvek priestory, ku ktorým ste sa pridali"</string>
<string name="screen_space_announcement_notice">"Filtrovanie, vytváranie a správa priestorov bude čoskoro k dispozícii."</string>
<string name="screen_space_announcement_subtitle">"Vitajte v beta verzii priestorov! S touto prvou verziou môžete:"</string>
<string name="screen_space_announcement_title">"Predstavujeme priestory"</string>
</resources>

View file

@ -9,7 +9,6 @@ package io.element.android.features.cachecleaner.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.cachecleaner.api.CacheCleaner
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
@ -24,7 +23,6 @@ import java.io.File
* Default implementation of [CacheCleaner].
*/
@ContributesBinding(AppScope::class)
@Inject
class DefaultCacheCleaner(
@AppCoroutineScope
private val coroutineScope: CoroutineScope,

View file

@ -10,7 +10,6 @@ package io.element.android.features.call.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
@ -21,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(AppScope::class)
@Inject
class DefaultElementCallEntryPoint(
@ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager,

View file

@ -13,7 +13,6 @@ import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.annotations.ApplicationContext
@ -23,7 +22,6 @@ interface PipSupportProvider {
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultPipSupportProvider(
@ApplicationContext private val context: Context,
) : PipSupportProvider {

View file

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

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.call.impl.ui
import io.element.android.features.call.api.CallType
import io.element.android.libraries.matrix.api.core.SessionId
fun CallType.getSessionId(): SessionId? {
return when (this) {
is CallType.ExternalUrl -> null
is CallType.RoomCall -> sessionId
}
}

View file

@ -23,14 +23,17 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
@ -105,9 +108,13 @@ class ElementCallActivity :
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
val colors by remember(webViewTarget.value?.getSessionId()) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId())
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val state = presenter.present()

View file

@ -11,9 +11,13 @@ import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
@ -78,9 +82,13 @@ class IncomingCallActivity : AppCompatActivity() {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = notificationData.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
IncomingCallScreen(

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
interface LanguageTagProvider {
@Composable
@ -19,7 +18,6 @@ interface LanguageTagProvider {
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultLanguageTagProvider : LanguageTagProvider {
@Composable
override fun provideLanguageTag(): String? {

View file

@ -17,7 +17,6 @@ import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
@ -87,7 +86,6 @@ interface ActiveCallManager {
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
class DefaultActiveCallManager(
@ApplicationContext context: Context,
@AppCoroutineScope

View file

@ -9,12 +9,10 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.call.impl.BuildConfig
import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
@ContributesBinding(AppScope::class)
@Inject
class DefaultCallAnalyticCredentialsProvider : CallAnalyticCredentialsProvider {
override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() }
override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() }

View file

@ -9,7 +9,6 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
@ -23,7 +22,6 @@ import kotlinx.coroutines.flow.firstOrNull
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
@ContributesBinding(AppScope::class)
@Inject
class DefaultCallWidgetProvider(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,

View file

@ -9,7 +9,6 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
class DefaultCurrentCallService : CurrentCallService {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)

View file

@ -0,0 +1,43 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.getSessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import org.junit.Test
class CallTypeTest {
@Test
fun `getSessionId returns null for ExternalUrl`() {
assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull()
}
@Test
fun `getSessionId returns the sessionId for RoomCall`() {
assertThat(
CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
).getSessionId()
).isEqualTo(A_SESSION_ID)
}
@Test
fun `ExternalUrl stringification does not contain the URL`() {
assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl")
}
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID).toString())
.isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID)")
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.changeroommemberroes.api
package io.element.android.features.changeroommemberroles.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -15,13 +15,12 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun builder(parentNode: Node, buildContext: BuildContext): Builder
interface Builder {
fun room(room: JoinedRoom): Builder
fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): Builder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
room: JoinedRoom,
listType: ChangeRoomMemberRolesListType,
): Node
interface NodeProxy {
val roomId: RoomId

View file

@ -15,7 +15,5 @@ sealed interface ChangeRolesEvent {
data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent
data object ClearError : ChangeRolesEvent
data object CancelSave : ChangeRolesEvent
data object CloseDialog : ChangeRolesEvent
}

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.inputs

View file

@ -69,20 +69,19 @@ class ChangeRolesPresenter(
val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
}
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Boolean>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState<ImmutableList<MatrixUser>>(initialValue = persistentListOf()) {
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous = value
value = users.toImmutableList()
// 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 } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
.onEach { users ->
val previous = value
value = users.toImmutableList()
// 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 } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
}
val roomMemberState by room.membersStateFlow.collectAsState()
@ -147,22 +146,16 @@ class ChangeRolesPresenter(
}
}
}
is ChangeRolesEvent.ClearError -> {
saveState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.Exit -> {
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) {
saveState.value = if (saveState.value.isUninitialized() && hasPendingChanges) {
// Has pending changes, confirm exit
AsyncAction.ConfirmingNoParams
AsyncAction.ConfirmingCancellation
} else {
// No pending changes, exit immediately
AsyncAction.Success(Unit)
AsyncAction.Success(false)
}
}
is ChangeRolesEvent.CancelExit -> {
exitState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.CancelSave -> {
is ChangeRolesEvent.CloseDialog -> {
saveState.value = AsyncAction.Uninitialized
}
}
@ -174,7 +167,6 @@ class ChangeRolesPresenter(
searchResults = searchResults,
selectedUsers = selectedUsers.value,
hasPendingChanges = hasPendingChanges,
exitState = exitState.value,
savingState = saveState.value,
canChangeMemberRole = ::canChangeMemberRole,
eventSink = ::handleEvent,
@ -198,7 +190,7 @@ class ChangeRolesPresenter(
private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Unit>>,
saveState: MutableState<AsyncAction<Boolean>>,
) = launch {
saveState.value = AsyncAction.Loading
@ -221,7 +213,7 @@ class ChangeRolesPresenter(
saveState.value = AsyncAction.Failure(it)
}
.onSuccess {
saveState.value = AsyncAction.Success(Unit)
saveState.value = AsyncAction.Success(true)
// Asynchronously reload the room members
launch { room.updateMembers() }
}

View file

@ -23,8 +23,7 @@ data class ChangeRolesState(
val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>,
val savingState: AsyncAction<Unit>,
val savingState: AsyncAction<Boolean>,
val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit,
)
@ -36,10 +35,10 @@ data class MembersByRole(
val members: ImmutableList<RoomMember>,
) {
constructor(members: List<RoomMember>) : this(
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
members = members.filter { it.role == RoomMember.Role.User }.sorted(),
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
members = members.filter { it.role == RoomMember.Role.User }.sorted(),
)
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()

View file

@ -38,10 +38,10 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.ConfirmingNoParams),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(),
aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)),
@ -55,8 +55,7 @@ internal fun aChangeRolesState(
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Boolean> = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true },
eventSink: (ChangeRolesEvent) -> Unit = {},
) = ChangeRolesState(
@ -66,7 +65,6 @@ internal fun aChangeRolesState(
searchResults = searchResults,
selectedUsers = selectedUsers,
hasPendingChanges = hasPendingChanges,
exitState = exitState,
savingState = savingState,
canChangeMemberRole = canRemoveMember,
eventSink = eventSink,

View file

@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
@ -41,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
@ -52,7 +50,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
@ -172,61 +169,59 @@ fun ChangeRolesView(
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
AsyncActionView(
async = state.exitState,
onSuccess = { latestNavigateUp() },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
)
},
onErrorDismiss = { /* Cannot happen */ },
)
when (state.savingState) {
is AsyncAction.Confirming -> {
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
else -> Unit // No confirmation needed for Moderator or User roles
}
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
is AsyncAction.Success -> {
LaunchedEffect(state.savingState) {
async = state.savingState,
onSuccess = { changeSaved ->
if (changeSaved) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
}
} else {
latestNavigateUp()
}
}
else -> Unit
}
},
confirmationDialog = { confirming ->
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
)
}
else -> {
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
)
}
// No confirmation needed for Moderator or User roles
else -> Unit
}
}
}
},
errorMessage = {
stringResource(CommonStrings.error_unknown)
},
onErrorDismiss = {
state.eventSink(ChangeRolesEvent.CloseDialog)
},
)
}
}

View file

@ -20,8 +20,8 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs

View file

@ -10,39 +10,25 @@ package io.element.android.features.changeroommemberroles.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesBinding(SessionScope::class)
@Inject
class DefaultChangeRoomMemberRolesEntyPoint : ChangeRoomMemberRolesEntryPoint {
override fun builder(parentNode: Node, buildContext: BuildContext): ChangeRoomMemberRolesEntryPoint.Builder {
return object : ChangeRoomMemberRolesEntryPoint.Builder {
private lateinit var changeRoomMemberRolesListType: ChangeRoomMemberRolesListType
private lateinit var room: JoinedRoom
override fun room(room: JoinedRoom): ChangeRoomMemberRolesEntryPoint.Builder {
this.room = room
return this
}
override fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): ChangeRoomMemberRolesEntryPoint.Builder {
this.changeRoomMemberRolesListType = changeRoomMemberRolesListType
return this
}
override fun build(): Node {
return parentNode.createNode<ChangeRoomMemberRolesRootNode>(
buildContext = buildContext,
plugins = listOf(
ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = changeRoomMemberRolesListType),
)
)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
room: JoinedRoom,
listType: ChangeRoomMemberRolesListType,
): Node {
return parentNode.createNode<ChangeRoomMemberRolesRootNode>(
buildContext = buildContext,
plugins = listOf(
ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = listType),
)
)
}
}

View file

@ -33,7 +33,7 @@
<string name="screen_room_change_role_section_users">"Mitglieder"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Du hast nicht gespeicherte Änderungen."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Änderungen speichern?"</string>
<string name="screen_room_member_list_banned_empty">"In diesem Chat gibt es keine gesperrten Nutzer."</string>
<string name="screen_room_member_list_banned_empty">"Es gibt keine gesperrten Nutzer."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d Personen"</item>

View file

@ -33,7 +33,7 @@
<string name="screen_room_change_role_section_users">"Liikmed"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Sul on salvestamata muudatusi"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Kas salvestame muudatused?"</string>
<string name="screen_room_member_list_banned_empty">"Jututoas pole suhtluskeeluga kasutajaid"</string>
<string name="screen_room_member_list_banned_empty">"Suhtluskeeluga kasutajaid pole"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d osaleja"</item>
<item quantity="other">"%1$d osalejat"</item>

View file

@ -33,7 +33,7 @@
<string name="screen_room_change_role_section_users">"Členovia"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Máte neuložené zmeny."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Uložiť zmeny?"</string>
<string name="screen_room_member_list_banned_empty">"V tejto miestnosti nie sú žiadni zakázaní používatelia."</string>
<string name="screen_room_member_list_banned_empty">"Neexistujú žiadni zablokovaní používatelia."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d osoba"</item>
<item quantity="few">"%1$d osoby"</item>

View file

@ -33,7 +33,7 @@
<string name="screen_room_change_role_section_users">"成員"</string>
<string name="screen_room_change_role_unsaved_changes_description">"您有尚未儲存的變更"</string>
<string name="screen_room_change_role_unsaved_changes_title">"是否儲存變更?"</string>
<string name="screen_room_member_list_banned_empty">"此聊天室沒有黑名單。"</string>
<string name="screen_room_member_list_banned_empty">"沒有被封鎖的使用者。"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="other">"%1$d 位夥伴"</item>
</plurals>

View file

@ -33,12 +33,12 @@
<string name="screen_room_change_role_section_users">"Members"</string>
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users in this room."</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban from room"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban user"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Unban"</string>
<string name="screen_room_member_list_manage_member_unban_message">"They will be able to join this room again if invited."</string>

View file

@ -8,7 +8,7 @@
package io.element.android.features.changeroommemberroles.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.RoomMember
import org.junit.Test

View file

@ -52,7 +52,6 @@ class ChangeRolesPresenterTest {
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(selectedUsers).isEmpty()
assertThat(hasPendingChanges).isFalse()
assertThat(exitState).isEqualTo(AsyncAction.Uninitialized)
assertThat(savingState).isEqualTo(AsyncAction.Uninitialized)
}
cancelAndIgnoreRemainingEvents()
@ -266,7 +265,7 @@ class ChangeRolesPresenterTest {
}
@Test
fun `present - Exit will display success if no pending changes`() = runTest {
fun `present - Exit will display success false if no pending changes`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -278,15 +277,15 @@ class ChangeRolesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false))
}
}
@Test
fun `present - CancelExit will remove exit confirmation`() = runTest {
fun `present - CloseDialog will remove exit confirmation`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -298,16 +297,16 @@ class ChangeRolesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Exit)
val confirmingState = awaitItem()
assertThat(confirmingState.exitState).isEqualTo(AsyncAction.ConfirmingNoParams)
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingCancellation)
confirmingState.eventSink(ChangeRolesEvent.CancelExit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Uninitialized)
confirmingState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
}
}
@ -324,7 +323,7 @@ class ChangeRolesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
val updatedState = awaitItem()
@ -332,10 +331,10 @@ class ChangeRolesPresenterTest {
skipItems(1)
updatedState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.ConfirmingNoParams)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.ConfirmingCancellation)
updatedState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false))
}
}
@ -367,12 +366,12 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
}
}
@Test
fun `present - CancelSave will remove the confirmation dialog`() = runTest {
fun `present - CloseDialog will remove the confirmation dialog`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -391,7 +390,7 @@ class ChangeRolesPresenterTest {
val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmingState.eventSink(ChangeRolesEvent.CancelSave)
confirmingState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
}
}
@ -426,7 +425,7 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator))
}
}
@ -504,13 +503,13 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
}
}
@Test
fun `present - Save can handle failures and ClearError clears them`() = runTest {
fun `present - Save can handle failures and CloseDialog clears them`() = runTest {
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
).apply {
@ -534,7 +533,7 @@ class ChangeRolesPresenterTest {
val failedState = awaitItem()
assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java)
failedState.eventSink(ChangeRolesEvent.ClearError)
failedState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
}
}

View file

@ -135,7 +135,7 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent(
state = aChangeRolesState(
isSearchActive = true,
exitState = AsyncAction.ConfirmingNoParams,
savingState = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
@ -151,14 +151,14 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent(
state = aChangeRolesState(
isSearchActive = true,
exitState = AsyncAction.ConfirmingNoParams,
savingState = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
}
@Test
@ -209,7 +209,7 @@ class ChangeRolesViewTest {
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
}
@Test
@ -225,7 +225,7 @@ class ChangeRolesViewTest {
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
}
@Test

View file

@ -10,7 +10,7 @@ package io.element.android.features.changeroommemberroles.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
@ -31,10 +31,12 @@ class DefaultChangeRoomMemberRolesEntyPointTest {
}
val room = FakeJoinedRoom()
val listType = ChangeRoomMemberRolesListType.Admins
val result = entryPoint.builder(parentNode, BuildContext.root(null))
.room(FakeJoinedRoom())
.listType(listType)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
room = FakeJoinedRoom(),
listType = listType,
)
assertThat(result).isInstanceOf(ChangeRoomMemberRolesRootNode::class.java)
// Search for the Inputs plugin
val input = result.plugins.filterIsInstance<ChangeRoomMemberRolesRootNode.Inputs>().single()

View file

@ -0,0 +1,21 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.changeroommemberroles.test"
}
dependencies {
implementation(projects.features.changeroommemberroles.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,26 @@
/*
* 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.changeroommemberroles.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroles.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.tests.testutils.lambda.lambdaError
class FakeChangeRoomMemberRolesEntryPoint : ChangeRoomMemberRolesEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
room: JoinedRoom,
listType: ChangeRoomMemberRolesListType,
): Node {
lambdaError()
}
}

View file

@ -14,12 +14,11 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onRoomCreated(roomId: RoomId)

View file

@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted
@ -24,6 +23,7 @@ import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ -42,9 +42,7 @@ class CreateRoomFlowNode(
buildContext = buildContext,
plugins = plugins
) {
private fun onRoomCreated(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onRoomCreated(roomId) }
}
private val callback: CreateRoomEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
@ -60,7 +58,7 @@ class CreateRoomFlowNode(
val inputs = AddPeopleNode.Inputs(navTarget.roomId)
val callback: AddPeopleNode.Callback = object : AddPeopleNode.Callback {
override fun onFinish() {
onRoomCreated(navTarget.roomId)
callback.onRoomCreated(navTarget.roomId)
}
}
createNode<AddPeopleNode>(buildContext, plugins = listOf(inputs, callback))

View file

@ -9,28 +9,18 @@ package io.element.android.features.createroom.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
@Inject
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreateRoomEntryPoint.NodeBuilder {
override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(callback))
}
}

View file

@ -12,13 +12,13 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ -39,10 +39,7 @@ class AddPeopleNode(
fun onFinish()
}
private fun onFinish() {
plugins<Callback>().forEach { it.onFinish() }
}
private val callback: Callback = callback()
private val roomId = inputs<Inputs>().roomId
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(
joinedRoom = null,
@ -54,7 +51,7 @@ class AddPeopleNode(
val state = invitePeoplePresenter.present()
AddPeopleView(
state = state,
onFinish = ::onFinish,
onFinish = callback::onFinish,
) {
invitePeopleRenderer.Render(state, Modifier)
}

View file

@ -13,11 +13,11 @@ import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
@ -42,9 +42,7 @@ class ConfigureRoomNode(
)
}
private fun onCreateRoomSuccess(roomId: RoomId) {
plugins<Callback>().forEach { it.onCreateRoomSuccess(roomId) }
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
@ -53,7 +51,7 @@ class ConfigureRoomNode(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onCreateRoomSuccess = ::onCreateRoomSuccess,
onCreateRoomSuccess = callback::onCreateRoomSuccess,
)
}
}

View file

@ -38,9 +38,11 @@ class DefaultCreateRoomEntryPointTest {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result.plugins).contains(callback)
}
}

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-library")
}
android {
namespace = "io.element.android.features.createroom.test"
}
dependencies {
implementation(projects.features.createroom.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.tests.testutils.lambda.lambdaError
class FakeCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -11,12 +11,10 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultAccountDeactivationEntryPoint : AccountDeactivationEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<AccountDeactivationNode>(buildContext)

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-library")
}
android {
namespace = "io.element.android.features.deactivation.test"
}
dependencies {
implementation(projects.features.deactivation.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.deactivation.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeAccountDeactivationEntryPoint : AccountDeactivationEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
): Node {
lambdaError()
}
}

View file

@ -7,9 +7,8 @@
package io.element.android.features.enterprise.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import io.element.android.compound.tokens.generated.SemanticColors
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
@ -21,20 +20,19 @@ interface EnterpriseService {
/**
* Override the brand color.
* @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session.
* @param brandColor the color in hex format (#RRGGBBAA or #RRGGBB), or null to reset to default.
*/
fun overrideBrandColor(brandColor: String?)
suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?)
@Composable
fun semanticColorsLight(): State<SemanticColors>
fun brandColorsFlow(sessionId: SessionId?): Flow<Color?>
@Composable
fun semanticColorsDark(): State<SemanticColors>
fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark>
fun firebasePushGateway(): String?
fun unifiedPushDefaultPushGateway(): String?
val bugReportUrlFlow: Flow<BugReportUrl>
fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl>
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"

View file

@ -7,23 +7,17 @@
package io.element.android.features.enterprise.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
@Inject
class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
@ -32,20 +26,20 @@ class DefaultEnterpriseService : EnterpriseService {
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override fun overrideBrandColor(brandColor: String?) = Unit
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@Composable
override fun semanticColorsLight(): State<SemanticColors> {
return remember { derivedStateOf { compoundColorsLight } }
override fun brandColorsFlow(sessionId: SessionId?): Flow<Color?> {
return flowOf(null)
}
@Composable
override fun semanticColorsDark(): State<SemanticColors> {
return remember { derivedStateOf { compoundColorsDark } }
override fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark> {
return flowOf(SemanticColorsLightDark.default)
}
override fun firebasePushGateway(): String? = null
override fun unifiedPushDefaultPushGateway(): String? = null
override val bugReportUrlFlow = flowOf(BugReportUrl.UseDefault)
override fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl> {
return flowOf(BugReportUrl.UseDefault)
}
}

View file

@ -8,12 +8,10 @@
package io.element.android.features.enterprise.impl
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
@Inject
class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun isElementCallAvailable(): Boolean = true

View file

@ -7,12 +7,10 @@
package io.element.android.features.enterprise.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.test.runTest
@ -44,28 +42,59 @@ class DefaultEnterpriseServiceTest {
}
@Test
fun `semanticColorsLight always emits the same value`() = runTest {
fun `semanticColorsFlow always emits the same value`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
moleculeFlow(RecompositionMode.Immediate) {
defaultEnterpriseService.semanticColorsLight().value
}.test {
defaultEnterpriseService.semanticColorsFlow(null).test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(compoundColorsLight)
defaultEnterpriseService.overrideBrandColor("#87654321")
expectNoEvents()
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
awaitComplete()
}
}
@Test
fun `semanticColorsDark always emits the same value`() = runTest {
fun `brandColorsFlow always emits null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
moleculeFlow(RecompositionMode.Immediate) {
defaultEnterpriseService.semanticColorsDark().value
}.test {
defaultEnterpriseService.brandColorsFlow(null).test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(compoundColorsDark)
defaultEnterpriseService.overrideBrandColor("#87654321")
expectNoEvents()
assertThat(initialState).isNull()
awaitComplete()
}
}
@Test
fun `semanticColorsFlow always emits the same value for a session`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
awaitComplete()
}
}
@Test
fun `overrideBrandColor has no effect`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor")
}
@Test
fun `firebasePushGateway returns null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.firebasePushGateway()).isNull()
}
@Test
fun `unifiedPushDefaultPushGateway returns null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull()
}
@Test
fun `bugReportUrlFlow only emits UseDefault`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService()
defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test {
assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault)
awaitComplete()
}
}
}

View file

@ -7,9 +7,8 @@
package io.element.android.features.enterprise.test
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import io.element.android.compound.tokens.generated.SemanticColors
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId
@ -24,12 +23,14 @@ class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
private val semanticColorsLightResult: () -> State<SemanticColors> = { lambdaError() },
private val semanticColorsDarkResult: () -> State<SemanticColors> = { lambdaError() },
private val overrideBrandColorResult: (String?) -> Unit = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow<Color?>(null)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask {
isEnterpriseUserResult(sessionId)
}
@ -42,18 +43,16 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
override fun overrideBrandColor(brandColor: String?) {
overrideBrandColorResult(brandColor)
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
overrideBrandColorResult(sessionId, brandColor)
}
@Composable
override fun semanticColorsLight(): State<SemanticColors> {
return semanticColorsLightResult()
override fun brandColorsFlow(sessionId: SessionId?): Flow<Color?> {
return brandColorState.asStateFlow()
}
@Composable
override fun semanticColorsDark(): State<SemanticColors> {
return semanticColorsDarkResult()
override fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark> {
return semanticColorsState.asStateFlow()
}
override fun firebasePushGateway(): String? {
@ -65,5 +64,7 @@ class FakeEnterpriseService(
}
val bugReportUrlMutableFlow = MutableStateFlow<BugReportUrl>(BugReportUrl.UseDefault)
override val bugReportUrlFlow: Flow<BugReportUrl> = bugReportUrlMutableFlow.asStateFlow()
override fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl> {
return bugReportUrlMutableFlow.asStateFlow()
}
}

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-library")
}
android {
namespace = "io.element.android.features.forward.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,35 @@
/*
* 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.forward.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
interface ForwardEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone(roomIds: List<RoomId>)
}
data class Params(
val eventId: EventId,
val timelineProvider: TimelineProvider,
) : NodeInputs
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
}

View file

@ -0,0 +1,40 @@
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")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.forward.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.features.forward.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.roomselect.test)
testImplementation(projects.libraries.testtags)
}

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.forward.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultForwardEntryPoint : ForwardEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: ForwardEntryPoint.Params,
callback: ForwardEntryPoint.Callback,
): Node {
return parentNode.createNode<ForwardMessagesNode>(
buildContext = buildContext,
plugins = listOf(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
timelineProvider = params.timelineProvider,
),
callback,
)
)
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.forward
package io.element.android.features.forward.impl
sealed interface ForwardMessagesEvents {
data object ClearError : ForwardMessagesEvents

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