Merge remote-tracking branch 'origin/develop' into mxandreas/disable-public-rooms

# Conflicts:
#	enterprise
This commit is contained in:
mxandreas
2026-10-09 09:58:27 +03:00
580 changed files with 7016 additions and 2776 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+14 -1
View File
@@ -53,7 +53,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
@@ -124,6 +124,19 @@ jobs:
disk-size: 3G disk-size: 3G
script: | script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
- name: Set up Python
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.14
- name: End all the remaining sessions of the Maestro account
if: always()
continue-on-error: true
env:
MAS_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
run: |
pip install requests
python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement
- name: Upload test results - name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: false cache-read-only: false
@@ -85,7 +85,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis
+6 -6
View File
@@ -74,7 +74,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
@@ -113,7 +113,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - name: Run Konsist tests
@@ -154,7 +154,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests - name: Run compose tests
@@ -188,7 +188,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build Gplay Debug
@@ -233,7 +233,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
@@ -274,7 +274,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots
+6 -2
View File
@@ -37,13 +37,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Check Matrix Rust SDK is not a nightly version
run: ./tools/release/check_no_nightly_sdk.sh
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -87,13 +89,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Check Matrix Rust SDK is not a nightly version
run: ./tools/release/check_no_nightly_sdk.sh
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" /> <option name="version" value="2.4.20" />
</component> </component>
</project> </project>
+1 -1
View File
@@ -37,7 +37,7 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable. - Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency. - Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. - Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence.
- Wrap previews in `ElementPreview { ... }`. - Wrap previews in `ElementPreview { ... }`.
- When writing tests, never try to record the screenshots, the CI will do it. - When writing tests, never try to record the screenshots, the CI will do it.
-6
View File
@@ -280,7 +280,6 @@ dependencies {
implementation(projects.features.migration.api) implementation(projects.features.migration.api)
implementation(projects.appnav) implementation(projects.appnav)
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
if (ModulesConfig.pushProvidersConfig.includeFirebase) { if (ModulesConfig.pushProvidersConfig.includeFirebase) {
@@ -298,18 +297,13 @@ dependencies {
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.androidx.preference) implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom)) implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.logging) implementation(libs.network.okhttp.logging)
implementation(libs.serialization.json)
implementation(libs.matrix.emojibase.bindings)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -11,12 +11,12 @@ package io.element.android.x.di
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds import dev.zacsweers.metro.Multibinds
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.callnative.api.NativeCallPip import io.element.android.features.callnative.api.NativeCallPip
import io.element.android.features.enterprise.api.AppStartupHook import io.element.android.features.enterprise.api.AppStartupHook
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.migration.api.MigrationEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
-1
View File
@@ -48,7 +48,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm) implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
-1
View File
@@ -44,7 +44,6 @@ dependencies {
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api) implementation(projects.features.login.api)
implementation(libs.coil) implementation(libs.coil)
@@ -33,7 +33,9 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
/** /**
@@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
callback.navigateToAddAccount() callback.navigateToAddAccount()
} }
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
} }
return createNode<LoggedInFlowNode>(buildContext, listOf(callback)) return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
} }
@@ -8,28 +8,51 @@
package io.element.android.appnav package io.element.android.appnav
import dev.zacsweers.metro.Inject import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@Inject @AssistedInject
class LoggedInEventProcessor( class LoggedInEventProcessor(
private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId,
private val roomListService: RoomListService,
private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers
) { ) {
private var observingJob: Job? = null @AssistedFactory
interface Factory {
fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var currentChildScope: CoroutineScope? = null
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates if (currentChildScope != null) return
val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor")
.also { currentChildScope = it }
roomMembershipObserver.updates
.filter { !it.isUserInRoom } .filter { !it.isUserInRoom }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { roomMemberShipUpdate -> .onEach { roomMemberShipUpdate ->
@@ -48,12 +71,20 @@ class LoggedInEventProcessor(
else -> Unit else -> Unit
} }
} }
.launchIn(coroutineScope) .launchIn(childScope)
// Use the room list service state as a 'heartbeat' update to check existing notifications
roomListService.state
.filter { it == RoomListService.State.Running }
.onEach {
notificationCleaner.clearReadRoomsNotifications(sessionId)
}
.launchIn(childScope)
} }
fun stopObserving() { fun stopObserving() {
observingJob?.cancel() currentChildScope?.cancel()
observingJob = null currentChildScope = null
} }
private fun displayMessage(message: Int) { private fun displayMessage(message: Int) {
@@ -88,6 +88,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -157,6 +158,7 @@ class LoggedInFlowNode(
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
@@ -172,13 +174,11 @@ class LoggedInFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher)
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
/** /**
* Display the incoming verification request, the app is expected to be in foreground. * Display the incoming verification request, the app is expected to be in foreground.
@@ -361,6 +361,10 @@ class LoggedInFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
callback.navigateToBugReport() callback.navigateToBugReport()
} }
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
} }
homeEntryPoint.createNode( homeEntryPoint.createNode(
parentNode = this, parentNode = this,
@@ -572,10 +576,16 @@ class LoggedInFlowNode(
buildContext = buildContext, buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback { callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen // Remove the incoming share screen
backstack.pop() backstack.pop()
if (sessionId != matrixClient.sessionId) {
// The data has been shared using another session, switch to it
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
return
}
// Navigate to the room if the text/media was shared to a single one // Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId -> roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch { lifecycleScope.launch {
@@ -13,6 +13,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@@ -45,6 +51,8 @@ class NotLoggedInFlowNode(
private val loginEntryPoint: LoginEntryPoint, private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>( ) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@@ -68,6 +76,13 @@ class NotLoggedInFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn() analyticsColdStartWatcher.whenLoggingIn()
lifecycleScope.launch {
// Users who enter the login flow while the feature is available, either for the first time or to add
// another account, do not need to be informed about the multi-account feature.
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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.asEventId
@@ -349,8 +350,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect( @Parcelize data class AccountSelect(
val currentSessionId: SessionId, val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?, val permalinkData: PermalinkData,
val permalinkData: PermalinkData?,
) : NavTarget ) : NavTarget
@Parcelize data class NotLoggedInFlow( @Parcelize data class NotLoggedInFlow(
@@ -389,6 +389,15 @@ class RootFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null)) backstack.push(NavTarget.NotLoggedInFlow(null))
} }
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
lifecycleScope.launch {
val loggedInFlowNode = attachSession(sessionId)
roomId?.let {
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
} }
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) { val buildContext = if (savedNavState != null) {
@@ -463,13 +472,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker. // Do not pop when the account is changed to avoid a UI flicker.
backstack.pop() backstack.pop()
} }
attachSession(sessionId).apply { attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
} }
} }
@@ -539,21 +542,9 @@ class RootFlowNode(
// No session, open login // No session, open login
switchToNotLoggedInFlow(null) switchToNotLoggedInFlow(null)
} else { } else {
// wait for the current session to be restored // wait for the current session to be restored, then attach the incoming share node.
val loggedInFlowNode = attachSession(latestSessionId) // In case of multiple accounts, the user can switch account from the room select screen.
if (sessionStore.numberOfSessions() > 1) { attachSession(latestSessionId).attachIncomingShare(shareIntentData)
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = shareIntentData,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(shareIntentData)
}
} }
} }
@@ -576,7 +567,6 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData, permalinkData = permalinkData,
) )
) )
@@ -28,8 +28,8 @@ import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
} else { } else {
(graph as TimelineBindings).timelineProvider (graph as TimelineBindings).timelineProvider
} }
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
backstack.pop() backstack.pop()
@@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoggedInEventProcessorTest {
@Test
fun `test observeEvents with left membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with left membership change for space`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = true,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with invitation rejected membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.INVITED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with knock retracted membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.KNOCKED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test stopObserving cancels the child scope`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
assertThat(loggedInEventProcessor.currentChildScope?.isActive).isTrue()
testScheduler.runCurrent()
// Stop observing events
loggedInEventProcessor.stopObserving()
// Verify that the child scope is cancelled (the scope is null)
assertThat(loggedInEventProcessor.currentChildScope).isNull()
}
@Test
fun `test notifications are cleared when room list service state is running`() = runTest {
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
val snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val roomListService = FakeRoomListService()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner,
roomListService = roomListService,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
// Simulate the room list service state changing to Running
roomListService.postState(RoomListService.State.Running)
testScheduler.runCurrent()
// Verify that the notifications were cleared
clearReadNotificationsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID))
}
private fun TestScope.createLoggedInEventProcessor(
snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
sessionId: SessionId = A_SESSION_ID,
roomListService: FakeRoomListService = FakeRoomListService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId,
roomListService = roomListService,
notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(),
)
}
@@ -26,6 +26,7 @@ import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestObserver import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginEntryPoint
@@ -33,7 +34,6 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
@@ -42,7 +42,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
@@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() {
loginEntryPoint = loginEntryPoint, loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(), imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
) )
} }
) )
-4
View File
@@ -101,10 +101,6 @@ allprojects {
// Uncomment to suppress Compose Kotlin compiler compatibility warning // Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
// Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only")
} }
} }
} }
@@ -53,7 +53,7 @@ class ContributesNodeProcessor(
val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>() .filterIsInstance<KSClassDeclaration>()
val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) }
if (validSymbols.isEmpty()) return invalidSymbols if (validSymbols.isEmpty()) return invalidSymbols
-2
View File
@@ -16,6 +16,4 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.uiStrings)
} }
@@ -17,4 +17,6 @@ sealed interface Announcement {
} }
data object NewNotificationSound : Announcement data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
} }
@@ -29,10 +29,11 @@ class DefaultAnnouncementService(
) : AnnouncementService { ) : AnnouncementService {
override suspend fun showAnnouncement(announcement: Announcement) { override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) { when (announcement) {
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement)
Announcement.NewNotificationSound -> { Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
} }
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
} }
} }
@@ -44,16 +45,22 @@ class DefaultAnnouncementService(
return combine( return combine(
flowOf(Unit), flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { _, newNotificationSoundStatus -> announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus ->
buildList { buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) { if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound) add(Announcement.NewNotificationSound)
} }
// The multi-account announcement is pending until it is dismissed. Users who were already
// logged in when the feature became available still have the default NeverShown status.
if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount)
}
} }
} }
} }
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) {
val currentValue = announcementStore.announcementStatusFlow(announcement).first() val currentValue = announcementStore.announcementStatusFlow(announcement).first()
if (currentValue == AnnouncementStatus.NeverShown) { if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnnouncementStore( class DefaultAnnouncementStore(
@@ -36,11 +37,15 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> { override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey() val key = announcement.toKey()
// Default-status convention: Shown for one-shot intro/feature announcements that should // Default-status convention: Shown for one-shot intro/feature announcements that should
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted // NOT replay on first launch.
// in by AppMigration08). // - Fullscreen.Space is disabled
// - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
val defaultStatus = when (announcement) { val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
} }
return store.data.map { prefs -> return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -56,4 +61,5 @@ class DefaultAnnouncementStore(
private fun Announcement.toKey() = when (this) { private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey
} }
@@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
} }
@Test
fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.MultiAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.MultiAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test @Test
fun `test announcementsToShowFlow`() = runTest { fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore() val announcementStore = InMemoryAnnouncementStore()
@@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore, announcementStore = announcementStore,
) )
sut.announcementsToShowFlow().test { sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty() // MultiAccount is pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
} }
} }
@@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore( class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore { ) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status announcement.toMutableStateFlow().value = status
@@ -31,10 +33,12 @@ class InMemoryAnnouncementStore(
override suspend fun reset() { override suspend fun reset() {
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
} }
private fun Announcement.toMutableStateFlow() = when (this) { private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement
} }
} }
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.test.logs package io.element.android.features.announcement.test
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
-1
View File
@@ -17,6 +17,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }
-1
View File
@@ -90,7 +90,6 @@ dependencies {
api(projects.features.call.api) api(projects.features.call.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -29,7 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -31,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -6,17 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call package io.element.android.features.call.impl
import android.content.Intent import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeNativeCallEntryPoint import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint
import io.element.android.features.callnative.api.NativeCallEntryPoint import io.element.android.features.callnative.api.NativeCallEntryPoint
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call package io.element.android.features.call.impl
import android.Manifest import android.Manifest
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
@@ -6,13 +6,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.notifications package io.element.android.features.call.impl.notifications
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
@@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test import org.junit.Test
class CallScreenBackPressPolicyTest { class CallScreenBackPressPolicyTest {
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@@ -14,13 +14,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvent import io.element.android.features.call.impl.utils.FakeActiveCallManager
import io.element.android.features.call.impl.ui.CallScreenNavigator import io.element.android.features.call.impl.utils.FakeCallWidgetProvider
import io.element.android.features.call.impl.ui.CallScreenPresenter import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import android.view.KeyEvent import android.view.KeyEvent
import android.webkit.WebView import android.webkit.WebView
@@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.call.impl.pip.PictureInPictureEvent import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenState
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -6,9 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
class FakeCallScreenNavigator : CallScreenNavigator { class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false var closeCalled = false
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.ui package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.call.impl.ui.LanguageTagProvider
class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider {
@Composable @Composable
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -6,12 +6,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CallData
import io.element.android.features.callnative.api.NativeCallEntryPoint import io.element.android.features.callnative.api.NativeCallEntryPoint
@@ -6,9 +6,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.call.utils package io.element.android.features.call.impl.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
-1
View File
@@ -17,7 +17,6 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api) api(projects.features.call.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@@ -47,8 +47,6 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(libs.element.call.test) testImplementation(libs.element.call.test)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
} }
@@ -8,16 +8,8 @@ android {
} }
dependencies { dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
} }
@@ -24,7 +24,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@@ -34,7 +34,6 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
@@ -52,7 +51,6 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
} }
@@ -8,7 +8,7 @@
<string name="screen_create_room_new_room_title">"新房间"</string> <string name="screen_create_room_new_room_title">"新房间"</string>
<string name="screen_create_room_new_space_title">"新空间"</string> <string name="screen_create_room_new_space_title">"新空间"</string>
<string name="screen_create_room_private_option_description">"仅限受邀人员加入。"</string> <string name="screen_create_room_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_private_option_title">"私密"</string> <string name="screen_create_room_private_option_title">"私有"</string>
<string name="screen_create_room_public_option_description">"任何人都能找到此房间。 <string name="screen_create_room_public_option_description">"任何人都能找到此房间。
你可以随时在房间设置中更改。"</string> 你可以随时在房间设置中更改。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入,包括你的组织之外的人员。"</string> <string name="screen_create_room_public_option_short_description">"任何人都可以加入,包括你的组织之外的人员。"</string>
@@ -18,7 +18,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"%1$s 中的任何人都可以加入,但其他人必须申请访问。"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_description">"%1$s 中的任何人都可以加入,但其他人必须申请访问。"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"申请加入"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_title">"申请加入"</string>
<string name="screen_create_room_room_access_section_private_option_description">"仅限受邀人员加入。"</string> <string name="screen_create_room_room_access_section_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私密"</string> <string name="screen_create_room_room_access_section_private_option_title">"私有"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入,包括你的组织之外的人员。"</string> <string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入,包括你的组织之外的人员。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公共"</string> <string name="screen_create_room_room_access_section_public_option_title">"公共"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s 中的任何人都可以加入。"</string> <string name="screen_create_room_room_access_section_restricted_option_description">"%1$s 中的任何人都可以加入。"</string>
@@ -6,10 +6,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.createroom.api package io.element.android.features.createroom.test
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -28,7 +28,6 @@ setupDependencyInjection()
dependencies { dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
sealed interface AccountDeactivationEvent { sealed interface AccountDeactivationEvent {
data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -44,8 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.deactivation.impl.R import io.element.android.features.deactivation.impl.ui.AccountDeactivationActionDialog
import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl.ui package io.element.android.features.deactivation.impl.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl.ui package io.element.android.features.deactivation.impl.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@@ -8,7 +8,7 @@
@file:OptIn(ExperimentalTestApi::class) @file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.logout.impl package io.element.android.features.deactivation.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
-1
View File
@@ -17,5 +17,4 @@ dependencies {
implementation(projects.libraries.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
} }
@@ -25,7 +25,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(libs.serialization.json) implementation(libs.serialization.json)
testCommonDependencies(libs) testCommonDependencies(libs)
@@ -18,6 +18,5 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
} }
data class Params( data class Params(
val eventId: EventId, val eventIds: List<EventId>,
val timelineProvider: TimelineProvider, val timelineProvider: TimelineProvider,
) : NodeInputs ) : NodeInputs
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
ForwardMessagesNode.Inputs( ForwardMessagesNode.Inputs(
eventId = params.eventId, eventIds = params.eventIds,
timelineProvider = params.timelineProvider, timelineProvider = params.timelineProvider,
), ),
callback, callback,
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -40,6 +41,7 @@ class ForwardMessagesNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory, presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint, private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ForwardMessagesNode.NavTarget>( ) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@@ -52,17 +54,18 @@ class ForwardMessagesNode(
object NavTarget : Parcelable object NavTarget : Parcelable
data class Inputs( data class Inputs(
val eventId: EventId, val eventIds: List<EventId>,
val timelineProvider: TimelineProvider, val timelineProvider: TimelineProvider,
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callback: ForwardEntryPoint.Callback = callback() private val callback: ForwardEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider) private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) { override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
// In Forward mode, the session cannot be changed
presenter.onRoomSelected(roomIds) presenter.onRoomSelected(roomIds)
} }
@@ -75,6 +78,7 @@ class ForwardMessagesNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = RoomSelectEntryPoint.Params( params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Forward, mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
), ),
@@ -28,22 +28,20 @@ import timber.log.Timber
@AssistedInject @AssistedInject
class ForwardMessagesPresenter( class ForwardMessagesPresenter(
@Assisted eventId: String, @Assisted private val eventIds: List<EventId>,
@Assisted private val timelineProvider: TimelineProvider, @Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> { ) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter fun create(eventIds: List<EventId>, timelineProvider: TimelineProvider): ForwardMessagesPresenter
} }
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds) sessionCoroutineScope.forwardEvents(eventIds, roomIds)
} }
@Composable @Composable
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
) )
} }
private fun CoroutineScope.forwardEvent( private fun CoroutineScope.forwardEvents(
eventId: EventId, eventIds: List<EventId>,
roomIds: List<RoomId>, roomIds: List<RoomId>,
) = launch { ) = launch {
suspend { suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
.onFailure { .onFailure {
Timber.e(it, "Error while forwarding event") Timber.e(it, "Error while forwarding events")
} }
.getOrThrow() .getOrThrow()
roomIds roomIds
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -39,13 +40,14 @@ class DefaultForwardEntryPointTest {
plugins = plugins, plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() }, presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(), roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
) )
} }
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError() override fun onDone(roomIds: List<RoomId>) = lambdaError()
} }
val params = ForwardEntryPoint.Params( val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID, eventIds = listOf(AN_EVENT_ID),
timelineProvider = FakeTimelineProvider(), timelineProvider = FakeTimelineProvider(),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
@@ -57,7 +59,7 @@ class DefaultForwardEntryPointTest {
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java) assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains( assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs( ForwardMessagesNode.Inputs(
eventId = params.eventId, eventIds = params.eventIds,
timelineProvider = params.timelineProvider, timelineProvider = params.timelineProvider,
) )
) )
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
@Test @Test
fun `present - forward successful`() = runTest { fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> -> val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda this.forwardEventsLambda = forwardEventsLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
assertThat(forwardingState.forwardAction.isLoading()).isTrue() assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem() val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce() forwardEventsLambda.assertions().isCalledOnce()
} }
} }
@Test @Test
fun `present - select a room and forward failed, then clear`() = runTest { fun `present - select a room and forward failed, then clear`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> -> val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error")) Result.failure<Unit>(IllegalStateException("error"))
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda this.forwardEventsLambda = forwardEventsLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
// Then clear error // Then clear error
failedForwardState.eventSink(ForwardMessagesEvent.ClearError) failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce() forwardEventsLambda.assertions().isCalledOnce()
} }
} }
} }
fun TestScope.createForwardMessagesPresenter( fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID, eventIds: List<EventId> = listOf(AN_EVENT_ID),
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter( ) = ForwardMessagesPresenter(
eventId = eventId.value, eventIds = eventIds,
timelineProvider = LiveTimelineProvider(fakeRoom), timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this, sessionCoroutineScope = this,
) )
-1
View File
@@ -15,7 +15,6 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.features.forward.api) implementation(projects.features.forward.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }
-2
View File
@@ -32,12 +32,10 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.securebackup.api) implementation(projects.features.securebackup.api)
@@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToEnterRecoveryKey() fun navigateToEnterRecoveryKey()
fun navigateToRoomSettings(roomId: RoomId) fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount()
} }
} }
-6
View File
@@ -45,14 +45,11 @@ dependencies {
implementation(projects.libraries.indicator.api) implementation(projects.libraries.indicator.api)
implementation(projects.libraries.deeplink.api) implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.fullscreenintent.api) implementation(projects.libraries.fullscreenintent.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api) implementation(projects.features.announcement.api)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@@ -72,11 +69,8 @@ dependencies {
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.indicator.test)
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
} }
@@ -8,8 +8,8 @@
package io.element.android.features.home.impl package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder {
matrixUser matrixUser
} else { } else {
// Use the data from the DB // Use the data from the DB
MatrixUser( it.toMatrixUser()
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
} }
} }
.let { sessionList -> .let { sessionList ->
@@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent { sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
} }
@@ -221,6 +221,7 @@ class HomeFlowNode(
homeState = state, homeState = state,
onRoomClick = ::navigateToRoom, onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings, onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onStartChatClick = callback::navigateToCreateRoom, onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace, onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery, onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -19,18 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
@@ -43,6 +48,8 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter<HomeSpacesState>, private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService,
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@@ -59,6 +66,30 @@ class HomePresenter(
}.collectAsState(initial = persistentListOf(matrixUser)) }.collectAsState(initial = persistentListOf(matrixUser))
val isOnline by syncService.isOnline.collectAsState() val isOnline by syncService.isOnline.collectAsState()
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val showMultiAccountAnnouncement by remember {
combine(
announcementService.announcementsToShowFlow(),
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount),
sessionStore.sessionsFlow(),
) { announcements, isMultiAccountEnabled, sessions ->
// Do not announce the feature to users who already have several accounts
isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount)
}
}.collectAsState(false)
LaunchedEffect(Unit) {
// The user already uses several accounts, so the feature does not need to be announced anymore,
// even if they later remove accounts.
combine(
announcementService.announcementsToShowFlow(),
sessionStore.sessionsFlow(),
) { announcements, sessions ->
announcements.contains(Announcement.MultiAccount) && sessions.size > 1
}
.filter { it }
.collect {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
val roomListState = roomListPresenter.present() val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present() val homeSpacesState = homeSpacesPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
@@ -82,6 +113,9 @@ class HomePresenter(
is HomeEvent.SwitchToAccount -> coroutineState.launch { is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value) sessionStore.setLatestSession(event.sessionId.value)
} }
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
} }
} }
@@ -95,6 +129,7 @@ class HomePresenter(
homeSpacesState = homeSpacesState, homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -28,6 +28,7 @@ data class HomeState(
val homeSpacesState: HomeSpacesState, val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean, val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
val eventSink: (HomeEvent) -> Unit, val eventSink: (HomeEvent) -> Unit,
) { ) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -48,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState( homeSpacesState = aHomeSpacesState(
spaceRooms = emptyList(), spaceRooms = emptyList(),
), ),
) + aHomeState(
showMultiAccountAnnouncement = true,
) )
} }
@@ -61,6 +63,7 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(), roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true, canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
eventSink: (HomeEvent) -> Unit = {} eventSink: (HomeEvent) -> Unit = {}
) = HomeState( ) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -68,6 +71,7 @@ internal fun aHomeState(
hasNetworkConnection = hasNetworkConnection, hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
currentHomeNavigationBarItem = currentHomeNavigationBarItem, currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState, roomListState = roomListState,
homeSpacesState = homeSpacesState, homeSpacesState = homeSpacesState,
@@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.HomeTopBar import io.element.android.features.home.impl.components.HomeTopBar
import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet
import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListContentView
import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -76,6 +77,7 @@ fun HomeView(
homeState: HomeState, homeState: HomeState,
onRoomClick: (RoomId, EventId?) -> Unit, onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit, onStartChatClick: () -> Unit,
@@ -110,6 +112,13 @@ fun HomeView(
) )
} }
if (homeState.showMultiAccountAnnouncement) {
MultiAccountAnnouncementBottomSheet(
onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) },
onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() },
)
}
leaveRoomView() leaveRoomView()
HomeScaffold( HomeScaffold(
@@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
homeState = state, homeState = state,
onRoomClick = { _, _ -> }, onRoomClick = { _, _ -> },
onSettingsClick = {}, onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {}, onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onStartChatClick = {}, onStartChatClick = {},
@@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
homeState = aHomeState(), homeState = aHomeState(),
onRoomClick = { _, _ -> }, onRoomClick = { _, _ -> },
onSettingsClick = {}, onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {}, onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onStartChatClick = {}, onStartChatClick = {},
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
/**
* Bottom sheet announcing that several accounts can now be added.
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MultiAccountAnnouncementBottomSheet(
onDismiss: () -> Unit,
onAddAccountClick: () -> Unit,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
scrollable = false,
) {
MultiAccountAnnouncementContent(
onAddAccountClick = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
onAddAccountClick()
}
},
)
}
}
@Composable
private fun MultiAccountAnnouncementContent(
onAddAccountClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
)
Spacer(Modifier.height(16.dp))
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics { heading() },
text = stringResource(R.string.screen_multi_account_announcement_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = annotatedTextWithBold(
text = stringResource(R.string.screen_multi_account_announcement_description, path),
boldText = path,
),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(40.dp))
Button(
text = stringResource(R.string.screen_multi_account_announcement_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
modifier = Modifier.fillMaxWidth(),
onClick = onAddAccountClick,
)
Spacer(Modifier.height(16.dp))
}
}
@PreviewsDayNight
@Composable
internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
MultiAccountAnnouncementBottomSheet(
onDismiss = {},
onAddAccountClick = {},
)
}
@@ -8,7 +8,6 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -23,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -129,6 +127,9 @@ private fun SkeletonView(
} }
} }
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656
*/
@Composable @Composable
private fun EmptyView( private fun EmptyView(
state: RoomListContentState.Empty, state: RoomListContentState.Empty,
@@ -139,13 +140,19 @@ private fun EmptyView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
val text = buildString {
append(stringResource(R.string.screen_roomlist_empty_title))
append("\n")
append(stringResource(R.string.screen_roomlist_empty_message))
}
EmptyScaffold( EmptyScaffold(
title = R.string.screen_roomlist_empty_title, title = null,
subtitle = R.string.screen_roomlist_empty_message, subtitle = text,
action = { bottomPart = {
Spacer(modifier = Modifier.height(16.dp))
Button( Button(
text = stringResource(CommonStrings.action_start_chat), text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()), leadingIcon = IconSource.Vector(CompoundIcons.Chat()),
onClick = onCreateRoomClick, onClick = onCreateRoomClick,
) )
}, },
@@ -299,39 +306,44 @@ private fun EmptyViewForFilterStates(
) { ) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
EmptyScaffold( EmptyScaffold(
title = emptyStateResources.title, title = stringResource(emptyStateResources.title),
subtitle = emptyStateResources.subtitle, subtitle = stringResource(emptyStateResources.subtitle),
modifier = modifier, modifier = modifier,
bottomPart = {
// Keep a spacer so that the content is not too low on the screen
Spacer(modifier = Modifier.height(32.dp))
}
) )
} }
@Composable @Composable
private fun EmptyScaffold( private fun EmptyScaffold(
@StringRes title: Int, title: String?,
@StringRes subtitle: Int, subtitle: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
action: @Composable (ColumnScope.() -> Unit)? = null, bottomPart: @Composable ColumnScope.() -> Unit,
) { ) {
Column( Column(
modifier = modifier.padding(horizontal = 60.dp), modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
title?.let {
Text(
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
Text( Text(
text = stringResource(title), text = subtitle,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(subtitle),
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(32.dp)) bottomPart()
action?.invoke(this)
} }
} }
@@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "", text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) { color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator ElementTheme.colors.textActionAccent
} else { } else {
ElementTheme.colors.roomListRoomMessageDate ElementTheme.colors.roomListRoomMessageDate
}, },
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread // Call and unread
Row( Row(
modifier = Modifier modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) { if (room.hasRoomCall) {
OnGoingCallIcon( OnGoingCallIcon(
color = tint, color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (!isInviteSeen) { if (!isInviteSeen) {
UnreadIndicatorAtom( UnreadIndicatorAtom(count = 0)
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
} }
} }
} }
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(), imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator, tint = ElementTheme.colors.iconAccentPrimary,
) )
} }
@@ -47,6 +47,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -119,7 +120,7 @@ class RoomListPresenter(
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
val showAllActivity by produceState(initialValue = false) { val showAllActivity by produceState(initialValue = false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it } featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature).collect { value = it }
} }
fun handleEvent(event: RoomListEvent) { fun handleEvent(event: RoomListEvent) {

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