diff --git a/.github/workflows/nightly_enterprise.yml b/.github/workflows/nightly_enterprise.yml
deleted file mode 100644
index 5e02c68240..0000000000
--- a/.github/workflows/nightly_enterprise.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-name: Build and release Enterprise nightly application
-
-on:
- workflow_dispatch:
- schedule:
- # Every nights at 4
- - cron: "0 4 * * *"
-
-env:
- GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
- CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
-
-jobs:
- nightly:
- name: Build and publish Enterprise nightly bundle to Firebase
- runs-on: ubuntu-latest
- if: ${{ github.repository == 'element-hq/element-x-android' }}
- steps:
- - uses: actions/checkout@v4
- - name: Add SSH private keys for submodule repositories
- uses: webfactory/ssh-agent@v0.9.1
- with:
- ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- - name: Clone submodules
- run: git submodule update --init --recursive
- - name: Use JDK 21
- uses: actions/setup-java@v4
- with:
- distribution: 'temurin' # See 'Supported distributions' for available options
- java-version: '21'
- - name: Build and upload Nightly application
- run: |
- ./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
- env:
- ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
- ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
- ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
- ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
- ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
- ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
- ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
- ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
- ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
- ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
- FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- - name: Additionally upload Nightly APK to browserstack for testing
- continue-on-error: true # don't block anything by this upload failing (for now)
- run: |
- curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
- env:
- BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
- BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 72a4dfe0a7..929bcb5dcd 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -1,6 +1,6 @@
name: Pull Request
on:
- pull_request:
+ pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
secrets:
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index c22b6fa9ee..131e44d798 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CHANGES.md b/CHANGES.md
index c3a966e77f..b9fefeaad8 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,21 @@
+Changes in Element X v25.03.4
+=============================
+
+
+
+## What's Changed
+### 🙌 Improvements
+* Change : composer suggestions by @ganfra in https://github.com/element-hq/element-x-android/pull/4485
+### 🧱 Build
+* Fix flaky incoming verification tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4479
+### Dependency upgrades
+* fix(deps): update dagger to v2.56.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4472
+* fix(deps): update dependencyanalysis to v2.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4473
+* Upgrade embedded EC version to `v0.9.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4489
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.3...v25.03.4
+
Changes in Element X v25.03.3
=============================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0da59234da..107f9431f2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -20,6 +20,7 @@ import extension.allEnterpriseImpl
import extension.allFeaturesImpl
import extension.allLibrariesImpl
import extension.allServicesImpl
+import extension.buildConfigFieldStr
import extension.koverDependencies
import extension.locales
import extension.setupAnvil
@@ -102,7 +103,7 @@ android {
}
val baseAppName = BuildTimeConfig.APPLICATION_NAME
- logger.warnInBox("Building $baseAppName")
+ logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName)")
buildTypes {
getByName("debug") {
@@ -170,13 +171,13 @@ android {
create("gplay") {
dimension = "store"
isDefault = true
- buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"G\"")
- buildConfigField("String", "FLAVOR_DESCRIPTION", "\"GooglePlay\"")
+ buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "G")
+ buildConfigFieldStr("FLAVOR_DESCRIPTION", "GooglePlay")
}
create("fdroid") {
dimension = "store"
- buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"F\"")
- buildConfigField("String", "FLAVOR_DESCRIPTION", "\"FDroid\"")
+ buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "F")
+ buildConfigFieldStr("FLAVOR_DESCRIPTION", "FDroid")
}
}
}
@@ -291,8 +292,8 @@ tasks.withType().configureEach {
outputs.upToDateWhen { false }
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
- android.defaultConfig.buildConfigField("String", "GIT_REVISION", "\"$gitRevision\"")
- android.defaultConfig.buildConfigField("String", "GIT_BRANCH_NAME", "\"$gitBranchName\"")
+ android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision)
+ android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName)
}
licensee {
diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
index a4bfe0c60d..30bef76339 100644
--- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
+++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
@@ -9,6 +9,8 @@ package io.element.android.x
import android.app.Application
import androidx.startup.AppInitializer
+import io.element.android.appconfig.RageshakeConfig
+import io.element.android.appconfig.isEnabled
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
@@ -23,7 +25,9 @@ class ElementXApplication : Application(), DaggerComponentOwner {
override fun onCreate() {
super.onCreate()
AppInitializer.getInstance(this).apply {
- initializeComponent(CrashInitializer::class.java)
+ if (RageshakeConfig.isEnabled) {
+ initializeComponent(CrashInitializer::class.java)
+ }
initializeComponent(PlatformInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java)
}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
index 650480a0ee..6dd0d69dc0 100644
--- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
+++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
@@ -37,6 +37,7 @@ class PlatformInitializer : Initializer {
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
logLevel = logLevel,
extraTargets = listOf(ELEMENT_X_TARGET),
+ traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
)
bugReporter.setCurrentTracingLogLevel(logLevel.name)
platformService.init(tracingConfiguration)
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 884ae0ea71..f61fb33868 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -5,8 +5,8 @@
~ Please see LICENSE files in the repository root for full details.
-->
-
+
#FF101317
-
+
#FFFFFFFF
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index 99c7e1defa..15b8bd51ba 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -1,3 +1,6 @@
+import config.BuildTimeConfig
+import extension.buildConfigFieldStr
+
/*
* Copyright 2022-2024 New Vector Ltd.
*
@@ -10,6 +13,37 @@ plugins {
android {
namespace = "io.element.android.appconfig"
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ defaultConfig {
+ buildConfigFieldStr(
+ name = "URL_POLICY",
+ value = if (isEnterpriseBuild) {
+ BuildTimeConfig.URL_POLICY ?: ""
+ } else {
+ "https://element.io/cookie-policy"
+ },
+ )
+ buildConfigFieldStr(
+ name = "BUG_REPORT_URL",
+ value = if (isEnterpriseBuild) {
+ BuildTimeConfig.BUG_REPORT_URL ?: ""
+ } else {
+ "https://riot.im/bugreports/submit"
+ },
+ )
+ buildConfigFieldStr(
+ name = "BUG_REPORT_APP_NAME",
+ value = if (isEnterpriseBuild) {
+ BuildTimeConfig.BUG_REPORT_APP_NAME ?: ""
+ } else {
+ "element-x-android"
+ },
+ )
+ }
}
dependencies {
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
index 4b213db637..346fce4725 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt
@@ -8,5 +8,5 @@
package io.element.android.appconfig
object AnalyticsConfig {
- const val POLICY_LINK = "https://element.io/cookie-policy"
+ const val POLICY_LINK = BuildConfig.URL_POLICY
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
index 47d94cc497..8c836bc8a2 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
@@ -11,17 +11,23 @@ object RageshakeConfig {
/**
* The URL to submit bug reports to.
*/
- const val BUG_REPORT_URL = "https://riot.im/bugreports/submit"
+ const val BUG_REPORT_URL = BuildConfig.BUG_REPORT_URL
/**
* As per https://github.com/matrix-org/rageshake:
* Identifier for the application (eg 'riot-web').
* Should correspond to a mapping configured in the configuration file for github issue reporting to work.
*/
- const val BUG_REPORT_APP_NAME = "element-x-android"
+ const val BUG_REPORT_APP_NAME = BuildConfig.BUG_REPORT_APP_NAME
/**
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
}
+
+/**
+ * Whether the rageshake feature is enabled.
+ */
+val RageshakeConfig.isEnabled: Boolean
+ get() = BUG_REPORT_URL.isNotEmpty() && BUG_REPORT_APP_NAME.isNotEmpty()
diff --git a/appicon/element/src/main/ic_launcher-playstore.png b/appicon/element/src/main/ic_launcher-playstore.png
index 62af9cf4b2..325bf570f5 100644
Binary files a/appicon/element/src/main/ic_launcher-playstore.png and b/appicon/element/src/main/ic_launcher-playstore.png differ
diff --git a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
index f55c6f23e8..c25675bffc 100644
--- a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
+++ b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
@@ -10,25 +10,28 @@ package io.element.android.appicon.element
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
@Preview
@Composable
internal fun IconPreview() {
Box {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -36,8 +39,15 @@ internal fun IconPreview() {
@Composable
internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
internal fun MonochromeIconPreview() {
Box(
modifier = Modifier
- .size(108.dp)
- .background(Color(0xFF2F3133))
- .clip(shape = RoundedCornerShape(32.dp)),
- contentAlignment = Alignment.Center
+ .background(Color(0xFF2F3133)),
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp
index 793d5ca60d..2ae0da8d0f 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp
deleted file mode 100644
index f051ae3c81..0000000000
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index d1ff05833e..e40370b86f 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 78a93b86f1..8ad6b74901 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp
index e8f321ff17..d4e1b90f22 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp
deleted file mode 100644
index 27d9d1db19..0000000000
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index f411c1016c..ac2361f8b0 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 5380a9e861..3cd52b2182 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp
index b31de82585..527b23880a 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp
deleted file mode 100644
index 4dbc6db066..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index 5e6654b50c..f8c5c5f218 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index a368522d59..1c98f35c9f 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 889388eab6..ed524b893c 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b635d5cbb5..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 9aebd17d21..bb401bcb37 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index af59382417..a6b0547ed0 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index 97afc844cb..359e3921a1 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b5cb68c7bb..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index 92e763d12f..f0f9a63324 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index d71ab178fe..36125792fe 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/release/res/drawable/ic_launcher_background.xml b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
index 6ff3e59543..1cbabacb4f 100644
--- a/appicon/element/src/release/res/drawable/ic_launcher_background.xml
+++ b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
@@ -1,2 +1,10 @@
-
+
+
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index fbecfeb60b..2a7403862e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
@@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 56f3716ca1..a2016a9171 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
index 49a05c9d36..3c6f30af72 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
@@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 8640fb03c8..0d79b5847a 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -241,7 +241,7 @@ class IntentResolverTest {
}
private fun createIntentResolver(
- permalinkParserResult: () -> PermalinkData = { lambdaError() }
+ permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
): IntentResolver {
return IntentResolver(
deeplinkParser = DeeplinkParser(),
diff --git a/fastlane/metadata/android/en-US/changelogs/202504000.txt b/fastlane/metadata/android/en-US/changelogs/202504000.txt
new file mode 100644
index 0000000000..8955ade680
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index f3708552d3..d0b9479886 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,38 +1,28 @@
-Element X brings you both sovereign & seamless collaboration built on Matrix.
+Freedom to communicate on your own terms
-The collaboration capabilities include chat & video calls with the modern set of features such as:
- • public & private channels
- • room moderation & access control
- • replies, reactions, polls, read receipts, pinned messages, etc.
- • simultaneous chat & calls (picture in picture)
- • decentralized & federated communication across organizations
+For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
-All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app:
- • enterprise-grade single sign-on
- • easy & secure login & device verification via QR-code
- • end to end encryption & zero trust
- • protection against MITM & other cyber attacks
+Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android.
-If you’re a new user, use the new Element X app from the start. Compared to the current Element app you will get:
- • greatly enhanced performance, sleek user interface and overall better user experience
- • enterprise-grade support for single sign-on (OIDC)
- • QR-code based login & device verification
- • natively integrated Element Call for video calls
- • continuous improvements, bug fixes and new features
+Stay in touch with friends, family and communities with:
+ • Real time messaging & video calls
+ • Public rooms for open group communication
+ • Private rooms for closed group communication
+ • Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
+ • Video calling while browsing messages.
+ • Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more.
-If you’re an existing user, using the current Element app - check out the new Element X and start planning your transition. The current Element app will be phased out and will only get critical security updates.
+Privacy-first
+Unlike some other messengers from Big Tech companies, we don’t mine your data or monitor your communications.
-Own your data
-Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
+Own your conversations
+Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
-Interoperate natively
-Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends, partners or customers are on a different Matrix-based app - you can still connect.
+Communicate in real time, all the time
+Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
-Encrypt your data
-Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages.
-
-Chat across multiple devices
-Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io
+Element X is our next-generation app
+If you’re using the original Element app, it’s time to try Element X! It’s faster, easier to use, and more powerful than the original app. It’s better in every way and we’re adding new features all the time.
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index a3107af4b1..325bf570f5 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
index 8e62bdd305..647b205722 100644
--- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
@@ -13,12 +13,17 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
+ aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""),
)
}
-fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
- applicationName = "Element X",
- isEnabled = false,
- policyUrl = "https://element.io",
+fun aAnalyticsPreferencesState(
+ applicationName: String = "Element X",
+ isEnabled: Boolean = false,
+ policyUrl: String = "https://element.io",
+) = AnalyticsPreferencesState(
+ applicationName = applicationName,
+ isEnabled = isEnabled,
+ policyUrl = policyUrl,
eventSink = {}
)
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
index 2e7ee47736..70461f22d3 100644
--- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
@@ -36,11 +36,6 @@ fun AnalyticsPreferencesView(
id = R.string.screen_analytics_settings_help_us_improve,
state.applicationName
)
- val linkText = buildAnnotatedStringWithStyledPart(
- R.string.screen_analytics_settings_read_terms,
- R.string.screen_analytics_settings_read_terms_content_link,
- tagAndLink = LINK_TAG to state.policyUrl,
- )
Column(modifier) {
ListItem(
headlineContent = {
@@ -57,7 +52,14 @@ fun AnalyticsPreferencesView(
onEnabledChanged(!state.isEnabled)
}
)
- ListSupportingText(annotatedString = linkText)
+ if (state.policyUrl.isNotEmpty()) {
+ val linkText = buildAnnotatedStringWithStyledPart(
+ R.string.screen_analytics_settings_read_terms,
+ R.string.screen_analytics_settings_read_terms_content_link,
+ tagAndLink = LINK_TAG to state.policyUrl,
+ )
+ ListSupportingText(annotatedString = linkText)
+ }
}
}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
index d545d00951..39b99a9257 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
@@ -9,6 +9,7 @@ package io.element.android.features.analytics.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -36,6 +37,7 @@ class AnalyticsOptInPresenter @Inject constructor(
return AnalyticsOptInState(
applicationName = buildMeta.applicationName,
+ hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(),
eventSink = ::handleEvents
)
}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
index d7e99e56c1..a0913bdb4f 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
@@ -11,5 +11,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsOptInState(
val applicationName: String,
+ val hasPolicyLink: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit
)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
index c159b738a7..e6917c237e 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
@@ -14,10 +14,14 @@ open class AnalyticsOptInStateProvider @Inject constructor() : PreviewParameterP
override val values: Sequence
get() = sequenceOf(
aAnalyticsOptInState(),
+ aAnalyticsOptInState(hasPolicyLink = false),
)
}
-fun aAnalyticsOptInState() = AnalyticsOptInState(
+fun aAnalyticsOptInState(
+ hasPolicyLink: Boolean = true,
+) = AnalyticsOptInState(
applicationName = "Element X",
+ hasPolicyLink = hasPolicyLink,
eventSink = {}
)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
index 3e8b9d98f9..a6ae754944 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
@@ -95,25 +95,27 @@ private fun AnalyticsOptInHeader(
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
)
- val text = buildAnnotatedStringWithStyledPart(
- R.string.screen_analytics_prompt_read_terms,
- R.string.screen_analytics_prompt_read_terms_content_link,
- color = Color.Unspecified,
- underline = false,
- bold = true,
- tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
- )
- ClickableLinkText(
- annotatedString = text,
- onClick = { onClickTerms() },
- modifier = Modifier
- .padding(8.dp),
- style = ElementTheme.typography.fontBodyMdRegular
- .copy(
- color = ElementTheme.colors.textSecondary,
- textAlign = TextAlign.Center,
- )
- )
+ if (state.hasPolicyLink) {
+ val text = buildAnnotatedStringWithStyledPart(
+ R.string.screen_analytics_prompt_read_terms,
+ R.string.screen_analytics_prompt_read_terms_content_link,
+ color = Color.Unspecified,
+ underline = false,
+ bold = true,
+ tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
+ )
+ ClickableLinkText(
+ annotatedString = text,
+ onClick = { onClickTerms() },
+ modifier = Modifier
+ .padding(8.dp),
+ style = ElementTheme.typography.fontBodyMdRegular
+ .copy(
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ )
+ }
}
}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
index 8212cc6c88..6904734f26 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
@@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
@Composable
override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope()
- val isEnabled = analyticsService.getUserConsent()
- .collectAsState(initial = false)
+ val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) {
when (event) {
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
index 1353d5ab5f..e9319fd7ba 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
@@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isTrue()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isTrue()
}
}
@@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isFalse()
}
}
}
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
index 30b64eb8ab..8ba915f03c 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
@@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -35,7 +36,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
- assertThat(initialState.policyUrl).isNotEmpty()
+ assertThat(initialState.policyUrl).isEqualTo(AnalyticsConfig.POLICY_LINK)
}
}
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
index 9229681966..53e1b8c846 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
@@ -32,7 +32,7 @@ interface ElementCallEntryPoint {
* @param notificationChannelId The id of the notification channel to use for the call notification.
* @param textContent The text content of the notification. If null the default content from the system will be used.
*/
- fun handleIncomingCall(
+ suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts
index be332ec4fe..f0a9eedca0 100644
--- a/features/call/impl/build.gradle.kts
+++ b/features/call/impl/build.gradle.kts
@@ -1,3 +1,4 @@
+import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupAnvil
@@ -26,45 +27,35 @@ android {
}
defaultConfig {
- buildConfigField(
- type = "String",
+ buildConfigFieldStr(
name = "SENTRY_DSN",
- value = (System.getenv("ELEMENT_CALL_SENTRY_DSN")
+ value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn")
?: ""
- ).let { "\"$it\"" }
)
- buildConfigField(
- type = "String",
+ buildConfigFieldStr(
name = "POSTHOG_USER_ID",
- value = (System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
+ value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
?: readLocalProperty("features.call.posthog.userid")
?: ""
- ).let { "\"$it\"" }
)
- buildConfigField(
- type = "String",
+ buildConfigFieldStr(
name = "POSTHOG_API_HOST",
- value = (System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
+ value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
?: readLocalProperty("features.call.posthog.api.host")
?: ""
- ).let { "\"$it\"" }
)
- buildConfigField(
- type = "String",
+ buildConfigFieldStr(
name = "POSTHOG_API_KEY",
- value = (System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
+ value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
?: readLocalProperty("features.call.posthog.api.key")
?: ""
- ).let { "\"$it\"" }
)
- buildConfigField(
- type = "String",
+ buildConfigFieldStr(
name = "RAGESHAKE_URL",
- value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
+ value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url")
?: ""
- ).let { "\"$it\"" }
)
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
index 290bfe0824..009840743c 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
@@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
context.startActivity(IntentProvider.createIntent(context, callType))
}
- override fun handleIncomingCall(
+ override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
index ab6f07ce49..bbc0611083 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
@@ -16,6 +16,8 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
}
@Inject
lateinit var activeCallManager: ActiveCallManager
+
+ @Inject
+ lateinit var appCoroutineScope: CoroutineScope
+
override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return
context.bindings().inject(this)
- activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ appCoroutineScope.launch {
+ activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
index 47f5682f4c..6baffd8143 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
@@ -62,6 +62,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
+ private val appCoroutineScope: CoroutineScope,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor(
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
- loadUrl(
+ fetchRoomCallUrl(
inputs = callType,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
@@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor(
)
}
onDispose {
- activeCallManager.hungUpCall(callType)
+ appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
}
}
@@ -187,7 +188,7 @@ class CallScreenPresenter @AssistedInject constructor(
)
}
- private suspend fun loadUrl(
+ private suspend fun fetchRoomCallUrl(
inputs: CallType,
urlState: MutableState>,
callWidgetDriver: MutableState,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
index 9669285ce0..c5162d9a9e 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
@@ -24,9 +24,11 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var buildMeta: BuildMeta
+ @Inject
+ lateinit var appCoroutineScope: CoroutineScope
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
- activeCallManager.hungUpCall(callType = activeCall.callType)
+ appCoroutineScope.launch {
+ activeCallManager.hungUpCall(callType = activeCall.callType)
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 2a12117a48..fe266d1cf6 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -8,8 +8,11 @@
package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint
+import android.content.Context
+import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
@@ -17,6 +20,7 @@ import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
@@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@@ -55,25 +61,26 @@ interface ActiveCallManager {
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification.
*/
- fun registerIncomingCall(notificationData: CallNotificationData)
+ suspend fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the active call has been hung up. It will remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one.
*/
- fun hungUpCall(callType: CallType)
+ suspend fun hungUpCall(callType: CallType)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param callType The type of call that the user joined, either an external url one or a room one.
*/
- fun joinedCall(callType: CallType)
+ suspend fun joinedCall(callType: CallType)
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
+ @ApplicationContext context: Context,
private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
@@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor(
) : ActiveCallManager {
private var timedOutCallJob: Job? = null
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService()
+ ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
+ ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
+
override val activeCall = MutableStateFlow(null)
+ private val mutex = Mutex()
+
init {
observeRingingCall()
observeCurrentCall()
}
- override fun registerIncomingCall(notificationData: CallNotificationData) {
- if (activeCall.value != null) {
- displayMissedCallNotification(notificationData)
- Timber.w("Already have an active call, ignoring incoming call: $notificationData")
- return
- }
- activeCall.value = ActiveCall(
- callType = CallType.RoomCall(
- sessionId = notificationData.sessionId,
- roomId = notificationData.roomId,
- ),
- callState = CallState.Ringing(notificationData),
- )
+ override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
+ mutex.withLock {
+ if (activeCall.value != null) {
+ displayMissedCallNotification(notificationData)
+ Timber.w("Already have an active call, ignoring incoming call: $notificationData")
+ return
+ }
+ activeCall.value = ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = notificationData.sessionId,
+ roomId = notificationData.roomId,
+ ),
+ callState = CallState.Ringing(notificationData),
+ )
- timedOutCallJob = coroutineScope.launch {
- showIncomingCallNotification(notificationData)
+ timedOutCallJob = coroutineScope.launch {
+ showIncomingCallNotification(notificationData)
- // Wait for the ringing call to time out
- delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
- incomingCallTimedOut(displayMissedCallNotification = true)
+ // Wait for the ringing call to time out
+ delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
+ incomingCallTimedOut(displayMissedCallNotification = true)
+ }
+
+ // Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
+ if (activeWakeLock?.isHeld == false) {
+ activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
+ }
}
}
@@ -117,10 +138,13 @@ class DefaultActiveCallManager @Inject constructor(
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- fun incomingCallTimedOut(displayMissedCallNotification: Boolean) {
+ suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
+ if (activeWakeLock?.isHeld == true) {
+ activeWakeLock.release()
+ }
cancelIncomingCallNotification()
@@ -129,18 +153,24 @@ class DefaultActiveCallManager @Inject constructor(
}
}
- override fun hungUpCall(callType: CallType) {
+ override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) {
Timber.w("Call type $callType does not match the active call type, ignoring")
return
}
cancelIncomingCallNotification()
+ if (activeWakeLock?.isHeld == true) {
+ activeWakeLock.release()
+ }
timedOutCallJob?.cancel()
activeCall.value = null
}
- override fun joinedCall(callType: CallType) {
+ override suspend fun joinedCall(callType: CallType) = mutex.withLock {
cancelIncomingCallNotification()
+ if (activeWakeLock?.isHeld == true) {
+ activeWakeLock.release()
+ }
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
@@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor(
?.getRoom(callType.roomId)
?.roomInfoFlow
?.map {
+ Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
}
?: flowOf()
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
index 97d6dac427..86d9b80d65 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
@@ -20,16 +20,21 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
+import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
- fun `startCall - starts ElementCallActivity setup with the needed extras`() {
+ fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
+ @OptIn(ExperimentalCoroutinesApi::class)
@Test
- fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
+ fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
@@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest {
textContent = "textContent",
)
+ advanceTimeBy(1.seconds)
+
registerIncomingCallLambda.assertions().isCalledOnce()
}
- private fun createEntryPoint(
+ private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
index ce185bcdfc..5ec48ce6a1 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
@@ -44,12 +44,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
-class CallScreenPresenterTest {
+@OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -66,7 +68,8 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
- skipItems(1)
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
@@ -101,16 +104,23 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
+ advanceTimeBy(1.seconds)
skipItems(1)
+
joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem()
- assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
+ assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
+
+ // Wait until the WidgetDriver is loaded
+ skipItems(1)
+
+ assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
}
}
@@ -126,6 +136,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@@ -141,7 +154,6 @@ class CallScreenPresenterTest {
}
}
- @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
@@ -158,11 +170,15 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
+
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup)
- // Let background coroutines run
+ // Let background coroutines run and the widget drive be received
runCurrent()
assertThat(navigator.closeCalled).isTrue()
@@ -172,7 +188,6 @@ class CallScreenPresenterTest {
}
}
- @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
@@ -189,11 +204,16 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
+
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run
+ advanceTimeBy(1.seconds)
runCurrent()
assertThat(navigator.closeCalled).isTrue()
@@ -218,7 +238,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- skipItems(1)
+ // Give it time to load the URL and WidgetDriver
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@@ -235,7 +257,7 @@ class CallScreenPresenterTest {
}
""".trimIndent()
)
- skipItems(1)
+ skipItems(2)
val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue()
}
@@ -300,7 +322,8 @@ class CallScreenPresenterTest {
presenter.present()
}.test {
// Wait until the URL is loaded
- skipItems(1)
+ advanceTimeBy(1.seconds)
+ skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
@@ -329,6 +352,8 @@ class CallScreenPresenterTest {
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
+
+ cancelAndIgnoreRemainingEvents()
}
}
@@ -361,6 +386,7 @@ class CallScreenPresenterTest {
screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
+ appCoroutineScope = backgroundScope,
)
}
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index ea1357c0e9..ff53b1ff77 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -7,7 +7,9 @@
package io.element.android.features.call.utils
+import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
@@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
@@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
@@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest {
runCurrent()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
}
@@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
inCancellableScope {
@@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest {
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
@@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull()
+ assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest {
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
+ setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
@@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull()
+ assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@@ -284,12 +299,19 @@ class DefaultActiveCallManagerTest {
}
}
+ private fun setupShadowPowerManager() {
+ shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService()).apply {
+ setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
+ }
+ }
+
private fun CoroutineScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager(
+ context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
index 3024ce9f2f..90527370e7 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
@@ -11,6 +11,7 @@ import io.element.android.features.call.api.CallType
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 kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
@@ -20,15 +21,15 @@ class FakeActiveCallManager(
) : ActiveCallManager {
override val activeCall = MutableStateFlow(null)
- override fun registerIncomingCall(notificationData: CallNotificationData) {
+ override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData)
}
- override fun hungUpCall(callType: CallType) {
+ override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType)
}
- override fun joinedCall(callType: CallType) {
+ override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
}
diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
index bbd990df27..09a1269259 100644
--- a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
+++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
@@ -30,7 +30,7 @@ class FakeElementCallEntryPoint(
startCallResult(callType)
}
- override fun handleIncomingCall(
+ override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
index 5f629d51eb..29b1525f51 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
@@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
}
val createRoomConfigWithInvites: Flow = combine(
- selectedUserListDataStore.selectedUsers(),
+ selectedUserListDataStore.selectedUsers,
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index fd54b753d9..0025a64df3 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
- val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
+ val isKnockFeatureEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
+ }.collectAsState(initial = false)
val roomAddressValidity = remember {
mutableStateOf(RoomAddressValidity.Unknown)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
index c05ed770f9..ea8be45e6b 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
@@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
- val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
+ val isRoomDirectorySearchEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
+ }.collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
index ad9b18f624..32d5767cc6 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
@@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
- val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
+ val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState> by remember {
mutableStateOf(SearchBarResultState.Initial())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
index 3be9868c98..a500e3a05f 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
@@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
- private val selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
+ private val _selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
- if (!selectedUsers.value.contains(user)) {
- selectedUsers.tryEmit(selectedUsers.value.plus(user))
+ if (!_selectedUsers.value.contains(user)) {
+ _selectedUsers.tryEmit(_selectedUsers.value.plus(user))
}
}
fun removeUserFromSelection(user: MatrixUser) {
- selectedUsers.tryEmit(selectedUsers.value.minus(user))
+ _selectedUsers.tryEmit(_selectedUsers.value.minus(user))
}
- fun selectedUsers(): Flow> = selectedUsers
+ val selectedUsers = _selectedUsers.asStateFlow()
}
diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml
index 514c30e19d..43f67e429c 100644
--- a/features/createroom/impl/src/main/res/values-eu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-eu/translations.xml
@@ -18,4 +18,6 @@ Gelaren ezarpenetan aldatu dezakezu hobespena."
"Mintzagaia (aukerakoa)"
"Gelen direktorioa"
"Errorea gertatu da txata hasten saiatzean"
+ "Sartu…"
+ "Ez da gela aurkitu"
diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml
index 98ea87e622..9b79740a97 100644
--- a/features/createroom/impl/src/main/res/values-nb/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nb/translations.xml
@@ -18,4 +18,7 @@ Du kan endre dette når som helst i rominnstillingene."
"Emne (valgfritt)"
"Romkatalog"
"Det oppstod en feil når du prøvde å starte en chat"
+ "Ikke en gyldig adresse"
+ "Rom ikke funnet"
+ "f.eks. #rom-navn:matrix.org"
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
index 40d31e710d..3ed917b7bb 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -21,4 +21,10 @@ Możesz to zmienić w ustawieniach pokoju."
"Temat (opcjonalnie)"
"Katalog pokoi"
"Wystąpił błąd podczas próby rozpoczęcia czatu"
+ "Dołącz do pokoju za pomocą adresu"
+ "Nieprawidłowy adres"
+ "Wprowadź…"
+ "Znaleziono pasujący pokój"
+ "Nie znaleziono pokoju"
+ "np. #room-name:matrix.org"
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index 034701fec4..71a27dcaae 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded()
})
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 0409d73538..744053b976 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
@@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
}
private suspend fun needsAnalyticsOptIn(): Boolean {
- return analyticsService.didAskUserConsent().first().not()
+ return analyticsService.didAskUserConsentFlow.first().not()
}
private suspend fun shouldAskNotificationPermissions(): Boolean {
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
new file mode 100644
index 0000000000..682970ffe7
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+
+interface SeenInvitesStore {
+ /**
+ * Returns a flow of seen room IDs of invitation.
+ */
+ fun seenRoomIds(): Flow>
+
+ /**
+ * Mark the invitation as seen.
+ * Call this when the invitation details are shown to the user.
+ * @param roomId the room ID of the invitation to mark as seen.
+ */
+ suspend fun markAsSeen(roomId: RoomId)
+
+ /**
+ * Mark the invitation as unseen.
+ * Call this when the invitation has been accepted or declined.
+ * @param roomId the room ID of the invitation to mark as unseen.
+ */
+ suspend fun markAsUnSeen(roomId: RoomId)
+
+ /**
+ * Delete the store.
+ */
+ suspend fun clear()
+}
diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts
index 8c00ac3d23..7f052bea09 100644
--- a/features/invite/impl/build.gradle.kts
+++ b/features/invite/impl/build.gradle.kts
@@ -21,6 +21,7 @@ setupAnvil()
dependencies {
api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences)
+ implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
new file mode 100644
index 0000000000..3accc163b0
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.impl
+
+import android.content.Context
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStoreFile
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.androidutils.file.safeDelete
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+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.user.CurrentSessionIdHolder
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
+
+@SingleIn(SessionScope::class)
+@ContributesBinding(SessionScope::class)
+class DefaultSeenInvitesStore @Inject constructor(
+ @ApplicationContext context: Context,
+ currentSessionIdHolder: CurrentSessionIdHolder,
+ @SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
+ sessionObserver: SessionObserver,
+) : SeenInvitesStore {
+ private val sessionId: SessionId = currentSessionIdHolder.current
+
+ init {
+ sessionObserver.addListener(object : SessionListener {
+ override suspend fun onSessionCreated(userId: String) = Unit
+ override suspend fun onSessionDeleted(userId: String) {
+ if (sessionId.value == userId) {
+ clear()
+ }
+ }
+ })
+ }
+
+ private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
+ context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
+ }
+
+ private val store = PreferenceDataStoreFactory.create(
+ scope = sessionCoroutineScope,
+ migrations = emptyList(),
+ ) {
+ dataStoreFile
+ }
+
+ override fun seenRoomIds(): Flow> =
+ store.data.map { prefs ->
+ prefs[seenInvitesKey]
+ .orEmpty()
+ .map { RoomId(it) }
+ .toSet()
+ }
+
+ override suspend fun markAsSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
+ }
+ }
+
+ override suspend fun markAsUnSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
+ }
+ }
+
+ override suspend fun clear() {
+ dataStoreFile.safeDelete()
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
index 9f4a7ee848..e02642c32e 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient,
private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
@Composable
override fun present(): AcceptDeclineInviteState {
@@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
)
.onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ seenInvitesStore.markAsUnSeen(roomId)
}
.map { roomId }
}
@@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
+ seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction)
}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
index 870d3bba09..7feaff091e 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
@@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -20,6 +22,8 @@ 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.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - declining invite cancel flow`() = runTest {
- val presenter = createAcceptDeclineInvitePresenter()
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
- val presenter = createAcceptDeclineInvitePresenter(client = client)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ client = client,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents()
}
assert(declineInviteFailure).isCalledOnce()
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
}
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
},
ignoreUserResult = ignoreUserSuccess
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
- val presenter = createAcceptDeclineInvitePresenter(client = client)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ client = client,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
}
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.failure(RuntimeException("Failed to join room $roomIdOrAlias"))
}
- val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ joinRoomLambda = joinRoomFailure,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList()),
value(JoinedRoom.Trigger.Invite)
)
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
private fun anInviteData(
@@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit)
},
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter(
client = client,
joinRoom = FakeJoinRoom(joinRoomLambda),
notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
}
}
diff --git a/features/invite/test/build.gradle.kts b/features/invite/test/build.gradle.kts
new file mode 100644
index 0000000000..dc43ba00c3
--- /dev/null
+++ b/features/invite/test/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.invite.test"
+}
+
+dependencies {
+ implementation(libs.coroutines.core)
+ implementation(projects.libraries.matrix.api)
+ api(projects.features.invite.api)
+}
diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt
new file mode 100644
index 0000000000..25db72532e
--- /dev/null
+++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.test
+
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class InMemorySeenInvitesStore(
+ initialRoomIds: Set = emptySet(),
+) : SeenInvitesStore {
+ private val roomIds = MutableStateFlow(initialRoomIds)
+
+ override fun seenRoomIds(): Flow> = roomIds
+
+ override suspend fun markAsSeen(roomId: RoomId) {
+ roomIds.value += roomId
+ }
+
+ override suspend fun markAsUnSeen(roomId: RoomId) {
+ roomIds.value -= roomId
+ }
+
+ override suspend fun clear() {
+ roomIds.value = emptySet()
+ }
+}
diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts
index 65635f1907..ecf3843674 100644
--- a/features/joinroom/impl/build.gradle.kts
+++ b/features/joinroom/impl/build.gradle.kts
@@ -42,6 +42,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 09ec3c9a5f..9914afbded 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
@@ -67,6 +69,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter,
private val buildMeta: BuildMeta,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
interface Factory {
fun create(
@@ -82,7 +85,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
- val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
+ val roomInfo by remember {
+ matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
+ }.collectAsState(initial = Optional.empty())
val joinAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@@ -147,6 +152,10 @@ class JoinRoomPresenter @AssistedInject constructor(
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ LaunchedEffect(contentState) {
+ contentState.markRoomInviteAsSeen()
+ }
+
fun handleEvents(event: JoinRoomEvents) {
when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@@ -234,6 +243,12 @@ class JoinRoomPresenter @AssistedInject constructor(
forgetRoom.invoke(roomId)
}
}
+
+ private suspend fun ContentState.markRoomInviteAsSeen() {
+ if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
+ seenInvitesStore.markAsSeen(roomId)
+ }
+ }
}
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
index ff4cbbbc80..6a9bd559af 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
@@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
@@ -35,6 +36,7 @@ object JoinRoomModule {
forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter,
buildMeta: BuildMeta,
+ seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(
@@ -57,6 +59,7 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
+ seenInvitesStore = seenInvitesStore,
)
}
}
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index 1c146285c2..b226d53706 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.ForgetRoom
import io.element.android.features.joinroom.impl.di.KnockRoom
@@ -52,6 +54,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -111,14 +114,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary))
}
}
+ val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter(
- matrixClient = matrixClient
+ matrixClient = matrixClient,
+ seenInvitesStore = seenInvitesStore,
)
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
}
+ // Check that the roomId is stored in the seen invites store
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
}
}
@@ -759,7 +767,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
- acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }
+ acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
@@ -773,7 +782,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
- acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
+ acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
+ seenInvitesStore = seenInvitesStore,
)
}
diff --git a/features/knockrequests/impl/src/main/res/values-el/translations.xml b/features/knockrequests/impl/src/main/res/values-el/translations.xml
index 326227df48..d07b289382 100644
--- a/features/knockrequests/impl/src/main/res/values-el/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-el/translations.xml
@@ -4,7 +4,10 @@
"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"
"Αποδοχή όλων των αιτημάτων"
"Αποδοχή όλων"
+ "Δεν μπορέσαμε να δεχτούμε όλα τα αιτήματα. Θες να προσπαθήσεις ξανά;"
+ "Αποτυχία αποδοχής όλων των αιτημάτων"
"Αποδοχή όλων των αιτημάτων συμμετοχής"
+ "Αποτυχία αποδοχής αιτήματος"
"Γίνεται αποδοχή αιτήματος συμμετοχής"
"Ναι, απόρριψη και αποκλεισμός"
"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."
@@ -17,6 +20,7 @@
"Γίνεται απόρριψη αιτήματος συμμετοχής"
"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."
"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"
+ "Φόρτωση αιτημάτων συμμετοχής…"
"Αιτήματα συμμετοχής"
- "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"
diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
index 109878cffa..61e4ace265 100644
--- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
+++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt
@@ -7,7 +7,6 @@
package io.element.android.features.licenses.impl.list
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -33,10 +31,11 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
@@ -60,7 +59,7 @@ fun DependencyLicensesListView(
) {
if (state.licenses.isSuccess()) {
// Search field
- OutlinedTextField(
+ TextField(
value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = {
diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts
index 909a3c978a..d299887165 100644
--- a/features/location/api/build.gradle.kts
+++ b/features/location/api/build.gradle.kts
@@ -6,6 +6,7 @@
*/
import config.BuildTimeConfig
+import extension.buildConfigFieldStr
import extension.readLocalProperty
plugins {
@@ -16,10 +17,17 @@ plugins {
android {
namespace = "io.element.android.features.location.api"
+ buildFeatures {
+ buildConfig = true
+ }
+
defaultConfig {
- resValue(
- type = "string",
- name = "maptiler_api_key",
+ buildConfigFieldStr(
+ name = "MAPTILER_BASE_URL",
+ value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
+ )
+ buildConfigFieldStr(
+ name = "MAPTILER_API_KEY",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else {
@@ -28,9 +36,8 @@ android {
}
?: ""
)
- resValue(
- type = "string",
- name = "maptiler_light_map_id",
+ buildConfigFieldStr(
+ name = "MAPTILER_LIGHT_MAP_ID",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
} else {
@@ -40,9 +47,8 @@ android {
// fall back to maptiler's default light map.
?: "basic-v2"
)
- resValue(
- type = "string",
- name = "maptiler_dark_map_id",
+ buildConfigFieldStr(
+ name = "MAPTILER_DARK_MAP_ID",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
} else {
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
index e80386a9c5..382a65ae47 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
@@ -57,7 +57,7 @@ fun StaticMapView(
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
- val builder = remember { StaticMapUrlBuilder(context) }
+ val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerConfig.kt
deleted file mode 100644
index 9580280d74..0000000000
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerConfig.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.location.api.internal
-
-import android.content.Context
-import io.element.android.features.location.api.R
-
-internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
-
-internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
- true -> getString(R.string.maptiler_dark_map_id)
- false -> getString(R.string.maptiler_light_map_id)
-}
-
-internal val Context.apiKey: String
- get() = getString(R.string.maptiler_api_key)
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
index 20c0466e13..eb8d0ea1b7 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
@@ -7,7 +7,7 @@
package io.element.android.features.location.api.internal
-import android.content.Context
+import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
/**
@@ -16,14 +16,16 @@ import kotlin.math.roundToInt
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
+ private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
- constructor(context: Context) : this(
- apiKey = context.apiKey,
- lightMapId = context.mapId(darkMode = false),
- darkMapId = context.mapId(darkMode = true),
+ constructor() : this(
+ baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
+ apiKey = BuildConfig.MAPTILER_API_KEY,
+ lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
+ darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(
@@ -55,7 +57,7 @@ internal class MapTilerStaticMapUrlBuilder(
// image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness.
- return "$MAPTILER_BASE_URL/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
+ return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
}
override fun isServiceAvailable() = apiKey.isNotEmpty()
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
index 3146d589e4..db75b5e30a 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
@@ -9,21 +9,23 @@
package io.element.android.features.location.api.internal
-import android.content.Context
+import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
+ private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
- constructor(context: Context) : this(
- apiKey = context.apiKey,
- lightMapId = context.mapId(darkMode = false),
- darkMapId = context.mapId(darkMode = true),
+ constructor() : this(
+ baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
+ apiKey = BuildConfig.MAPTILER_API_KEY,
+ lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
+ darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
- return "$MAPTILER_BASE_URL/$mapId/style.json?key=$apiKey"
+ return "$baseUrl/$mapId/style.json?key=$apiKey"
}
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
index e52205c2f3..533ecf8d2d 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
@@ -7,8 +7,6 @@
package io.element.android.features.location.api.internal
-import android.content.Context
-
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
@@ -26,4 +24,4 @@ interface StaticMapUrlBuilder {
fun isServiceAvailable(): Boolean
}
-fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)
+fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
index 4d93a8d8bb..051b2448d4 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
@@ -7,10 +7,8 @@
package io.element.android.features.location.api.internal
-import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalContext
import io.element.android.compound.theme.ElementTheme
/**
@@ -24,7 +22,7 @@ interface TileServerStyleUriBuilder {
): String
}
-fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context)
+fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
@@ -33,9 +31,8 @@ fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = Map
*/
@Composable
fun rememberTileStyleUrl(): String {
- val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
- TileServerStyleUriBuilder(context).build(darkMode)
+ TileServerStyleUriBuilder().build(darkMode)
}
}
diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt
index b74cebf1e7..ba44426a70 100644
--- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt
+++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt
@@ -12,6 +12,7 @@ import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder(
+ baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@@ -25,6 +26,7 @@ class MapTilerStaticMapUrlBuilderTest {
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
+ baseUrl = "https://base.url",
apiKey = "",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@@ -44,7 +46,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 600,
density = 1f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
@@ -59,7 +61,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 900,
density = 1.5f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
@@ -74,7 +76,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1200,
density = 2f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
@@ -89,7 +91,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1800,
density = 3f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
@@ -104,7 +106,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048,
density = 1f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -116,7 +118,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096,
density = 1f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -128,7 +130,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048,
density = 2f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -140,7 +142,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096,
density = 2f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -152,7 +154,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MAX_VALUE,
density = 2f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
@@ -167,7 +169,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0,
density = 1f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -179,7 +181,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0,
density = 2f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
@@ -191,6 +193,6 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MIN_VALUE,
density = 1f,
)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
+ ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
}
}
diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
index f65c044540..dc53765836 100644
--- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
+++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
@@ -12,6 +12,7 @@ import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
+ baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@@ -21,13 +22,13 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() {
assertThat(
builder.build(darkMode = false)
- ).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey")
+ ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(darkMode = true)
- ).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey")
+ ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
}
}
diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts
index 8a47e77b1a..4c62bdff1a 100644
--- a/features/location/impl/build.gradle.kts
+++ b/features/location/impl/build.gradle.kts
@@ -49,7 +49,6 @@ dependencies {
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
- testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt
index 1a9359b301..e662ef8115 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt
@@ -8,17 +8,14 @@
package io.element.android.features.location.impl
import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.location.api.BuildConfig
import io.element.android.features.location.api.LocationService
-import io.element.android.features.location.api.R
import io.element.android.libraries.di.AppScope
-import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
-class DefaultLocationService @Inject constructor(
- private val stringProvider: StringProvider,
-) : LocationService {
+class DefaultLocationService @Inject constructor() : LocationService {
override fun isServiceAvailable(): Boolean {
- return stringProvider.getString(R.string.maptiler_api_key).isNotEmpty()
+ return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
}
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
index b059a88dbb..304f8eb1aa 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
@@ -11,12 +11,14 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
+import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
+import java.util.Locale
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
) : LocationActions {
override fun share(location: Location, label: String?) {
runCatching {
- val uri = Uri.parse(buildUrl(location, label))
+ val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
}
}
+// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
@VisibleForTesting
internal fun buildUrl(
location: Location,
label: String?,
urlEncoder: (String) -> String = Uri::encode
): String {
- // Ref: https://developer.android.com/guide/components/intents-common#ViewMap
- val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
- return if (label == null) {
- base
- } else {
- "%s (%s)".format(base, urlEncoder(label))
- }
+ // This is needed so the coordinates are formatted with a dot as decimal separator
+ val locale = Locale.ENGLISH
+ return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt
index 213ce52ac5..fd687dd938 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt
@@ -8,30 +8,15 @@
package io.element.android.features.location.impl
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.location.api.R
-import io.element.android.services.toolbox.test.strings.FakeStringProvider
+import io.element.android.features.location.api.BuildConfig
import org.junit.Test
class DefaultLocationServiceTest {
@Test
- fun `if apiKey is empty, isServiceAvailable should return false`() {
- val fakeStringProvider = FakeStringProvider(
- defaultResult = ""
+ fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
+ val locationService = DefaultLocationService()
+ assertThat(locationService.isServiceAvailable()).isEqualTo(
+ BuildConfig.MAPTILER_API_KEY.isNotEmpty()
)
- val locationService = DefaultLocationService(
- stringProvider = fakeStringProvider,
- )
- assertThat(locationService.isServiceAvailable()).isFalse()
- assertThat(fakeStringProvider.lastResIdParam).isEqualTo(R.string.maptiler_api_key)
- }
-
- @Test
- fun `if apiKey is not empty, isServiceAvailable should return true`() {
- val locationService = DefaultLocationService(
- stringProvider = FakeStringProvider(
- defaultResult = "aKey"
- )
- )
- assertThat(locationService.isServiceAvailable()).isTrue()
}
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
index 4a98e22167..5b584a39f8 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import org.junit.Test
import java.net.URLEncoder
+import java.util.Locale
internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
@@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
)
val actual = buildUrl(location, null, ::urlEncoder)
- val expected = "geo:0,0?q=1.234568,123.456789"
+ val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected)
}
@@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
assertThat(actual).isEqualTo(expected)
}
+
+ @Test
+ fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
+ val location = Location(
+ lat = 1.000001,
+ lon = 2.000001,
+ accuracy = 0f
+ )
+ // Set a locale with comma as decimal separator
+ Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
+
+ val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
+ val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
+
+ assertThat(actual).isEqualTo(expected)
+ }
}
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
index 808abf9150..ad77b20e60 100644
--- a/features/lockscreen/impl/build.gradle.kts
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -14,6 +14,10 @@ plugins {
android {
namespace = "io.element.android.features.lockscreen.impl"
+
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
}
setupAnvil()
@@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
@@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
@@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
index 62de1f902d..5c2b1fdd53 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
@@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
@Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
- val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ val isBiometricAllowed by remember {
+ lockScreenStore.isBiometricUnlockAllowed()
+ }.collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
index f9a2cd8767..97dd875b01 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
value = !lockScreenConfig.isPinMandatory && hasPinCode
}
}
- val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ val isBiometricEnabled by remember {
+ lockScreenStore.isBiometricUnlockAllowed()
+ }.collectAsState(initial = false)
var showRemovePinConfirmation by remember {
mutableStateOf(false)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
index 596c6b1b59..820b557423 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
@@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.ui.utils.time.digit
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -60,7 +68,22 @@ fun PinKeypad(
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column(
- modifier = modifier,
+ modifier = modifier.onKeyEvent { event ->
+ if (event.type == KeyEventType.KeyUp) {
+ val digitChar = event.digit
+ if (digitChar != null) {
+ onClick(PinKeypadModel.Number(digitChar))
+ true
+ } else if (event.key == Key.Backspace) {
+ onClick(PinKeypadModel.Back)
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ },
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
) {
@@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace,
- contentDescription = null,
+ contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
new file mode 100644
index 0000000000..e51b007312
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock.keypad
+
+import android.view.KeyEvent
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.isRoot
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performKeyInput
+import androidx.compose.ui.test.pressKey
+import androidx.compose.ui.test.requestFocus
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class PinKeypadTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on a number emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNode(hasText("1")).performClick()
+ eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
+ }
+
+ @Test
+ fun `clicking on the delete previous character button emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
+ eventsRecorder.assertSingle(PinKeypadModel.Back)
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun `typing using the hardware keyboard emits the expected events`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNodeWithText("1").requestFocus()
+ rule.onAllNodes(isRoot())[0].performKeyInput {
+ val keys = listOf(
+ Key.A,
+ Key.NumPad1,
+ Key.NumPad2,
+ Key.NumPad3,
+ Key.NumPad4,
+ Key.NumPad5,
+ Key.NumPad6,
+ Key.NumPad7,
+ Key.NumPad8,
+ Key.NumPad9,
+ Key.NumPad0,
+ Key(KeyEvent.KEYCODE_1),
+ Key(KeyEvent.KEYCODE_2),
+ Key(KeyEvent.KEYCODE_3),
+ Key(KeyEvent.KEYCODE_4),
+ Key(KeyEvent.KEYCODE_5),
+ Key(KeyEvent.KEYCODE_6),
+ Key(KeyEvent.KEYCODE_7),
+ Key(KeyEvent.KEYCODE_8),
+ Key(KeyEvent.KEYCODE_9),
+ Key(KeyEvent.KEYCODE_0),
+ Key.Backspace,
+ )
+ for (key in keys) {
+ pressKey(key)
+ }
+ }
+ eventsRecorder.assertList(
+ listOf(
+ // Note that the first key is not a number, but a letter so it's ignored as input
+ // Then we have the numpad keys
+ PinKeypadModel.Number('1'),
+ PinKeypadModel.Number('2'),
+ PinKeypadModel.Number('3'),
+ PinKeypadModel.Number('4'),
+ PinKeypadModel.Number('5'),
+ PinKeypadModel.Number('6'),
+ PinKeypadModel.Number('7'),
+ PinKeypadModel.Number('8'),
+ PinKeypadModel.Number('9'),
+ PinKeypadModel.Number('0'),
+ // And the normal keys from the number row in the keyboard
+ PinKeypadModel.Number('1'),
+ PinKeypadModel.Number('2'),
+ PinKeypadModel.Number('3'),
+ PinKeypadModel.Number('4'),
+ PinKeypadModel.Number('5'),
+ PinKeypadModel.Number('6'),
+ PinKeypadModel.Number('7'),
+ PinKeypadModel.Number('8'),
+ PinKeypadModel.Number('9'),
+ PinKeypadModel.Number('0'),
+ PinKeypadModel.Back,
+ )
+ )
+ }
+
+ private fun AndroidComposeTestRule.setPinKeyPad(
+ onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
+ ) {
+ setContent {
+ PinKeypad(
+ onClick = onClick,
+ maxWidth = 1000.dp,
+ maxHeight = 1000.dp,
+ )
+ }
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
index 3f9c368a1d..3e1fc1853c 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
@@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
defaultAccountProvider
)
- fun flow(): StateFlow {
- return accountProvider.asStateFlow()
- }
+ val flow: StateFlow = accountProvider.asStateFlow()
fun reset() {
accountProvider.tryEmit(defaultAccountProvider)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
index eb5a8fee67..99b029a928 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Composable
override fun present(): ConfirmAccountProviderState {
- val accountProvider by accountProviderDataSource.flow().collectAsState()
+ val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState> = remember {
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
index 027036059d..c937cf9d48 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
@@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
val parser = Json { ignoreUnknownKeys = true }
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
val userId = response.userId ?: error("No user ID in response")
- val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
+ val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
val accessToken = response.accessToken ?: error("No access token in response")
val deviceId = response.deviceId ?: error("No device ID in response")
return ExternalSession(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
index ea60dc66f0..3a12715f6e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
@@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
- val accountProvider by accountProviderDataSource.flow().collectAsState()
+ val accountProvider by accountProviderDataSource.flow.collectAsState()
fun handleEvents(event: LoginPasswordEvents) {
when (event) {
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
index b0570cee93..e9a8595f3b 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
@@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
@@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
val sut = AccountProviderDataSource(FakeEnterpriseService(
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
))
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
@@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
sut.userSelection(AccountProvider(url = "https://example.com"))
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
index c26f3a7407..3d1cacd89c 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
}
.collectAsState(initial = BackupUploadState.Unknown)
+ var waitingForALongTime by remember { mutableStateOf(false) }
+ LaunchedEffect(backupUploadState) {
+ if (backupUploadState is BackupUploadState.Waiting) {
+ delay(2_000)
+ waitingForALongTime = true
+ } else {
+ waitingForALongTime = false
+ }
+ }
+
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
@@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
recoveryState = recoveryState,
backupUploadState = backupUploadState,
+ waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
index 2f84c837c6..584d7a0ac9 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
@@ -18,6 +18,7 @@ data class LogoutState(
val doesBackupExistOnServer: Boolean,
val recoveryState: RecoveryState,
val backupUploadState: BackupUploadState,
+ val waitingForALongTime: Boolean,
val logoutAction: AsyncAction,
val eventSink: (LogoutEvents) -> Unit,
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
index 12e83fc713..a55a9afb96 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
@@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider {
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
// Last session no backup
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
+ aLogoutState(
+ isLastDevice = false,
+ backupUploadState = BackupUploadState.Waiting,
+ ),
+ aLogoutState(
+ isLastDevice = false,
+ backupUploadState = BackupUploadState.Waiting,
+ waitingForALongTime = true,
+ ),
)
}
@@ -38,6 +47,7 @@ fun aLogoutState(
doesBackupExistOnServer: Boolean = true,
recoveryState: RecoveryState = RecoveryState.ENABLED,
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
+ waitingForALongTime: Boolean = false,
logoutAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (LogoutEvents) -> Unit = {},
) = LogoutState(
@@ -46,6 +56,7 @@ fun aLogoutState(
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
backupUploadState = backupUploadState,
+ waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction,
eventSink = eventSink,
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
index f8e5a428c2..321879b009 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
@@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
@Composable
private fun Content(
state: LogoutState,
+ modifier: Modifier = Modifier,
) {
- if (state.backupUploadState is BackupUploadState.Uploading) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 60.dp, start = 20.dp, end = 20.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- LinearProgressIndicator(
- modifier = Modifier.fillMaxWidth(),
- progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
- trackColor = ElementTheme.colors.progressIndicatorTrackColor,
- )
- Text(
- modifier = Modifier.align(Alignment.End),
- text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
- style = ElementTheme.typography.fontBodySmRegular,
- )
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 60.dp, start = 20.dp, end = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ when (state.backupUploadState) {
+ is BackupUploadState.Uploading -> {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
+ )
+ Text(
+ modifier = Modifier.align(Alignment.End),
+ text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ BackupUploadState.Waiting -> {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
+ )
+ if (state.waitingForALongTime) {
+ Text(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ text = stringResource(CommonStrings.common_please_check_internet_connection),
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ }
+ else -> Unit
}
}
}
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
index d054556f87..61a1f1371d 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
@@ -44,6 +44,7 @@ class LogoutPresenterTest {
assertThat(initialState.doesBackupExistOnServer).isTrue()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ assertThat(initialState.waitingForALongTime).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@@ -66,6 +67,34 @@ class LogoutPresenterTest {
}
}
+ @Test
+ fun `present - initial state - waiting a long time`() = runTest {
+ val encryptionService = FakeEncryptionService()
+ encryptionService.givenWaitForBackupUploadSteadyStateFlow(
+ flow {
+ emit(BackupUploadState.Waiting)
+ delay(3_000)
+ }
+ )
+ val presenter = createLogoutPresenter(
+ encryptionService = encryptionService
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.waitingForALongTime).isFalse()
+ assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ val waitingState = awaitItem()
+ assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
+ assertThat(initialState.waitingForALongTime).isFalse()
+ skipItems(1)
+ val waitingALongTimeState = awaitItem()
+ assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
+ assertThat(waitingALongTimeState.waitingForALongTime).isTrue()
+ }
+ }
+
@Test
fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService()
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
index 5071613e58..607283792f 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
@@ -8,12 +8,11 @@
package io.element.android.features.messages.api.timeline
import androidx.compose.runtime.Composable
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
interface HtmlConverterProvider {
@Composable
- fun Update(currentUserId: UserId)
+ fun Update()
fun provide(): HtmlConverter
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 09bc3a287f..6feaa23b62 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -57,6 +57,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
@@ -73,17 +74,19 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
-import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
+import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -100,11 +103,14 @@ class MessagesFlowNode @AssistedInject constructor(
private val locationService: LocationService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
+ private val roomNamesCache: RoomNamesCache,
+ private val mentionSpanUpdater: MentionSpanUpdater,
private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
+ private val coroutineDispatchers: CoroutineDispatchers,
) : BaseFlowNode(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialTarget.toNavTarget(),
@@ -172,13 +178,29 @@ class MessagesFlowNode @AssistedInject constructor(
timelineController.close()
}
)
+ setupCacheUpdaters()
+
+ pinnedEventsTimelineProvider.launchIn(lifecycleScope)
+ }
+
+ private fun setupCacheUpdaters() {
room.membersStateFlow
.onEach { membersState ->
- roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
+ withContext(coroutineDispatchers.computation) {
+ roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
+ }
}
.launchIn(lifecycleScope)
- pinnedEventsTimelineProvider.launchIn(lifecycleScope)
+ matrixClient.roomListService
+ .allRooms
+ .summaries
+ .onEach {
+ withContext(coroutineDispatchers.computation) {
+ roomNamesCache.replace(it)
+ }
+ }
+ .launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -465,10 +487,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
- mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
+ mentionSpanTheme.updateStyles()
CompositionLocalProvider(
- LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
- LocalMentionSpanTheme provides mentionSpanTheme,
+ LocalMentionSpanUpdater provides mentionSpanUpdater
) {
BackstackWithOverlayBox(modifier)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 405bc2c536..b5a1842212 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -128,7 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
@Composable
override fun present(): MessagesState {
- htmlConverterProvider.Update(currentUserId = room.sessionId)
+ htmlConverterProvider.Update()
val roomInfo by room.roomInfoFlow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
@@ -183,7 +182,7 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
}
}
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index b8fb44dca2..87134ac7d0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -484,14 +483,15 @@ private fun MessagesViewTopBar(
BackButton(onClick = onBackClick)
},
title = {
+ val roundedCornerShape = RoundedCornerShape(8.dp)
Row(
+ modifier = Modifier
+ .clip(roundedCornerShape)
+ .clickable { onRoomDetailsClick() },
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- val roundedCornerShape = RoundedCornerShape(8.dp)
- val titleModifier = Modifier
- .clip(roundedCornerShape)
- .clickable { onRoomDetailsClick() }
+ val titleModifier = Modifier.weight(1f, fill = false)
if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow(
roomName = roomName,
@@ -509,7 +509,6 @@ private fun MessagesViewTopBar(
when (dmUserIdentityState) {
IdentityState.Verified -> {
Icon(
- modifier = Modifier.requiredWidthIn(min = 16.dp),
imageVector = CompoundIcons.Verified(),
tint = ElementTheme.colors.iconSuccessPrimary,
contentDescription = null,
@@ -517,7 +516,6 @@ private fun MessagesViewTopBar(
}
IdentityState.VerificationViolation -> {
Icon(
- modifier = Modifier.requiredWidthIn(min = 16.dp),
imageVector = CompoundIcons.ErrorSolid(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index 5274dcbc22..2ca03e09ea 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
mutableStateOf(ActionListState.Target.None)
}
- val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
+ val isDeveloperModeEnabled by remember {
+ appPreferencesStore.isDeveloperModeEnabledFlow()
+ }.collectAsState(initial = false)
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index e7160d26dc..90bc3d0427 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
- val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
- val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
+ val allowCaption by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation)
+ }.collectAsState(initial = false)
+ val showCaptionCompatibilityWarning by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning)
+ }.collectAsState(initial = false)
var useSendQueue by remember { mutableStateOf(false) }
var preprocessMediaJob by remember { mutableStateOf(null) }
@@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = !useSendQueue,
+ replyParameters = null,
)
}
}
@@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption: String?,
sendActionState: MutableState,
dismissAfterSend: Boolean,
+ replyParameters: ReplyParameters?,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
@@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
).getOrThrow()
}.fold(
onSuccess = {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
index ca9faddfee..8b66fd9e62 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
@@ -196,6 +196,7 @@ private fun AttachmentsPreviewBottomActions(
onDeleteVoiceMessage = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
+ resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onError = {},
onTyping = {},
onSelectRichContent = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 8489139bb1..32142734e1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -47,16 +47,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
-import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
-import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -65,7 +64,6 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@@ -119,7 +117,6 @@ class MessageComposerPresenter @AssistedInject constructor(
private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
- private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter {
@AssistedFactory
@@ -181,7 +178,9 @@ class MessageComposerPresenter @AssistedInject constructor(
}
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
- val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
+ val sendTypingNotifications by remember {
+ sessionPreferencesStore.isSendTypingNotificationsEnabled()
+ }.collectAsState(initial = true)
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
@@ -331,7 +330,6 @@ class MessageComposerPresenter @AssistedInject constructor(
markdownTextEditorState.insertSuggestion(
resolvedSuggestion = event.resolvedSuggestion,
mentionSpanProvider = mentionSpanProvider,
- permalinkBuilder = permalinkBuilder,
)
suggestionSearchTrigger.value = null
}
@@ -344,23 +342,24 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
- val mentionSpanTheme = LocalMentionSpanTheme.current
- val resolveMentionDisplay = remember(mentionSpanTheme) {
+ val resolveMentionDisplay = remember {
{ text: String, url: String ->
- val permalinkData = permalinkParser.parse(url)
- if (permalinkData is PermalinkData.UserLink) {
- val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
- val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)
- mentionSpan.update(mentionSpanTheme)
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
+ if (mentionSpan != null) {
TextDisplay.Custom(mentionSpan)
} else {
- val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
- mentionSpan.update(mentionSpanTheme)
- TextDisplay.Custom(mentionSpan)
+ TextDisplay.Plain
}
}
}
+ val resolveAtRoomMentionDisplay = remember {
+ {
+ val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
+ TextDisplay.Custom(mentionSpan)
+ }
+ }
+
return MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
@@ -371,6 +370,7 @@ class MessageComposerPresenter @AssistedInject constructor(
canCreatePoll = canCreatePoll.value,
suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay,
+ resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
eventSink = { handleEvents(it) },
)
}
@@ -400,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
.stateIn(this, SharingStarted.Lazily, emptyList())
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
- val result = suggestionsProcessor.process(
- suggestion = suggestion,
- roomMembersState = roomMembersState,
- roomAliasSuggestions = roomAliasSuggestions,
- currentUserId = currentUserId,
- canSendRoomMention = ::canSendRoomMention,
- )
- suggestions.clear()
- suggestions.addAll(result)
- }
+ val result = suggestionsProcessor.process(
+ suggestion = suggestion,
+ roomMembersState = roomMembersState,
+ roomAliasSuggestions = roomAliasSuggestions,
+ currentUserId = currentUserId,
+ canSendRoomMention = ::canSendRoomMention,
+ )
+ suggestions.clear()
+ suggestions.addAll(result)
+ }
.collect()
}
}
@@ -453,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
- replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
+ with(capturedMode) {
+ replyMessage(
+ body = message.markdown,
+ htmlBody = message.html,
+ intentionalMentions = message.intentionalMentions,
+ replyParameters = ReplyParameters(
+ inReplyToEventId = eventId,
+ enforceThreadReply = inThread,
+ // This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
+ replyWithinThread = false,
+ ),
+ )
+ }
}
}
}
@@ -640,8 +652,8 @@ class MessageComposerPresenter @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else {
val markdown = richTextEditorState.messageMarkdown
- val pilliefiedMarkdown = pillificationHelper.pillify(markdown)
- markdownTextEditorState.text.update(pilliefiedMarkdown, true)
+ val markdownWithMentions = pillificationHelper.pillify(markdown, false)
+ markdownTextEditorState.text.update(markdownWithMentions, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
@@ -709,7 +721,7 @@ class MessageComposerPresenter @AssistedInject constructor(
if (content.isEmpty()) {
markdownTextEditorState.selection = IntRange.EMPTY
}
- val pillifiedContent = pillificationHelper.pillify(content)
+ val pillifiedContent = pillificationHelper.pillify(content, false)
markdownTextEditorState.text.update(pillifiedContent, true)
if (requestFocus) {
markdownTextEditorState.requestFocusAction()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index d86ba8d98c..1f1f80b203 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -25,5 +25,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean,
val suggestions: ImmutableList,
val resolveMentionDisplay: (String, String) -> TextDisplay,
+ val resolveAtRoomMentionDisplay: () -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index c0ea895618..590cfd20cf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -43,5 +43,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
+ resolveAtRoomMentionDisplay = { TextDisplay.Plain },
eventSink = eventSink,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index bfb51f4a87..42c68255e8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -113,6 +113,7 @@ internal fun MessageComposerView(
onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived,
resolveMentionDisplay = state.resolveMentionDisplay,
+ resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
onError = ::onError,
onTyping = ::onTyping,
onSelectRichContent = ::sendUri,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
index 8cdf5a1626..78e763fcc2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
@@ -16,11 +16,9 @@ import androidx.compose.ui.platform.LocalInspectionMode
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.core.bool.orFalse
-import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
@@ -29,42 +27,45 @@ import io.element.android.wysiwyg.utils.HtmlConverter
import uniffi.wysiwyg_composer.newMentionDetector
import javax.inject.Inject
-@ContributesBinding(SessionScope::class)
-@SingleIn(SessionScope::class)
+@ContributesBinding(RoomScope::class)
+@SingleIn(RoomScope::class)
class DefaultHtmlConverterProvider @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
) : HtmlConverterProvider {
private val htmlConverter: MutableState = mutableStateOf(null)
@Composable
- override fun Update(currentUserId: UserId) {
+ override fun Update() {
val isInEditMode = LocalInspectionMode.current
val mentionDetector = remember(isInEditMode) {
if (isInEditMode) null else newMentionDetector()
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
- val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current
- htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
+ htmlConverter.value = remember(editorStyle) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
- val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")
- mentionSpan.update(mentionSpanTheme)
+ val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
return TextDisplay.Custom(mentionSpan)
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
- mentionSpan.update(mentionSpanTheme)
- return TextDisplay.Custom(mentionSpan)
+ return if (mentionSpan != null) {
+ TextDisplay.Custom(mentionSpan)
+ } else {
+ TextDisplay.Plain
+ }
}
},
isEditor = false,
- isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
+ isMention = { _, url ->
+ mentionDetector?.isMention(url).orFalse()
+ }
).apply {
configureWith(editorStyle)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 4061be78c1..6ac01c990b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
val messageShield: MutableState = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
- val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
- val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
- val isLive by timelineController.isLive().collectAsState(initial = true)
+ val isSendPublicReadReceiptsEnabled by remember {
+ sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
+ }.collectAsState(initial = true)
+ val renderReadReceipts by remember {
+ sessionPreferencesStore.isRenderReadReceiptsEnabled()
+ }.collectAsState(initial = true)
+ val isLive by remember {
+ timelineController.isLive()
+ }.collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) {
when (event) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index ae17f26898..1b0a4df559 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@@ -93,6 +94,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch
@@ -314,6 +316,7 @@ private fun TimelineItemEventRowContent(
event.senderId,
event.senderProfile,
event.senderAvatar,
+ onUserDataClick,
Modifier
.constrainAs(sender) {
top.linkTo(parent.top)
@@ -321,13 +324,7 @@ private fun TimelineItemEventRowContent(
start.linkTo(parent.start)
}
.padding(horizontal = 16.dp)
- .zIndex(1f)
- .clickable(onClick = onUserDataClick)
- // This is redundant when using talkback
- .clearAndSetSemantics {
- invisibleToUser()
- testTag = TestTags.timelineItemSenderInfo.value
- }
+ .zIndex(1f),
)
}
@@ -425,13 +422,31 @@ private fun MessageSenderInformation(
senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData,
+ onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id)
- Row(modifier = modifier) {
- Avatar(senderAvatar)
- Spacer(modifier = Modifier.width(4.dp))
+ Row(
+ modifier = modifier
+ // Add external clickable modifier with no indicator so the touch target is larger than just the display name
+ .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
+ .clearAndSetSemantics {
+ invisibleToUser()
+ }
+ ) {
+ Avatar(
+ modifier = Modifier
+ .testTag(TestTags.timelineItemSenderAvatar)
+ .clip(CircleShape)
+ .clickable(onClick = onClick),
+ avatarData = senderAvatar,
+ )
SenderName(
+ modifier = Modifier
+ .testTag(TestTags.timelineItemSenderName)
+ .clip(RoundedCornerShape(6.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 4.dp),
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
index 8933538b8a..0555670e46 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
@@ -32,7 +32,7 @@ internal fun TimelineItemEventRowTimestampPreview(
event = event.copy(
content = event.content.copy(
body = str,
- pillifiedBody = str,
+ formattedBody = str,
),
reactionsState = aTimelineItemReactions(count = 0),
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
index b8256ba402..fcaac65e82 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -41,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
-@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
onSelectEmoji: (Emoji) -> Unit,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
index aed72254c6..d496b94f3b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
@@ -7,16 +7,13 @@
package io.element.android.features.messages.impl.timeline.components.event
-import android.text.SpannableString
+import android.text.SpannedString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -32,14 +29,8 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
-import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
-import io.element.android.libraries.textcomposer.mentions.MentionSpan
-import io.element.android.libraries.textcomposer.mentions.getMentionSpans
-import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@@ -51,7 +42,7 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
- val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) &&
+ val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
@@ -61,10 +52,10 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides textStyle
) {
- val body = getTextWithResolvedMentions(content)
+ val text = getTextWithResolvedMentions(content)
Box(modifier.semantics { contentDescription = content.plainText }) {
EditorStyledText(
- text = body,
+ text = text,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
@@ -78,36 +69,9 @@ fun TimelineItemTextView(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
- val userProfileCache = LocalRoomMemberProfilesCache.current
- val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
- val mentionSpanTheme = LocalMentionSpanTheme.current
- val formattedBody = content.formattedBody ?: content.pillifiedBody
- val textWithMentions = remember(formattedBody, mentionSpanTheme, lastCacheUpdate) {
- updateMentionSpans(formattedBody, userProfileCache)
- mentionSpanTheme.updateMentionStyles(formattedBody)
- formattedBody
- }
- return SpannableString(textWithMentions)
-}
-
-private fun updateMentionSpans(text: CharSequence, cache: RoomMemberProfilesCache): Boolean {
- var changedContents = false
- for (mentionSpan in text.getMentionSpans()) {
- when (mentionSpan.type) {
- MentionSpan.Type.USER -> {
- val displayName = cache.getDisplayName(UserId(mentionSpan.rawValue)) ?: mentionSpan.rawValue
- if (mentionSpan.text != displayName) {
- changedContents = true
- mentionSpan.text = displayName
- }
- }
- // There's no need to do anything for `@room` pills
- MentionSpan.Type.EVERYONE -> Unit
- // Nothing yet for room mentions
- MentionSpan.Type.ROOM -> Unit
- }
- }
- return changedContents
+ val mentionSpanUpdater = LocalMentionSpanUpdater.current
+ val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
+ return SpannedString.valueOf(bodyWithResolvedMentions)
}
@PreviewsDayNight
@@ -126,7 +90,7 @@ internal fun TimelineItemTextViewPreview(
@Composable
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
val content = aTimelineItemTextContent(
- pillifiedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.")
+ formattedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.")
)
TimelineItemTextView(
content = content,
@@ -139,7 +103,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
@Composable
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
val content = aTimelineItemTextContent(
- pillifiedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!")
+ formattedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!")
)
TimelineItemTextView(
content = content,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
index 12f325a20d..b1aa726628 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.reactionsummary
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -94,7 +93,6 @@ fun ReactionSummaryView(
}
}
-@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ReactionSummaryViewContent(
summary: ReactionSummaryState.Summary,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
index 701dd93fc7..80d594d4d7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
@@ -42,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.testtags.TestTags
-import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@@ -60,7 +60,6 @@ fun TimelineItemReadReceiptView(
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
- .testTag(TestTags.messageReadReceipts)
.clip(RoundedCornerShape(4.dp))
.clickable {
onReadReceiptsClick()
@@ -135,6 +134,7 @@ private fun ReadReceiptsAvatars(
Row(
modifier = modifier
.clearAndSetSemantics {
+ testTag = TestTags.messageReadReceipts.value
contentDescription = receiptDescription
},
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index d7d267ce48..f3b54dae53 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -69,13 +69,16 @@ class TimelineItemContentMessageFactory @Inject constructor(
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
+ val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
+ emoteBody
+ ).safeLinkify()
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
),
- formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
+ formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
@@ -123,10 +126,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
- pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
- plainText = body,
- formattedBody = null,
+ formattedBody = body,
isEdited = content.isEdited,
)
} else {
@@ -219,20 +220,26 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
+ val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
+ body
+ ).safeLinkify()
+ val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent(
body = body,
- htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
- formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
+ htmlDocument = htmlDocument,
+ formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
+ val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
+ body
+ ).safeLinkify()
TimelineItemTextContent(
body = body,
- pillifiedBody = textPillificationHelper.pillify(body).safeLinkify(),
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
- formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
+ formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
@@ -240,9 +247,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
- pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
- formattedBody = body.withLinks(),
+ formattedBody = textPillificationHelper.pillify(body).safeLinkify(),
isEdited = content.isEdited,
)
}
@@ -263,6 +269,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body.trimEnd())
+ .let { textPillificationHelper.pillify(it) }
.safeLinkify()
return if (prefix != null) {
buildSpannedString {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
index 82cd5a3f39..dc04e5b269 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
@@ -12,11 +12,10 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
- override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
- override val plainText: String = htmlDocument?.toPlainText() ?: body,
- override val formattedBody: CharSequence?,
+ override val formattedBody: CharSequence,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
+ override val plainText: String = htmlDocument?.toPlainText() ?: body
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
index 09484ac7e3..36c32222be 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
@@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import org.jsoup.nodes.Document
class TimelineItemEventContentProvider : PreviewParameterProvider {
override val values = sequenceOf(
@@ -58,35 +59,46 @@ class TimelineItemTextBasedContentProvider : PreviewParameterProvider {
@Composable
override fun present(): TimelineProtectionState {
- val hideMediaContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false)
+ val hideMediaContent by remember {
+ appPreferencesStore.doesHideImagesAndVideosFlow()
+ }.collectAsState(initial = false)
var allowedEvents by remember { mutableStateOf>(setOf()) }
val protectionState by remember(hideMediaContent) {
derivedStateOf {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
index 35759ade74..4dfc02a2a1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
@@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
) : Presenter {
@Composable
override fun present(): TypingNotificationState {
- val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
+ val renderTypingNotifications by remember {
+ sessionPreferencesStore.isRenderTypingNotificationsEnabled()
+ }.collectAsState(initial = true)
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
if (renderTypingNotifications) {
observeRoomTypingMembers()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
index c6dc27870b..458c27061c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
@@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.utils
import android.text.Spannable
import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.style.URLSpan
+import android.util.Patterns
import androidx.core.text.getSpans
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
@@ -16,63 +19,90 @@ import io.element.android.libraries.matrix.api.core.MatrixPatternType
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
-import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
-import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.getMentionSpans
+import io.element.android.wysiwyg.view.spans.CodeBlockSpan
+import io.element.android.wysiwyg.view.spans.InlineCodeSpan
import javax.inject.Inject
interface TextPillificationHelper {
- fun pillify(text: CharSequence): CharSequence
+ fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence
}
@ContributesBinding(RoomScope::class)
class DefaultTextPillificationHelper @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider,
- private val permalinkBuilder: PermalinkBuilder,
private val permalinkParser: PermalinkParser,
- private val roomMemberProfilesCache: RoomMemberProfilesCache,
+ private val permalinkBuilder: PermalinkBuilder,
) : TextPillificationHelper {
@Suppress("LoopWithTooManyJumpStatements")
- override fun pillify(text: CharSequence): CharSequence {
- val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
- if (matches.isEmpty()) return text
+ override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
+ return SpannableStringBuilder(text).apply {
+ pillifyMatrixPatterns(this)
+ if (pillifyPermalinks) {
+ pillifyPermalinks(this)
+ }
+ }
+ }
- val spannable = SpannableStringBuilder(text)
+ private fun pillifyMatrixPatterns(text: SpannableStringBuilder) {
+ val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
+ if (matches.isEmpty()) return
for (match in matches) {
+ if (!text.canPillify(match.start, match.end)) continue
when (match.type) {
MatrixPatternType.USER_ID -> {
- val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
- if (!mentionSpanExists) {
- val userId = UserId(match.value)
- val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue
- val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
- roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it }
- spannable.replace(match.start, match.end, "@ ")
- spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ val userId = UserId(match.value)
+ val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
+ text.replace(match.start, match.end, "@ ")
+ text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink ->
+ // Also add a URLSpan in case of raw user id so it can be clicked
+ val urlSpan = URLSpan(permalink)
+ text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MatrixPatternType.ROOM_ALIAS -> {
- val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
- if (!mentionSpanExists) {
- val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue
- val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
- spannable.replace(match.start, match.end, "@ ")
- spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ val roomAlias = RoomAlias(match.value)
+ val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
+ text.replace(match.start, match.end, "@ ")
+ text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink ->
+ // Also add a URLSpan in case of raw room alias so it can be clicked
+ val urlSpan = URLSpan(permalink)
+ text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
MatrixPatternType.AT_ROOM -> {
- val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
- if (!mentionSpanExists) {
- val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "")
- spannable.replace(match.start, match.end, "@ ")
- spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
- }
+ val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
+ text.replace(match.start, match.end, "@ ")
+ text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
else -> Unit
}
}
- return spannable
+ }
+
+ private fun pillifyPermalinks(text: SpannableStringBuilder) {
+ for (match in Patterns.WEB_URL.toRegex().findAll(text)) {
+ val start = match.range.first
+ val end = match.range.last + 1
+ if (!text.canPillify(start, end)) continue
+ val url = text.substring(match.range)
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, url)
+ if (mentionSpan != null) {
+ text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+ }
+
+ private fun Spanned.canPillify(start: Int, end: Int): Boolean {
+ if (getMentionSpans(start, end).isNotEmpty()) return false
+ if (getSpans(start, end).isNotEmpty()) return false
+ if (getSpans(start, end).isNotEmpty()) return false
+ return true
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt
index 1f44d3dd1f..8a05770942 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt
@@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings
@@ -35,11 +36,6 @@ import javax.inject.Inject
class DefaultMessageSummaryFormatter @Inject constructor(
@ApplicationContext private val context: Context,
) : MessageSummaryFormatter {
- companion object {
- // Max characters to display in the summary message. This works around https://github.com/element-hq/element-x-android/issues/2105
- private const val MAX_SAFE_LENGTH = 500
- }
-
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.plainText
@@ -58,6 +54,8 @@ class DefaultMessageSummaryFormatter @Inject constructor(
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemCallNotifyContent -> context.getString(CommonStrings.common_call_started)
- }.take(MAX_SAFE_LENGTH)
+ }
+ // Truncate the message to a safe length to avoid crashes in Compose
+ .toSafeLength()
}
}
diff --git a/features/messages/impl/src/main/res/values-eu/translations.xml b/features/messages/impl/src/main/res/values-eu/translations.xml
index cfd41379f8..34d0a114ee 100644
--- a/features/messages/impl/src/main/res/values-eu/translations.xml
+++ b/features/messages/impl/src/main/res/values-eu/translations.xml
@@ -25,6 +25,7 @@
"Jakinarazi gela osoari"
"Guztiak"
"Bidali berriro"
+ "Huts egin du mezuaren bidalketak"
"Gehitu emojia"
"Hauxe da %1$s(r)en hasiera"
"Hau da elkarrizketaren hasiera."
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index 9f46637948..5080013d27 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -104,7 +104,7 @@ class MessagesViewTest {
state = state,
onRoomDetailsClick = callback,
)
- rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick()
+ rule.onNodeWithText(state.roomName.dataOrNull().orEmpty(), useUnmergedTree = true).performClick()
}
}
@@ -206,6 +206,7 @@ class MessagesViewTest {
}
@Test
+ @Config(qualifiers = "h1024dp")
fun `clicking on a read receipt list emits the expected Event`() {
val eventsRecorder = EventsRecorder()
val state = aMessagesState(
@@ -229,7 +230,7 @@ class MessagesViewTest {
rule.setMessagesView(
state = state,
)
- rule.onNodeWithTag(TestTags.messageReadReceipts.value).performClick()
+ rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem))
}
@@ -309,7 +310,7 @@ class MessagesViewTest {
@Test
@Config(qualifiers = "h1024dp")
- fun `clicking on the sender of an Event invoke expected callback`() {
+ fun `clicking on the avatar of the sender of an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
@@ -322,7 +323,26 @@ class MessagesViewTest {
state = state,
onUserDataClick = callback,
)
- rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick()
+ rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
+ }
+ }
+
+ @Test
+ @Config(qualifiers = "h1024dp")
+ fun `clicking on the display name of the sender of an Event invoke expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ val state = aMessagesState(
+ eventSink = eventsRecorder
+ )
+ val timelineItem = state.timelineState.timelineItems.first()
+ ensureCalledOnceWithParam(
+ param = (timelineItem as TimelineItem.Event).senderId
+ ) { callback ->
+ rule.setMessagesView(
+ state = state,
+ onUserDataClick = callback,
+ )
+ rule.onNodeWithTag(TestTags.timelineItemSenderName.value, useUnmergedTree = true).performClick()
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index 8a5b388f72..e5e6ff5ea2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -153,7 +153,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -201,7 +201,7 @@ class ActionListPresenterTest {
isMine = false,
isEditable = false,
isThreaded = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -248,7 +248,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -296,7 +296,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -344,7 +344,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -391,7 +391,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -439,7 +439,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = true,
isThreaded = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -486,7 +486,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -804,7 +804,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -852,7 +852,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -907,7 +907,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -956,7 +956,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
val redactedEvent = aMessageEvent(
isMine = true,
@@ -1006,7 +1006,7 @@ class ActionListPresenterTest {
eventId = null,
isMine = true,
canBeRepliedTo = false,
- content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE),
)
initialState.eventSink.invoke(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 63a759bd67..d55ea7c7a0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send video with caption success scenario`() = runTest {
val sendVideoResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
- lambdaRecorder> { _, _, _, _, _ ->
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario without media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(
@@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario with media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val onDoneListenerResult = lambdaRecorder {}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
index c52f01949d..a8dbab2cf2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt
@@ -38,7 +38,7 @@ internal fun aMessageEvent(
isMine: Boolean = true,
isEditable: Boolean = true,
canBeRepliedTo: Boolean = true,
- content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
+ content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index 8328624bf6..a82df0f2aa 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
+import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.core.mimetype.MimeTypes
@@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@@ -67,7 +69,6 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
-import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
@@ -82,6 +83,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@@ -610,7 +612,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@@ -1109,7 +1111,7 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List ->
@@ -1535,8 +1537,11 @@ class MessageComposerPresenterTest {
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
- mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser),
- roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
+ mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
+ permalinkParser = permalinkParser,
+ mentionSpanFormatter = FakeMentionSpanFormatter(),
+ mentionSpanTheme = MentionSpanTheme(A_USER_ID)
+ ),
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
@@ -1562,7 +1567,6 @@ class MessageComposerPresenterTest {
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
- roomMemberProfilesCache = roomMemberProfilesCache,
suggestionsProcessor = SuggestionsProcessor(),
).apply {
isTesting = true
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
index 2f4740dfc9..a5f3417d5d 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
@@ -11,9 +11,11 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -23,10 +25,16 @@ import org.robolectric.RobolectricTestRunner
class DefaultHtmlConverterProviderTest {
@get:Rule val composeTestRule = createComposeRule()
+ private val provider = DefaultHtmlConverterProvider(
+ mentionSpanProvider = MentionSpanProvider(
+ permalinkParser = FakePermalinkParser(),
+ mentionSpanFormatter = FakeMentionSpanFormatter(),
+ mentionSpanTheme = MentionSpanTheme(A_USER_ID)
+ )
+ )
+
@Test
fun `calling provide without calling Update first should throw an exception`() {
- val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
-
val exception = runCatching { provider.provide() }.exceptionOrNull()
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
@@ -34,13 +42,11 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
- val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
- provider.Update(currentUserId = A_USER_ID)
+ provider.Update()
}
}
-
val htmlConverter = runCatching { provider.provide() }.getOrNull()
assertThat(htmlConverter).isNotNull()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
index fd081ac489..ee52aff578 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString
+import android.text.SpannedString
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@@ -18,16 +19,22 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
+import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
-import io.element.android.libraries.matrix.test.A_USER_ID_2
-import io.element.android.libraries.matrix.test.A_USER_NAME
-import io.element.android.libraries.matrix.test.room.aRoomMember
-import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
+import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
+import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanUpdater
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.textcomposer.mentions.MentionSpan
+import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
+import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
@@ -40,101 +47,100 @@ import org.junit.runner.RunWith
class TimelineTextViewTest {
@get:Rule val rule = createAndroidComposeRule()
+ private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID)
+ private val formatLambda = lambdaRecorder { mentionType -> mentionType.toString() }
+ private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda)
+
@Test
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest {
val charSequence = "Hello @alice:example.com"
-
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
+ assert(formatLambda).isNeverCalled()
}
@Test
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest {
val charSequence = SpannableString("Hello @alice:example.com")
-
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
+ assert(formatLambda).isNeverCalled()
}
@Test
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest {
val charSequence = "Hello @alice:example.com"
-
- val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
assertThat(result.getMentionSpans()).isEmpty()
assertThat(result.toString()).isEqualTo(charSequence)
+ assert(formatLambda).isNeverCalled()
}
@Test
- fun `getTextWithResolvedMentions - with Room mention does nothing`() = runTest {
+ fun `getTextWithResolvedMentions - with Room mention format correctly`() = runTest {
+ val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias())
val charSequence = buildSpannedString {
append("Hello ")
- inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) {
+ inSpans(MentionSpan(mentionType)) {
append(A_ROOM_ID.value)
}
}
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
-
- assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
+ val expectedDisplayText = mentionType.toString()
+ assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
assertThat(result).isEqualTo(charSequence)
+ assert(formatLambda).isCalledOnce()
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
+ val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString {
append("Hello ")
- inSpans(aMentionSpan(rawValue = A_USER_ID.value)) {
+ inSpans(MentionSpan(mentionType)) {
append("@NotAlice")
}
}
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
-
- assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
+ val expectedDisplayText = mentionType.toString()
+ assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
+ assert(formatLambda).isCalledOnce()
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
+ val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString {
append("Hello ")
- inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) {
+ inSpans(CustomMentionSpan(MentionSpan(mentionType))) {
append("@NotAlice")
}
}
-
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
-
- assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
- }
-
- @Test
- fun `getTextWithResolvedMentions - replaces MentionSpan's text with user id if no display name is cached`() = runTest {
- val charSequence = buildSpannedString {
- append("Hello ")
- inSpans(aMentionSpan(rawValue = A_USER_ID_2.value)) {
- append("@NotAlice")
- }
- }
-
- val result = rule.getText(aTextContentWithFormattedBody(charSequence))
-
- assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo(A_USER_ID_2.value)
+ val mentionSpanUpdater = aMentionSpanUpdater()
+ val expectedDisplayText = mentionType.toString()
+ val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
+ assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
+ assert(formatLambda).isCalledOnce()
}
private suspend fun AndroidComposeTestRule.getText(
+ mentionSpanUpdater: MentionSpanUpdater,
content: TimelineItemTextBasedContent,
): CharSequence {
val completable = CompletableDeferred()
setContent {
- val roomMemberProfilesCache = RoomMemberProfilesCache().apply {
- replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME)))
- }
CompositionLocalProvider(
- LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
+ LocalMentionSpanUpdater provides mentionSpanUpdater
) {
completable.complete(getTextWithResolvedMentions(content = content))
}
@@ -142,21 +148,20 @@ class TimelineTextViewTest {
return completable.await()
}
- private fun aMentionSpan(
- rawValue: String,
- text: String = "",
- type: MentionSpan.Type = MentionSpan.Type.USER
- ) = MentionSpan(
- text = text,
- rawValue = rawValue,
- type = type,
- )
+ private fun aMentionSpanUpdater(): MentionSpanUpdater {
+ return DefaultMentionSpanUpdater(
+ formatter = mentionSpanFormatter,
+ theme = mentionSpanTheme,
+ roomMemberProfilesCache = RoomMemberProfilesCache(),
+ roomNamesCache = RoomNamesCache(),
+ )
+ }
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
TimelineItemTextContent(
body = body,
htmlDocument = null,
- formattedBody = formattedBody,
+ formattedBody = formattedBody ?: SpannedString(body),
isEdited = false
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 51f3f09268..c6c8c7c1e3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -89,9 +89,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent(
body = "body",
htmlDocument = null,
- plainText = "body",
isEdited = false,
- formattedBody = null,
+ formattedBody = SpannedString("body"),
)
assertThat(result).isEqualTo(expected)
}
@@ -123,9 +122,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent(
body = "body",
htmlDocument = null,
- plainText = "body",
isEdited = false,
- formattedBody = null,
+ formattedBody = "body",
)
assertThat(result).isEqualTo(expected)
}
@@ -141,10 +139,8 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent(
body = "body",
htmlDocument = null,
- plainText = "body",
isEdited = false,
- formattedBody = null,
- pillifiedBody = SpannableString("body"),
+ formattedBody = SpannedString("body"),
)
assertThat(result).isEqualTo(expected)
}
@@ -160,7 +156,6 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemTextContent(
body = "https://www.example.org",
htmlDocument = null,
- plainText = "https://www.example.org",
isEdited = false,
formattedBody = buildSpannedString {
inSpans(URLSpan("https://www.example.org")) {
@@ -223,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
- assertThat((result as TimelineItemTextContent).formattedBody).isNull()
+ assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
}
@Test
@@ -637,8 +632,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemNoticeContent(
body = "body",
htmlDocument = null,
- plainText = "body",
- formattedBody = null,
+ formattedBody = SpannedString("body"),
isEdited = false,
)
assertThat(result).isEqualTo(expected)
@@ -671,8 +665,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemEmoteContent(
body = "* Bob body",
htmlDocument = null,
- plainText = "* Bob body",
- formattedBody = null,
+ formattedBody = SpannedString("* Bob body"),
isEdited = false,
)
assertThat(result).isEqualTo(expected)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt
index 013ce22cc2..0d571a2459 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt
@@ -10,17 +10,21 @@ package io.element.android.features.messages.impl.utils
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
-import io.element.android.libraries.matrix.test.room.aRoomMember
-import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
-import io.element.android.libraries.textcomposer.mentions.MentionSpan
+import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import org.junit.Test
import org.junit.runner.RunWith
@@ -30,90 +34,201 @@ class DefaultTextPillificationHelperTest {
@Test
fun `pillify - adds pills for user ids`() {
val text = "A @user:server.com"
+ val formatter = FakeMentionSpanFormatter()
+ val userId = UserId("@user:server.com")
val helper = aTextPillificationHelper(
- permalinkparser = FakePermalinkParser(result = {
- PermalinkData.UserLink(UserId("@user:server.com"))
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.UserLink(userId)
}),
permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
Result.success("https://matrix.to/#/@user:server.com")
}),
+ mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
- val mentionSpan = mentionSpans.firstOrNull()
- assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
- assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
- assertThat(mentionSpan?.text).isEqualTo("@user:server.com")
- }
-
- @Test
- fun `pillify - uses the cached display name for user mentions`() {
- val text = "A @user:server.com"
- val helper = aTextPillificationHelper(
- permalinkparser = FakePermalinkParser(result = {
- PermalinkData.UserLink(UserId("@user:server.com"))
- }),
- permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
- Result.success("https://matrix.to/#/@user:server.com")
- }),
- roomMemberProfilesCache = RoomMemberProfilesCache().apply {
- replace(listOf(aRoomMember(userId = UserId("@user:server.com"), displayName = "Alice")))
- },
- )
- val pillified = helper.pillify(text)
- val mentionSpans = pillified.getMentionSpans()
- assertThat(mentionSpans).hasSize(1)
- val mentionSpan = mentionSpans.firstOrNull()
- assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
- assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
- assertThat(mentionSpan?.text).isEqualTo("Alice")
+ val mentionSpan = mentionSpans.first()
+ assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
+ val userType = mentionSpan.type as MentionType.User
+ assertThat(userType.userId).isEqualTo(userId)
+ val formatted = formatter.formatDisplayText(MentionType.User(userId))
+ assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
}
@Test
fun `pillify - adds pills for room aliases`() {
val text = "A #room:server.com"
+ val roomAlias = RoomAlias("#room:server.com")
+ val formatter = FakeMentionSpanFormatter()
val helper = aTextPillificationHelper(
- permalinkparser = FakePermalinkParser(result = {
- PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com")))
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias))
}),
permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
Result.success("https://matrix.to/#/#room:server.com")
}),
+ mentionSpanFormatter = formatter,
)
val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
- val mentionSpan = mentionSpans.firstOrNull()
- assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM)
- assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com")
- assertThat(mentionSpan?.text).isEqualTo("#room:server.com")
+ val mentionSpan = mentionSpans.first()
+ assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java)
+ val roomType = mentionSpan.type as MentionType.Room
+ assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias())
+ val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias()))
+ assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
}
@Test
fun `pillify - adds pills for @room mentions`() {
val text = "An @room mention"
- val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = {
- PermalinkData.FallbackLink(Uri.EMPTY)
- }))
+ val formatter = FakeMentionSpanFormatter()
+ val helper = aTextPillificationHelper(
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.FallbackLink(Uri.EMPTY)
+ }),
+ mentionSpanFormatter = formatter,
+ )
val pillified = helper.pillify(text)
val mentionSpans = pillified.getMentionSpans()
assertThat(mentionSpans).hasSize(1)
- val mentionSpan = mentionSpans.firstOrNull()
- assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE)
- assertThat(mentionSpan?.rawValue).isEqualTo("@room")
- assertThat(mentionSpan?.text).isEqualTo("@room")
+ val mentionSpan = mentionSpans.first()
+ assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone)
+ val formatted = formatter.formatDisplayText(MentionType.Everyone)
+ assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
+ }
+
+ @Test
+ fun `pillify - adds pills for message permalinks`() {
+ val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
+ val roomId = RoomId("!roomid:server.com")
+ val eventId = EventId("$123")
+ val formatter = FakeMentionSpanFormatter()
+ val helper = aTextPillificationHelper(
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.RoomLink(
+ roomIdOrAlias = RoomIdOrAlias.Id(roomId),
+ eventId = eventId
+ )
+ }),
+ permalinkBuilder = FakePermalinkBuilder(),
+ mentionSpanFormatter = formatter,
+ )
+ val pillified = helper.pillify(text)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.first()
+ assertThat(mentionSpan.type).isInstanceOf(MentionType.Message::class.java)
+ val messageType = mentionSpan.type as MentionType.Message
+ assertThat(messageType.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
+ assertThat(messageType.eventId).isEqualTo(eventId)
+ val formatted = formatter.formatDisplayText(MentionType.Message(roomId.toRoomIdOrAlias(), eventId))
+ assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted)
+ }
+
+ @Test
+ fun `pillify - with pillifyPermalinks false does not add pills for permalinks`() {
+ val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123"
+ val roomId = RoomId("!roomid:server.com")
+ val eventId = EventId("$123")
+ val formatter = FakeMentionSpanFormatter()
+ val helper = aTextPillificationHelper(
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.RoomLink(
+ roomIdOrAlias = RoomIdOrAlias.Id(roomId),
+ eventId = eventId
+ )
+ }),
+ permalinkBuilder = FakePermalinkBuilder(),
+ mentionSpanFormatter = formatter,
+ )
+ val pillified = helper.pillify(text, pillifyPermalinks = false)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).isEmpty()
+ }
+
+ @Test
+ fun `pillify - with pillifyPermalinks false still adds pills for matrix patterns`() {
+ val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
+ val userId = UserId("@user:server.com")
+ val formatter = FakeMentionSpanFormatter()
+ val helper = aTextPillificationHelper(
+ permalinkParser = FakePermalinkParser(result = {
+ PermalinkData.UserLink(userId)
+ }),
+ permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
+ Result.success("https://matrix.to/#/@user:server.com")
+ }),
+ mentionSpanFormatter = formatter,
+ )
+ val pillified = helper.pillify(text, pillifyPermalinks = false)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.first()
+ assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java)
+ val userType = mentionSpan.type as MentionType.User
+ assertThat(userType.userId).isEqualTo(userId)
+ }
+
+ @Test
+ fun `pillify - with pillifyPermalinks true adds pills for both matrix patterns and permalinks`() {
+ val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123"
+ val userId = UserId("@user:server.com")
+ val roomId = RoomId("!roomid:server.com")
+ val eventId = EventId("$123")
+ val formatter = FakeMentionSpanFormatter()
+ val permalinkParser = FakePermalinkParser(result = { url ->
+ if (url.contains("matrix.to")) {
+ PermalinkData.RoomLink(
+ roomIdOrAlias = RoomIdOrAlias.Id(roomId),
+ eventId = eventId
+ )
+ } else {
+ PermalinkData.UserLink(userId)
+ }
+ })
+ val helper = aTextPillificationHelper(
+ permalinkParser = permalinkParser,
+ permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
+ Result.success("https://matrix.to/#/@user:server.com")
+ }),
+ mentionSpanFormatter = formatter,
+ )
+ val pillified = helper.pillify(text, pillifyPermalinks = true)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(2)
+
+ // Check that we have both a user mention and a message mention
+ val types = mentionSpans.map { it.type::class.java }
+ assertThat(types).contains(MentionType.User::class.java)
+ assertThat(types).contains(MentionType.Message::class.java)
+
+ // Verify the user mention
+ val userMention = mentionSpans.first { it.type is MentionType.User }.type as MentionType.User
+ assertThat(userMention.userId).isEqualTo(userId)
+
+ // Verify the message mention
+ val messageMention = mentionSpans.first { it.type is MentionType.Message }.type as MentionType.Message
+ assertThat(messageMention.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias())
+ assertThat(messageMention.eventId).isEqualTo(eventId)
}
private fun aTextPillificationHelper(
- permalinkparser: PermalinkParser = FakePermalinkParser(),
+ permalinkParser: PermalinkParser = FakePermalinkParser(),
permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
- mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser),
- roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
- ) = DefaultTextPillificationHelper(
- mentionSpanProvider = mentionSpanProvider,
- permalinkBuilder = permalinkBuilder,
- permalinkParser = permalinkparser,
- roomMemberProfilesCache = roomMemberProfilesCache,
- )
+ mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(),
+ ): TextPillificationHelper {
+ val mentionSpanProvider = MentionSpanProvider(
+ permalinkParser = permalinkParser,
+ mentionSpanFormatter = mentionSpanFormatter,
+ mentionSpanTheme = MentionSpanTheme(A_USER_ID),
+ )
+ return DefaultTextPillificationHelper(
+ mentionSpanProvider = mentionSpanProvider,
+ permalinkBuilder = permalinkBuilder,
+ permalinkParser = permalinkParser,
+ )
+ }
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt
new file mode 100644
index 0000000000..a8128bf622
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
+import io.element.android.libraries.textcomposer.mentions.MentionType
+
+class FakeMentionSpanFormatter(
+ private val formatLambda: (MentionType) -> CharSequence = { type -> type.toString() },
+) : MentionSpanFormatter {
+ override fun formatDisplayText(mentionType: MentionType): CharSequence {
+ return formatLambda(mentionType)
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt
index d8c62bca40..bbc7c7a728 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt
@@ -8,9 +8,9 @@
package io.element.android.features.messages.impl.utils
class FakeTextPillificationHelper(
- private val pillifyLambda: (CharSequence) -> CharSequence = { it }
+ private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text }
) : TextPillificationHelper {
- override fun pillify(text: CharSequence): CharSequence {
- return pillifyLambda(text)
+ override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence {
+ return pillifyLambda(text, pillifyPermalinks)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
index e9ffd00b23..5c3e694062 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
)
private val analyticsService = FakeAnalyticsService()
private val sendVoiceMessageResult =
- lambdaRecorder, ProgressCallback?, Result> { _, _, _, _ ->
+ lambdaRecorder, ProgressCallback?, ReplyParameters?, Result> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
private val matrixRoom = FakeMatrixRoom(
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
index 38f10071e9..b9ea0d2416 100644
--- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
@@ -9,14 +9,13 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
) : HtmlConverterProvider {
@Composable
- override fun Update(currentUserId: UserId) = Unit
+ override fun Update() = Unit
override fun provide(): HtmlConverter {
return object : HtmlConverter {
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
index f89d81685f..3d8fc3a0aa 100644
--- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
@@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
@Composable
override fun present(): MigrationState {
- val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
+ val migrationStoreVersion by remember {
+ migrationStore.applicationMigrationVersion()
+ }.collectAsState(initial = null)
var migrationAction: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) }
// Uncomment this block to run the migration everytime
diff --git a/features/onboarding/impl/build.gradle.kts b/features/onboarding/impl/build.gradle.kts
index 13e37e0a54..c59cd5684b 100644
--- a/features/onboarding/impl/build.gradle.kts
+++ b/features/onboarding/impl/build.gradle.kts
@@ -26,6 +26,7 @@ setupAnvil()
dependencies {
implementation(projects.appconfig)
+ implementation(projects.features.rageshake.api)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
index 8d33f60393..481ad6edc6 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt
@@ -10,7 +10,9 @@ package io.element.android.features.onboarding.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
import io.element.android.appconfig.OnBoardingConfig
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -24,16 +26,19 @@ import javax.inject.Inject
class OnBoardingPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter {
@Composable
override fun present(): OnBoardingState {
val canLoginWithQrCode by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
+ val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT,
+ canReportBug = canReportBug,
)
}
}
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
index 6ffb80c2bd..3a5afb741c 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt
@@ -11,4 +11,5 @@ data class OnBoardingState(
val productionApplicationName: String,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
+ val canReportBug: Boolean,
)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
index 25dc697782..d65b6aa2bf 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt
@@ -16,15 +16,18 @@ open class OnBoardingStateProvider : PreviewParameterProvider {
anOnBoardingState(canLoginWithQrCode = true),
anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
+ anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
)
}
fun anOnBoardingState(
productionApplicationName: String = "Element",
canLoginWithQrCode: Boolean = false,
- canCreateAccount: Boolean = false
+ canCreateAccount: Boolean = false,
+ canReportBug: Boolean = false,
) = OnBoardingState(
productionApplicationName = productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode,
- canCreateAccount = canCreateAccount
+ canCreateAccount = canCreateAccount,
+ canReportBug = canReportBug,
)
diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
index 95a8e2618d..44ab2d84a5 100644
--- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
+++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
@@ -144,8 +144,8 @@ private fun OnBoardingButtons(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
modifier = Modifier
- .fillMaxWidth()
- .testTag(TestTags.onBoardingSignIn)
+ .fillMaxWidth()
+ .testTag(TestTags.onBoardingSignIn)
)
if (state.canCreateAccount) {
TextButton(
@@ -155,15 +155,17 @@ private fun OnBoardingButtons(
.fillMaxWidth()
)
}
- // Add a report problem text button. Use a Text since we need a special theme here.
- Text(
- modifier = Modifier
+ if (state.canReportBug) {
+ // Add a report problem text button. Use a Text since we need a special theme here.
+ Text(
+ modifier = Modifier
.padding(16.dp)
.clickable(onClick = onReportProblem),
- text = stringResource(id = CommonStrings.common_report_a_problem),
- style = ElementTheme.typography.fontBodySmRegular,
- color = ElementTheme.colors.textSecondary,
- )
+ text = stringResource(id = CommonStrings.common_report_a_problem),
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
}
}
diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
index e98fbff3db..b692d291b5 100644
--- a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
+++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -38,6 +39,7 @@ class OnBoardingPresenterTest {
val presenter = OnBoardingPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
+ rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -46,7 +48,21 @@ class OnBoardingPresenterTest {
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
+ assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
+
+ @Test
+ fun `present - rageshake not available`() = runTest {
+ val presenter = OnBoardingPresenter(
+ buildMeta = aBuildMeta(),
+ featureFlagService = FakeFeatureFlagService(),
+ rageshakeFeatureAvailability = { false },
+ )
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem().canReportBug).isFalse()
+ }
+ }
}
diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt
index 25cb075697..955bbea1f1 100644
--- a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt
+++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt
@@ -10,6 +10,7 @@ package io.element.android.features.onboarding.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -76,13 +77,28 @@ class OnboardingViewTest {
fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
- state = anOnBoardingState(),
+ state = anOnBoardingState(
+ canReportBug = true,
+ ),
onReportProblem = callback,
)
+ val text = rule.activity.getString(CommonStrings.common_report_a_problem)
+ rule.onNodeWithText(text).assertExists()
rule.clickOn(CommonStrings.common_report_a_problem)
}
}
+ @Test
+ fun `cannot report a problem when the feature is disabled`() {
+ rule.setOnboardingView(
+ state = anOnBoardingState(
+ canReportBug = false,
+ ),
+ )
+ val text = rule.activity.getString(CommonStrings.common_report_a_problem)
+ rule.onNodeWithText(text).assertDoesNotExist()
+ }
+
private fun AndroidComposeTestRule.setOnboardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
index 00f68e923b..d4da6a76b4 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
@@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
@Composable
override fun present(): PollHistoryState {
val timeline = room.liveTimeline
- val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
+ val paginationState by timeline.backwardPaginationStatus.collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items)
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index bbe96ecb80..8d34d55559 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -1,3 +1,5 @@
+import config.BuildTimeConfig
+import extension.buildConfigFieldStr
import extension.setupAnvil
/*
@@ -19,6 +21,25 @@ android {
isIncludeAndroidResources = true
}
}
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ defaultConfig {
+ buildConfigFieldStr(
+ name = "URL_COPYRIGHT",
+ value = BuildTimeConfig.URL_COPYRIGHT ?: "https://element.io/copyright",
+ )
+ buildConfigFieldStr(
+ name = "URL_ACCEPTABLE_USE",
+ value = BuildTimeConfig.URL_ACCEPTABLE_USE ?: "https://element.io/acceptable-use-policy-terms",
+ )
+ buildConfigFieldStr(
+ name = "URL_PRIVACY",
+ value = BuildTimeConfig.URL_PRIVACY ?: "https://element.io/privacy",
+ )
+ }
}
setupAnvil()
@@ -53,6 +74,7 @@ dependencies {
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
+ implementation(projects.features.invite.api)
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
@@ -82,6 +104,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.ftue.test)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.test)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt
index a94e1637ba..a5de31f05d 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt
@@ -8,11 +8,12 @@
package io.element.android.features.preferences.impl.about
import androidx.annotation.StringRes
+import io.element.android.features.preferences.impl.BuildConfig
import io.element.android.libraries.ui.strings.CommonStrings
-private const val COPYRIGHT_URL = "https://element.io/copyright"
-private const val USE_POLICY_URL = "https://element.io/acceptable-use-policy-terms"
-private const val PRIVACY_URL = "https://element.io/privacy"
+private const val COPYRIGHT_URL = BuildConfig.URL_COPYRIGHT
+private const val USE_POLICY_URL = BuildConfig.URL_ACCEPTABLE_USE
+private const val PRIVACY_URL = BuildConfig.URL_PRIVACY
sealed class ElementLegal(
@StringRes val titleRes: Int,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
index b256997d1c..80f9a98b0c 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
@@ -29,19 +29,18 @@ class AdvancedSettingsPresenter @Inject constructor(
@Composable
override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope()
- val isDeveloperModeEnabled by appPreferencesStore
- .isDeveloperModeEnabledFlow()
- .collectAsState(initial = false)
- val isSharePresenceEnabled by sessionPreferencesStore
- .isSharePresenceEnabled()
- .collectAsState(initial = true)
- val doesCompressMedia by sessionPreferencesStore
- .doesCompressMedia()
- .collectAsState(initial = true)
+ val isDeveloperModeEnabled by remember {
+ appPreferencesStore.isDeveloperModeEnabledFlow()
+ }.collectAsState(initial = false)
+ val isSharePresenceEnabled by remember {
+ sessionPreferencesStore.isSharePresenceEnabled()
+ }.collectAsState(initial = true)
+ val doesCompressMedia by remember {
+ sessionPreferencesStore.doesCompressMedia()
+ }.collectAsState(initial = true)
val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme()
- }
- .collectAsState(initial = Theme.System)
+ }.collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) }
fun handleEvents(event: AdvancedSettingsEvents) {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
index 19365e307c..c673276a17 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
@@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized)
}
- val renderBlockedUsersDetail = featureFlagService
- .isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
- .collectAsState(initial = false)
+ val renderBlockedUsersDetail by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
+ }.collectAsState(initial = false)
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
val ignoredMatrixUser by produceState(
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
- key1 = renderBlockedUsersDetail.value,
+ key1 = renderBlockedUsersDetail,
key2 = ignoredUserIds
) {
value = ignoredUserIds.map {
- if (renderBlockedUsersDetail.value) {
+ if (renderBlockedUsersDetail) {
matrixClient.getProfile(it).getOrNull()
} else {
null
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
index 4a9437a188..74c673bc8a 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
@@ -9,11 +9,13 @@ package io.element.android.features.preferences.impl.developer
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
data class SetHideImagesAndVideos(val value: Boolean) : DeveloperSettingsEvents
data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents
+ data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
index ee57d3b8e7..5159983a6e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap
@@ -34,9 +35,13 @@ import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.URL
@@ -66,17 +71,25 @@ class DeveloperSettingsPresenter @Inject constructor(
val clearCacheAction = remember {
mutableStateOf>(AsyncAction.Uninitialized)
}
- val customElementCallBaseUrl by appPreferencesStore
- .getCustomElementCallBaseUrlFlow()
- .collectAsState(initial = null)
- val hideImagesAndVideos by appPreferencesStore
- .doesHideImagesAndVideosFlow()
- .collectAsState(initial = false)
+ val customElementCallBaseUrl by remember {
+ appPreferencesStore
+ .getCustomElementCallBaseUrlFlow()
+ }.collectAsState(initial = null)
+ val hideImagesAndVideos by remember {
+ appPreferencesStore
+ .doesHideImagesAndVideosFlow()
+ }.collectAsState(initial = false)
val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
}
val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized)
+ val tracingLogPacks by produceState(persistentListOf()) {
+ appPreferencesStore.getTracingLogPacksFlow()
+ // Sort the entries alphabetically by its title
+ .map { it.sortedBy { it.title }.toPersistentList() }
+ .collectLatest { value = it }
+ }
LaunchedEffect(Unit) {
FeatureFlags.entries
@@ -121,6 +134,15 @@ class DeveloperSettingsPresenter @Inject constructor(
is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch {
appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel())
}
+ is DeveloperSettingsEvents.ToggleTracingLogPack -> coroutineScope.launch {
+ val currentPacks = tracingLogPacks.toMutableSet()
+ if (currentPacks.contains(event.logPack)) {
+ currentPacks.remove(event.logPack)
+ } else {
+ currentPacks.add(event.logPack)
+ }
+ appPreferencesStore.setTracingLogPacks(currentPacks)
+ }
}
}
@@ -135,6 +157,7 @@ class DeveloperSettingsPresenter @Inject constructor(
),
hideImagesAndVideos = hideImagesAndVideos,
tracingLogLevel = tracingLogLevel,
+ tracingLogPacks = tracingLogPacks,
eventSink = ::handleEvents
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
index 6bc8743439..efcfcd01d4 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
@@ -12,6 +12,7 @@ import io.element.android.features.rageshake.api.preferences.RageshakePreference
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
data class DeveloperSettingsState(
@@ -22,6 +23,7 @@ data class DeveloperSettingsState(
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val hideImagesAndVideos: Boolean,
val tracingLogLevel: AsyncData,
+ val tracingLogPacks: ImmutableList,
val eventSink: (DeveloperSettingsEvents) -> Unit
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
index 064a208f49..18151d32c7 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
@@ -13,6 +13,8 @@ import io.element.android.features.rageshake.api.preferences.aRageshakePreferenc
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
+import kotlinx.collections.immutable.toPersistentList
open class DeveloperSettingsStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -33,6 +35,7 @@ fun aDeveloperSettingsState(
clearCacheAction: AsyncAction = AsyncAction.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
hideImagesAndVideos: Boolean = false,
+ traceLogPacks: List = emptyList(),
eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState(
features = aFeatureUiModelList(),
@@ -42,6 +45,7 @@ fun aDeveloperSettingsState(
customElementCallBaseUrlState = customElementCallBaseUrlState,
hideImagesAndVideos = hideImagesAndVideos,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
+ tracingLogPacks = traceLogPacks.toPersistentList(),
eventSink = eventSink,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
index de368b9a65..e335c0a6cd 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
@@ -7,6 +7,7 @@
package io.element.android.features.preferences.impl.developer
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.text.KeyboardOptions
@@ -16,6 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
@@ -32,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.featureflag.ui.FeatureListView
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
@@ -56,6 +59,7 @@ fun DeveloperSettingsView(
FeatureListContent(state)
}
ElementCallCategory(state = state)
+
PreferenceCategory(title = "Rust SDK") {
PreferenceDropdown(
title = "Tracing log level",
@@ -67,6 +71,22 @@ fun DeveloperSettingsView(
}
)
}
+ PreferenceCategory(title = "Enable trace logs per SDK feature") {
+ Text(
+ text = "Requires app reboot",
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
+ )
+ for (logPack in TraceLogPack.entries) {
+ PreferenceSwitch(
+ title = logPack.title,
+ isChecked = state.tracingLogPacks.contains(logPack),
+ onCheckedChange = { isChecked -> state.eventSink(DeveloperSettingsEvents.ToggleTracingLogPack(logPack, isChecked)) }
+ )
+ }
+ }
+
PreferenceCategory(title = "Showkase") {
ListItem(
headlineContent = {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
index 317c6e796e..3c1c81e758 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
@@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
- val appNotificationsEnabled = userPushStore
- .getNotificationEnabledForDevice()
- .collectAsState(initial = false)
+ val appNotificationsEnabled by remember {
+ userPushStore.getNotificationEnabledForDevice()
+ }.collectAsState(initial = false)
val matrixSettings: MutableState = remember {
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
@@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
matrixSettings = matrixSettings.value,
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = systemNotificationsEnabled.value,
- appNotificationsEnabled = appNotificationsEnabled.value
+ appNotificationsEnabled = appNotificationsEnabled,
),
changeNotificationSettingAction = changeNotificationSettingAction.value,
currentPushDistributor = currentDistributor,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index ae5267de0f..15a55f41ca 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
@@ -44,6 +45,7 @@ class PreferencesRootPresenter @Inject constructor(
private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter,
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter {
@Composable
override fun present(): PreferencesRootState {
@@ -79,6 +81,7 @@ class PreferencesRootPresenter @Inject constructor(
var canDeactivateAccount by remember {
mutableStateOf(false)
}
+ val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
LaunchedEffect(Unit) {
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
@@ -114,6 +117,7 @@ class PreferencesRootPresenter @Inject constructor(
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
+ canReportBug = canReportBug,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showNotificationSettings = showNotificationSettings.value,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
index 9a16e6f65b..2e5cb4fa14 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
@@ -20,6 +20,7 @@ data class PreferencesRootState(
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
+ val canReportBug: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index e4ca550777..43307e9988 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -25,6 +25,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
+ canReportBug = true,
showDeveloperSettings = true,
showNotificationSettings = true,
showLockScreenSettings = true,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 236d659f33..b63919cd26 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -202,11 +202,13 @@ private fun ColumnScope.GeneralSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = onOpenAbout,
)
- ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
- onClick = onOpenRageShake
- )
+ if (state.canReportBug) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
+ onClick = onOpenRageShake
+ )
+ }
if (state.showAnalyticsSettings) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) },
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
index 27763db5f5..8a081c0e41 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
@@ -11,6 +11,7 @@ import android.content.Context
import coil3.SingletonImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
@@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider,
private val ftueService: FtueService,
private val pushService: PushService,
+ private val seenInvitesStore: SeenInvitesStore,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
context.cacheDir.deleteRecursively()
// Clear some settings
ftueService.reset()
+ seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted
diff --git a/features/preferences/impl/src/main/res/values-et/translations.xml b/features/preferences/impl/src/main/res/values-et/translations.xml
index bf7c028dd3..6c5c1af22d 100644
--- a/features/preferences/impl/src/main/res/values-et/translations.xml
+++ b/features/preferences/impl/src/main/res/values-et/translations.xml
@@ -8,8 +8,11 @@
"Element Calli kohandatud teenuseaadress"
"Seadista kohandatud teenuseaadress Element Calli jaoks."
"Vigane url. Palun vaata, et url algaks protokolliga (http/https) ning aadress ise oleks ka õige."
+ "Peida jututubade kutsetest tunnuspildid"
+ "Peida meedia eelvaated ajajoonel"
"Sellega laadid fotosid ja videoid kiiremini üles ning vähendad andmemahtu"
"Optimeeri meedia kvaliteeti"
+ "Modereerimine ja ohutus"
"Tõuketeavituste pakkuja"
"Kui soovid Markdown-vormingut käsitsi lisada, siis lülita vormindatud teksti toimeti välja."
"Lugemisteatised"
diff --git a/features/preferences/impl/src/main/res/values-eu/translations.xml b/features/preferences/impl/src/main/res/values-eu/translations.xml
index 4caeb4f6db..62e4da471c 100644
--- a/features/preferences/impl/src/main/res/values-eu/translations.xml
+++ b/features/preferences/impl/src/main/res/values-eu/translations.xml
@@ -5,6 +5,7 @@
"Aukeratu jakinarazpenak nola jaso"
"Garatzaile modua"
"Gaitu garatzaileentzako ezaugarrietarako eta funtzionalitateetarako sarbidea izateko."
+ "Moderazioa eta Segurtasuna"
"Push jakinarazpen hornitzailea"
"Desgaitu testu aberatseko editorea Markdown eskuz idazteko."
"Irakurketa-agiriak"
diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml
index e47167e32b..348c54771e 100644
--- a/features/preferences/impl/src/main/res/values-it/translations.xml
+++ b/features/preferences/impl/src/main/res/values-it/translations.xml
@@ -8,8 +8,11 @@
"URL base di Element Call personalizzato"
"Imposta un URL di base personalizzato per Element Call."
"URL non valido, assicurati di includere il protocollo (http/https) e l\'indirizzo corretto."
+ "Nascondi gli avatar nelle richieste di invito alle stanze"
+ "Nascondi le anteprime dei media nelle conversazioni"
"Carica foto e video più velocemente e riduci l\'utilizzo dei dati"
"Ottimizza la qualità dei contenuti multimediali"
+ "Moderazione e Sicurezza"
"Fornitore di notifiche push"
"Disattiva l\'editor di testo avanzato per scrivere manualmente in Markdown"
"Ricevute di visualizzazione"
diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml
index 118cd7d6a5..0a4cdb7c27 100644
--- a/features/preferences/impl/src/main/res/values-sk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sk/translations.xml
@@ -8,8 +8,11 @@
"Vlastná Element Call základná URL adresa"
"Nastaviť vlastnú základnú URL adresu pre Element Call."
"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."
+ "Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti"
+ "Skryť ukážky médií na časovej osi"
"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"
"Optimalizovať kvalitu médií"
+ "Moderovanie a bezpečnosť"
"Poskytovateľ oznámení Push"
"Vypnite rozšírený textový editor na ručné písanie Markdown."
"Potvrdenia o prečítaní"
diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml
index afbbe97e39..4a92a89832 100644
--- a/features/preferences/impl/src/main/res/values-uk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-uk/translations.xml
@@ -8,8 +8,11 @@
"Користувацька URL-адреса Element Call"
"Встановіть URL-адресу для Element Call."
"Неправильна URL-адреса. Переконайтеся, що ви вказали протокол (http/https) та правильну адресу."
+ "Сховати аватари у запитах на запрошення до кімнат"
+ "Сховати попередній перегляд медіа у стрічці"
"Швидше завантажуйте фотографії та відео та зменшуйте використання даних"
"Оптимізуйте медіаякість"
+ "Модерування й безпека"
"Постачальник push-сповіщень"
"Вимкніть редактор розширеного тексту, щоб вводити Markdown вручну."
"Читати журнали"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
index 94b70442d9..255c6442d9 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
@@ -68,7 +68,7 @@ class DeveloperSettingsViewTest {
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
}
- @Config(qualifiers = "h1024dp")
+ @Config(qualifiers = "h1200dp")
@Test
fun `clicking on open showkase invokes the expected callback`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
@@ -97,7 +97,7 @@ class DeveloperSettingsViewTest {
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG))
}
- @Config(qualifiers = "h1500dp")
+ @Config(qualifiers = "h1700dp")
@Test
fun `clicking on clear cache emits the expected event`() {
val eventsRecorder = EventsRecorder()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 72ae8bdb10..8075a43485 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -13,6 +13,7 @@ import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -78,6 +79,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showLockScreenSettings).isTrue()
assertThat(loadedState.showNotificationSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
+ assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull()
skipItems(1)
@@ -92,6 +94,22 @@ class PreferencesRootPresenterTest {
}
}
+ @Test
+ fun `present - cannot report bug`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canDeactivateAccountResult = { true },
+ accountManagementUrlResult = { Result.success("") },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ rageshakeFeatureAvailability = { false },
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.canReportBug).isFalse()
+ skipItems(1)
+ }
+ }
+
@Test
fun `present - can deactivate account is false if the Matrix client say so`() = runTest {
createPresenter(
@@ -146,6 +164,7 @@ class PreferencesRootPresenterTest {
matrixClient: FakeMatrixClient = FakeMatrixClient(),
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
+ rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
) = PreferencesRootPresenter(
matrixClient = matrixClient,
sessionVerificationService = sessionVerificationService,
@@ -159,5 +178,6 @@ class PreferencesRootPresenterTest {
),
directLogoutPresenter = { aDirectLogoutState() },
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
+ rageshakeFeatureAvailability = rageshakeFeatureAvailability,
)
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
index 778db4a4a8..401477d5fc 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
@@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
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.test.runTest
import okhttp3.OkHttpClient
import org.junit.Test
@@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient,
@@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
pushService = pushService,
+ seenInvitesStore = seenInvitesStore,
)
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
@@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
}
}
}
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt
new file mode 100644
index 0000000000..34e740d4ab
--- /dev/null
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.rageshake.api
+
+fun interface RageshakeFeatureAvailability {
+ fun isAvailable(): Boolean
+}
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt
index 9207d5ff38..40a9d0282b 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt
@@ -8,6 +8,7 @@
package io.element.android.features.rageshake.api.preferences
data class RageshakePreferencesState(
+ val isFeatureEnabled: Boolean,
val isEnabled: Boolean,
val isSupported: Boolean,
val sensitivity: Float,
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt
index d18123cd8d..a98c75a02a 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt
@@ -12,14 +12,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RageshakePreferencesStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aRageshakePreferencesState().copy(isEnabled = true, isSupported = true, sensitivity = 0.5f),
- aRageshakePreferencesState().copy(isEnabled = true, isSupported = false, sensitivity = 0.5f),
+ aRageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f),
+ aRageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f),
)
}
-fun aRageshakePreferencesState() = RageshakePreferencesState(
- isEnabled = false,
- isSupported = true,
- sensitivity = 0.3f,
- eventSink = {}
+fun aRageshakePreferencesState(
+ isFeatureEnabled: Boolean = true,
+ isEnabled: Boolean = false,
+ isSupported: Boolean = true,
+ sensitivity: Float = 0.3f,
+ eventSink: (RageshakePreferencesEvents) -> Unit = {}
+) = RageshakePreferencesState(
+ isFeatureEnabled = isFeatureEnabled,
+ isEnabled = isEnabled,
+ isSupported = isSupported,
+ sensitivity = sensitivity,
+ eventSink = eventSink,
)
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt
index 0841e097ba..86f1c05247 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt
+++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt
@@ -36,28 +36,30 @@ fun RageshakePreferencesView(
}
Column(modifier = modifier) {
- PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
- if (state.isSupported) {
- PreferenceSwitch(
- title = stringResource(id = CommonStrings.preference_rageshake),
- isChecked = state.isEnabled,
- onCheckedChange = ::onEnabledChanged
- )
- PreferenceSlide(
- title = stringResource(id = R.string.settings_rageshake_detection_threshold),
- // summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
- value = state.sensitivity,
- enabled = state.isEnabled,
- // 5 possible values - steps are in ]0, 1[
- steps = 3,
- onValueChange = ::onSensitivityChanged
- )
- } else {
- ListItem(
- headlineContent = {
- Text("Rageshaking is not supported by your device")
- },
- )
+ if (state.isFeatureEnabled) {
+ PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
+ if (state.isSupported) {
+ PreferenceSwitch(
+ title = stringResource(id = CommonStrings.preference_rageshake),
+ isChecked = state.isEnabled,
+ onCheckedChange = ::onEnabledChanged
+ )
+ PreferenceSlide(
+ title = stringResource(id = R.string.settings_rageshake_detection_threshold),
+ // summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
+ value = state.sensitivity,
+ enabled = state.isEnabled,
+ // 5 possible values - steps are in ]0, 1[
+ steps = 3,
+ onValueChange = ::onSensitivityChanged
+ )
+ } else {
+ ListItem(
+ headlineContent = {
+ Text("Rageshaking is not supported by your device")
+ },
+ )
+ }
}
}
}
diff --git a/features/rageshake/api/src/main/res/values-nb/translations.xml b/features/rageshake/api/src/main/res/values-nb/translations.xml
index ca37b6e6b6..dfc2d710ad 100644
--- a/features/rageshake/api/src/main/res/values-nb/translations.xml
+++ b/features/rageshake/api/src/main/res/values-nb/translations.xml
@@ -2,5 +2,6 @@
"%1$s krasjet sist gang den ble brukt. Vil du dele en krasjrapport med oss?"
"Du ser ut til å riste på telefonen i frustrasjon. Vil du åpne feilrapportskjermen?"
+ "Rageshake"
"Gjenkjenningsterskel"
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt
new file mode 100644
index 0000000000..5f7548e9ec
--- /dev/null
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.rageshake.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.RageshakeConfig
+import io.element.android.appconfig.isEnabled
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultRageshakeFeatureAvailability @Inject constructor() : RageshakeFeatureAvailability {
+ override fun isAvailable(): Boolean {
+ return RageshakeConfig.isEnabled
+ }
+}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
index 88bba32f23..455cefdb24 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
@@ -16,10 +16,10 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
-import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
+import io.element.android.features.rageshake.impl.crash.CrashDataStore
+import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
@@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
screenshotHolder.getFileUri()
)
}
- val crashInfo: String by crashDataStore
- .crashInfo()
- .collectAsState(initial = "")
+ val crashInfo: String by remember {
+ crashDataStore.crashInfo()
+ }.collectAsState(initial = "")
val sendingProgress = remember {
mutableFloatStateOf(0f)
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt
similarity index 88%
rename from features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt
rename to features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt
index 5a13f44a0a..3d6df9a424 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.api.crash
+package io.element.android.features.rageshake.impl.crash
import kotlinx.coroutines.flow.Flow
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt
index 749ba4255c..fffb87722a 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt
@@ -9,15 +9,17 @@ package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.rageshake.api.crash.CrashDataStore
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,12 +27,18 @@ import javax.inject.Inject
class DefaultCrashDetectionPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val crashDataStore: CrashDataStore,
-) :
- CrashDetectionPresenter {
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
+) : CrashDetectionPresenter {
@Composable
override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope()
- val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false)
+ val crashDetected = remember {
+ if (rageshakeFeatureAvailability.isAvailable()) {
+ crashDataStore.appHasCrashed()
+ } else {
+ flowOf(false)
+ }
+ }.collectAsState(false)
fun handleEvents(event: CrashDetectionEvents) {
when (event) {
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt
index 2cdea62b1a..0b86bb4ef0 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt
@@ -15,7 +15,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt
index 29785d34fe..1a8aed7051 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt
@@ -20,9 +20,9 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionPre
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
-import io.element.android.features.rageshake.api.rageshake.RageShake
import io.element.android.features.rageshake.api.screenshot.ImageResult
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
+import io.element.android.features.rageshake.impl.rageshake.RageShake
+import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -75,7 +75,8 @@ class DefaultRageshakeDetectionPresenter @Inject constructor(
LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity)
}
- val shouldStart = preferencesState.isEnabled &&
+ val shouldStart = preferencesState.isFeatureEnabled &&
+ preferencesState.isEnabled &&
preferencesState.isSupported &&
isStarted.value &&
!takeScreenshot.value &&
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
index e129bed8d5..b4f45a3bdc 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
@@ -10,15 +10,18 @@ package io.element.android.features.rageshake.impl.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
-import io.element.android.features.rageshake.api.rageshake.RageShake
-import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
+import io.element.android.features.rageshake.impl.rageshake.RageShake
+import io.element.android.features.rageshake.impl.rageshake.RageshakeDataStore
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -28,6 +31,7 @@ import javax.inject.Inject
class DefaultRageshakePreferencesPresenter @Inject constructor(
private val rageshake: RageShake,
private val rageshakeDataStore: RageshakeDataStore,
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : RageshakePreferencesPresenter {
@Composable
override fun present(): RageshakePreferencesState {
@@ -35,13 +39,14 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
val isSupported: MutableState = rememberSaveable {
mutableStateOf(rageshake.isAvailable())
}
- val isEnabled = rageshakeDataStore
- .isEnabled()
- .collectAsState(initial = false)
+ val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
+ val isEnabled by remember {
+ rageshakeDataStore.isEnabled()
+ }.collectAsState(initial = false)
- val sensitivity = rageshakeDataStore
- .sensitivity()
- .collectAsState(initial = 0f)
+ val sensitivity by remember {
+ rageshakeDataStore.sensitivity()
+ }.collectAsState(initial = 0f)
fun handleEvents(event: RageshakePreferencesEvents) {
when (event) {
@@ -51,9 +56,10 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
}
return RageshakePreferencesState(
- isEnabled = isEnabled.value,
+ isFeatureEnabled = isFeatureAvailable,
+ isEnabled = isEnabled,
isSupported = isSupported.value,
- sensitivity = sensitivity.value,
+ sensitivity = sensitivity,
eventSink = ::handleEvents
)
}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt
index 21c5fffdb7..651b71c079 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt
@@ -13,7 +13,6 @@ import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.seismic.ShakeDetector
-import io.element.android.features.rageshake.api.rageshake.RageShake
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt
index f9379de16d..9d7171b8a0 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt
@@ -15,7 +15,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageShake.kt
similarity index 91%
rename from features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt
rename to features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageShake.kt
index 548d11a41d..d75d5e5666 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageShake.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.api.rageshake
+package io.element.android.features.rageshake.impl.rageshake
interface RageShake {
/**
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt
similarity index 88%
rename from features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt
rename to features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt
index a59c3670d8..f13419bfb2 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.api.rageshake
+package io.element.android.features.rageshake.impl.rageshake
import kotlinx.coroutines.flow.Flow
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
index 4ec8500417..36ece91408 100755
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
@@ -13,10 +13,10 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
-import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
+import io.element.android.features.rageshake.impl.crash.CrashDataStore
+import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt
index 270c5628b3..dd3674ab26 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt
@@ -11,7 +11,6 @@ import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.AppScope
diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt
similarity index 84%
rename from features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt
rename to features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt
index 9a7e64da59..c746a573d3 100644
--- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.api.screenshot
+package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
index cae6f6e500..027e2fb38c 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
@@ -11,13 +11,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
-import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
-import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
-import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
-import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
+import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
+import io.element.android.features.rageshake.impl.crash.CrashDataStore
+import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
+import io.element.android.features.rageshake.impl.screenshot.A_SCREENSHOT_URI
+import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
+import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt
similarity index 88%
rename from features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt
rename to features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt
index 145e582dec..1a89a52bf7 100644
--- a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.test.crash
+package io.element.android.features.rageshake.impl.crash
-import io.element.android.features.rageshake.api.crash.CrashDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
index a5fa6671d7..92dfbb02a2 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
@@ -12,9 +12,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
+import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
-import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
-import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
+import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
@@ -51,6 +51,20 @@ class CrashDetectionPresenterTest {
}
}
+ @Test
+ fun `present - initial state crash is ignored if the feature is not available`() = runTest {
+ val presenter = createPresenter(
+ FakeCrashDataStore(appHasCrashed = true),
+ isFeatureAvailable = false,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.crashDetected).isFalse()
+ }
+ }
+
@Test
fun `present - reset app has crashed`() = runTest {
val presenter = createPresenter(
@@ -86,8 +100,10 @@ class CrashDetectionPresenterTest {
private fun createPresenter(
crashDataStore: FakeCrashDataStore = FakeCrashDataStore(),
buildMeta: BuildMeta = aBuildMeta(),
+ isFeatureAvailable: Boolean = true,
) = DefaultCrashDetectionPresenter(
buildMeta = buildMeta,
crashDataStore = crashDataStore,
+ rageshakeFeatureAvailability = { isFeatureAvailable },
)
}
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
index 5aa9679b8c..6f433a78d7 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
@@ -15,9 +15,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
-import io.element.android.features.rageshake.test.rageshake.FakeRageShake
-import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
-import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
+import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
+import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
+import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
@@ -52,6 +52,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
+ rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -76,6 +77,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
+ rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -101,6 +103,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
+ rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -135,6 +138,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
+ rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -169,6 +173,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
+ rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
index 796545c859..ba68345440 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
@@ -12,9 +12,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
-import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
-import io.element.android.features.rageshake.test.rageshake.FakeRageShake
-import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
+import io.element.android.features.rageshake.impl.rageshake.A_SENSITIVITY
+import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
+import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -28,7 +28,8 @@ class RageshakePreferencesPresenterTest {
fun `present - initial state available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
- FakeRageshakeDataStore(isEnabled = true)
+ FakeRageshakeDataStore(isEnabled = true),
+ rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -44,7 +45,8 @@ class RageshakePreferencesPresenterTest {
fun `present - initial state not available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = false),
- FakeRageshakeDataStore(isEnabled = true)
+ FakeRageshakeDataStore(isEnabled = true),
+ rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -60,7 +62,8 @@ class RageshakePreferencesPresenterTest {
fun `present - enable and disable`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
- FakeRageshakeDataStore(isEnabled = true)
+ FakeRageshakeDataStore(isEnabled = true),
+ rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -79,7 +82,8 @@ class RageshakePreferencesPresenterTest {
fun `present - set sensitivity`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
- FakeRageshakeDataStore(isEnabled = true)
+ FakeRageshakeDataStore(isEnabled = true),
+ rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt
similarity index 84%
rename from features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt
rename to features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt
index 1e3ac96770..b35bde295a 100644
--- a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt
@@ -5,9 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.test.rageshake
-
-import io.element.android.features.rageshake.api.rageshake.RageShake
+package io.element.android.features.rageshake.impl.rageshake
class FakeRageShake(
private var isAvailableValue: Boolean = true
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt
similarity index 87%
rename from features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt
rename to features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt
index b8fcc22d23..ec67ed13c0 100644
--- a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.test.rageshake
+package io.element.android.features.rageshake.impl.rageshake
-import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
index b38c52e6ef..e73c7863b4 100755
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
@@ -8,9 +8,10 @@
package io.element.android.features.rageshake.impl.reporter
import com.google.common.truth.Truth.assertThat
+import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.reporter.BugReporterListener
-import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
-import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
+import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
+import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata
@@ -138,7 +139,7 @@ class DefaultBugReporterTest {
val foundValues = collectValuesFromFormData(request)
- assertThat(foundValues["app"]).isEqualTo("element-x-android")
+ assertThat(foundValues["app"]).isEqualTo(RageshakeConfig.BUG_REPORT_APP_NAME)
assertThat(foundValues["can_contact"]).isEqualTo("true")
assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH")
assertThat(foundValues["sdk_sha"]).isEqualTo("123456789")
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt
index 415440b164..71563892dc 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt
@@ -16,7 +16,9 @@ class DefaultBugReporterUrlProviderTest {
@Test
fun `test DefaultBugReporterUrlProvider`() {
val sut = DefaultBugReporterUrlProvider()
- val result = sut.provide()
- assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl())
+ if (RageshakeConfig.BUG_REPORT_URL.isNotEmpty()) {
+ val result = sut.provide()
+ assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl())
+ }
}
}
diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt
similarity index 78%
rename from features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt
rename to features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt
index 3f7d83f1c2..8e37da1910 100644
--- a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt
@@ -5,10 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.features.rageshake.test.screenshot
+package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap
-import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
const val A_SCREENSHOT_URI = "file://content/uri"
diff --git a/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml
new file mode 100644
index 0000000000..4fd8f68bdf
--- /dev/null
+++ b/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Kunne ikke løse romalias."
+
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
index 57d1cc6df0..93c93c345a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
@@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
+ data class CopyToClipboard(val text: String) : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index df1daf6386..5c6333a290 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+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.matrix.api.MatrixClient
@@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
+import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
+ private val clipboardHelper: ClipboardHelper,
) : Presenter {
@Composable
override fun present(): RoomDetailsState {
@@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
}
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
- val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false)
+ val isKnockRequestsEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
+ }.collectAsState(false)
val knockRequestsCount by produceState(null) {
room.knockRequestsFlow.collect { value = it.size }
}
@@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
+ val snackbarDispatcher = LocalSnackbarDispatcher.current
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
+
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
RoomDetailsEvent.LeaveRoom ->
@@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
}
}
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
+ is RoomDetailsEvent.CopyToClipboard -> {
+ clipboardHelper.copyPlainText(event.text)
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
+ }
}
}
@@ -188,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
+ snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 5502d4e29a..8a0439b15d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -42,6 +43,7 @@ data class RoomDetailsState(
val canShowPinnedMessages: Boolean,
val canShowMediaGallery: Boolean,
val pinnedMessagesCount: Int?,
+ val snackbarMessage: SnackbarMessage?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val canShowSecurityAndPrivacy: Boolean,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index b2db46115c..4304f151a3 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -111,6 +112,7 @@ fun aRoomDetailsState(
canShowPinnedMessages: Boolean = true,
canShowMediaGallery: Boolean = true,
pinnedMessagesCount: Int? = null,
+ snackbarMessage: SnackbarMessage? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
canShowSecurityAndPrivacy: Boolean = true,
@@ -139,11 +141,12 @@ fun aRoomDetailsState(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
+ snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
- eventSink = eventSink
+ eventSink = eventSink,
)
fun aRoomNotificationSettings(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index 33985c44ee..381f94ad26 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
+import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -106,6 +109,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
+ val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
@@ -115,6 +119,7 @@ fun RoomDetailsView(
onActionClick = onActionClick
)
},
+ snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
@@ -135,6 +140,9 @@ fun RoomDetailsView(
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl)
},
+ onSubtitleClick = { subtitle ->
+ state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
+ }
)
}
is RoomDetailsType.Dm -> {
@@ -145,6 +153,9 @@ fun RoomDetailsView(
openAvatarPreview = { name, avatarUrl ->
openAvatarPreview(name, avatarUrl)
},
+ onSubtitleClick = { subtitle ->
+ state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
+ }
)
}
}
@@ -368,6 +379,7 @@ private fun RoomHeaderSection(
roomAlias: RoomAlias?,
heroes: ImmutableList,
openAvatarPreview: (url: String) -> Unit,
+ onSubtitleClick: (String) -> Unit,
) {
Column(
modifier = Modifier
@@ -384,7 +396,11 @@ private fun RoomHeaderSection(
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar)
)
- TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
+ TitleAndSubtitle(
+ title = roomName,
+ subtitle = roomAlias?.value,
+ onSubtitleClick = onSubtitleClick,
+ )
}
}
@@ -394,6 +410,7 @@ private fun DmHeaderSection(
otherMember: RoomMember,
roomName: String,
openAvatarPreview: (name: String, url: String) -> Unit,
+ onSubtitleClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -411,6 +428,7 @@ private fun DmHeaderSection(
TitleAndSubtitle(
title = roomName,
subtitle = otherMember.userId.value,
+ onSubtitleClick = onSubtitleClick,
)
}
}
@@ -419,6 +437,7 @@ private fun DmHeaderSection(
private fun TitleAndSubtitle(
title: String,
subtitle: String?,
+ onSubtitleClick: (String) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
@@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
+ modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
@@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
trailingContent =
- if (pinnedMessagesCount == null) {
- ListItemContent.Custom {
- CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
- }
- } else {
- ListItemContent.Text(pinnedMessagesCount.toString())
- },
+ if (pinnedMessagesCount == null) {
+ ListItemContent.Custom {
+ CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
+ }
+ } else {
+ ListItemContent.Text(pinnedMessagesCount.toString())
+ },
onClick = {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
onPinnedMessagesClick()
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
index 4001eb7edb..e5f63332aa 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
@@ -12,6 +12,7 @@ import dagger.Module
import dagger.Provides
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -25,6 +26,7 @@ object RoomMemberModule {
room: MatrixRoom,
userProfilePresenterFactory: UserProfilePresenterFactory,
encryptionService: EncryptionService,
+ clipboardHelper: ClipboardHelper,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@@ -33,6 +35,7 @@ object RoomMemberModule {
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
index 9a5d456cbe..c5de24c603 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
@@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
+import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
@@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId,
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
+ private val clipboardHelper: ClipboardHelper,
userProfilePresenterFactory: UserProfilePresenterFactory,
) : Presenter {
interface Factory {
@@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
+ val snackbarDispatcher = LocalSnackbarDispatcher.current
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val roomMember by room.getRoomMemberAsState(roomMemberId)
LaunchedEffect(Unit) {
// Update room member info when opening this screen
@@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
encryptionService.withdrawVerification(roomMemberId)
}
- else -> Unit
+ is UserProfileEvents.CopyToClipboard -> {
+ clipboardHelper.copyPlainText(event.text)
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
+ }
+ else -> userProfileState.eventSink(event)
}
}
@@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userName = roomUserName ?: userProfileState.userName,
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
verificationState = verificationState,
- eventSink = { event ->
- if (event is UserProfileEvents.WithdrawVerification) {
- eventSink(UserProfileEvents.WithdrawVerification)
- } else {
- userProfileState.eventSink(event)
- }
- }
+ snackbarMessage = snackbarMessage,
+ eventSink = ::eventSink
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationEvents.kt
index 824c239a91..eaabc8e4c2 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationEvents.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationEvents.kt
@@ -13,7 +13,9 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
+ data class DoKickUser(val reason: String) : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
+ data class DoBanUser(val reason: String) : RoomMembersModerationEvents
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenter.kt
index e9d4a91514..1fea6a8146 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenter.kt
@@ -96,20 +96,22 @@ class RoomMembersModerationPresenter @Inject constructor(
}
}
is RoomMembersModerationEvents.KickUser -> {
+ kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams
+ }
+ is RoomMembersModerationEvents.DoKickUser -> {
selectedMember?.let {
- coroutineScope.kickUser(it.userId, kickUserAsyncAction)
+ coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
- if (banUserAsyncAction.value.isConfirming()) {
- selectedMember?.let {
- coroutineScope.banUser(it.userId, banUserAsyncAction)
- }
- selectedMember = null
- } else {
- banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
+ banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
+ }
+ is RoomMembersModerationEvents.DoBanUser -> {
+ selectedMember?.let {
+ coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
+ selectedMember = null
}
is RoomMembersModerationEvents.UnbanUser -> {
// We are already confirming when we are reaching this point
@@ -138,18 +140,26 @@ class RoomMembersModerationPresenter @Inject constructor(
private fun CoroutineScope.kickUser(
userId: UserId,
+ reason: String,
kickUserAction: MutableState>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
- room.kickUser(userId)
+ room.kickUser(
+ userId = userId,
+ reason = reason.takeIf { it.isNotBlank() },
+ )
}
private fun CoroutineScope.banUser(
userId: UserId,
+ reason: String,
banUserAction: MutableState>,
) = runActionAndWaitForMembershipChange(banUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
- room.banUser(userId)
+ room.banUser(
+ userId = userId,
+ reason = reason.takeIf { it.isNotBlank() },
+ )
}
private fun CoroutineScope.unbanUser(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationStateProvider.kt
index 41e61d8351..9139100980 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationStateProvider.kt
@@ -37,10 +37,18 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider {
+ TextFieldDialog(
+ title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title),
+ submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action),
+ onSubmit = { reason ->
+ state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
+ },
+ onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
+ placeholder = stringResource(id = CommonStrings.common_reason),
+ label = stringResource(id = CommonStrings.common_reason),
+ content = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
+ value = "",
+ )
+ }
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
@@ -113,12 +128,17 @@ fun RoomMembersModerationView(
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
- ConfirmationDialog(
+ TextFieldDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
- content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
- onSubmitClick = { state.eventSink(RoomMembersModerationEvents.BanUser) },
- onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
+ onSubmit = { reason ->
+ state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
+ },
+ onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
+ placeholder = stringResource(id = CommonStrings.common_reason),
+ label = stringResource(id = CommonStrings.common_reason),
+ content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
+ value = "",
)
}
is AsyncAction.Loading -> {
diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml
index 3cd064109e..16d6fa18a4 100644
--- a/features/roomdetails/impl/src/main/res/values-el/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml
@@ -7,11 +7,11 @@
"Αποκλεισμός ατόμων"
"Αφαίρεση μηνυμάτων"
"Όλοι"
- "Πρόσκληση ατόμων"
+ "Προσκάλεσε άτομα και αποδέξου αιτήματα συμμετοχής"
"Συντονισμός μελών"
"Μηνύματα και περιεχόμενο"
"Διαχειριστές και συντονιστές"
- "Αφαίρεση ατόμων"
+ "Αφαίρεση ατόμων και απόρριψη αιτημάτων συμμετοχής"
"Αλλαγή avatar δωματίου"
"Λεπτομέρειες δωματίου"
"Αλλαγή ονόματος δωματίου"
diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml
index 8cf2c4a6a3..16f3d852b1 100644
--- a/features/roomdetails/impl/src/main/res/values-et/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml
@@ -75,6 +75,9 @@
- "%1$d osaleja"
- "%1$d osalejat"
+ "Eemalda"
+ "Uue kutse saamisel on tal võimalik selle jututoaga uuesti liituda."
+ "Kas sa oled kindel, et soovid selle osaleja eemaldada?"
"Eemalda ja sea suhtluskeeld"
"Eemalda kasutaja jututoast"
"Eemalda ja sea suhtluskeeld"
diff --git a/features/roomdetails/impl/src/main/res/values-eu/translations.xml b/features/roomdetails/impl/src/main/res/values-eu/translations.xml
index 8968f36d80..ecbdc614d4 100644
--- a/features/roomdetails/impl/src/main/res/values-eu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-eu/translations.xml
@@ -52,6 +52,7 @@
"Lehenetsia"
"Jakinarazpenak"
"Finkatutako mezuak"
+ "Profila"
"Sartzeko eskaerak"
"Rolak eta baimenak"
"Gelaren izena"
@@ -110,9 +111,12 @@
"Gelaren xehetasunak"
"Rolak eta baimenak"
"Gehitu gelaren helbidea"
+ "Bai, gaitu zifratzea"
"Zifratzea"
"Edonork aurkitu eta bat egin dezake"
"Edonork"
+ "Gonbidatutako pertsonak bakarrik sartu ahal izango dira"
+ "Gonbidapen bidez"
"Gelarako sarbidea"
"Gaur-gaurkoz ez da guneekin bateragarria"
"Guneko kideak"
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index 585f61ece5..e9cec9a02a 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -75,6 +75,9 @@
- "%1$d személy"
- "%1$d személy"
+ "Eltávolítás"
+ "Ehhez a szobához is csatlakozhat, ha meghívják."
+ "Biztos, hogy eltávolítja ezt a tagot?"
"Eltávolítás és a tag kitiltása"
"Eltávolítás a szobából"
"Eltávolítás és a tag kitiltása"
diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml
index d09ccd3cff..99c763ca9f 100644
--- a/features/roomdetails/impl/src/main/res/values-in/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml
@@ -7,11 +7,11 @@
"Cekal orang-orang"
"Hapus pesan"
"Semua orang"
- "Undang orang-orang"
+ "Undang orang-orang dan terima permintaan untuk bergabung"
"Moderasi anggota"
"Pesan dan konten"
"Admin dan moderator"
- "Keluarkan orang-orang"
+ "Keluarkan orang-orang dan tolak permintaan untuk bergabung"
"Ubah avatar ruangan"
"Detail ruangan"
"Ubah nama ruangan"
@@ -57,6 +57,7 @@
"Permintaan untuk bergabung"
"Peran dan perizinan"
"Nama ruangan"
+ "Keamanan & privasi"
"Keamanan"
"Bagikan ruangan"
"Info ruangan"
@@ -118,6 +119,9 @@
"Minta untuk bergabung"
"Enkripsi"
"Siapa pun"
+ "Hanya undangan"
+ "Akses ruangan"
"Siapa pun"
"Keterlihatan ruangan"
+ "Keamanan & privasi"
diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
index 4dd6f3fbb1..5ed7599258 100644
--- a/features/roomdetails/impl/src/main/res/values-nb/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
@@ -10,6 +10,7 @@
"Fjern meldinger"
"Alle"
"Inviter folk og godta forespørsler om å bli med"
+ "Moderering av medlemmer"
"Meldinger og innhold"
"Administratorer og moderatorer"
"Fjern folk og avslå forespørsler om å bli med"
@@ -25,6 +26,7 @@
"Du vil ikke kunne angre denne endringen ettersom du degraderer deg selv, og hvis du er den siste privilegerte brukeren i rommet, vil det være umulig å få tilbake privilegiene."
"Degradere deg selv?"
"%1$s (Venter)"
+ "(Venter)"
"Administratorer har automatisk moderatorrettigheter"
"Rediger moderatorer"
"Administratorer"
@@ -108,6 +110,7 @@
"Endre rollen min"
"Nedgradere til medlem"
"Nedgradere til moderator"
+ "Moderering av medlemmer"
"Meldinger og innhold"
"Moderatorer"
"Tillatelser"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 4c470cd1a7..39e2fccaa9 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -76,6 +76,9 @@
- "%1$d osoby"
- "%1$d osôb"
+ "Odstrániť"
+ "V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti."
+ "Ste si istý, že chcete odstrániť tohto člena?"
"Odstrániť a zakázať člena"
"Odstrániť z miestnosti"
"Odstrániť a zakázať člena"
diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
index aace77772d..988c1dfdbc 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -75,6 +75,9 @@
- "%1$d person"
- "%1$d personer"
+ "Ta bort"
+ "Denne kommer kunna gå med i rummet igen om denne bjuds in"
+ "Är du säker på att du vill ta bort den här medlemmen?"
"Ta bort och banna medlem"
"Ta bort från rummet"
"Ta bort och banna medlem"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index 62a7e48295..fb2cfc686e 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -75,6 +75,9 @@
- "%1$d person"
- "%1$d people"
+ "Remove"
+ "They will be able to join this room again if invited."
+ "Are you sure you want to remove this member?"
"Remove and ban member"
"Remove from room"
"Remove and ban member"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
index 07d10ede63..6c88ce8c2b 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
@@ -17,6 +17,8 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
+import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -81,6 +83,7 @@ class RoomDetailsPresenterTest {
),
isPinnedMessagesFeatureEnabled: Boolean = true,
encryptionService: FakeEncryptionService = FakeEncryptionService(),
+ clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@@ -92,6 +95,7 @@ class RoomDetailsPresenterTest {
Presenter { aUserProfileState() }
},
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
@@ -106,6 +110,7 @@ class RoomDetailsPresenterTest {
dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService,
+ clipboardHelper = clipboardHelper,
)
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
index 1506f50f42..41ff8706ae 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
@@ -17,6 +17,8 @@ import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
+import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -350,12 +352,14 @@ class RoomMemberDetailsPresenterTest {
}
},
encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }),
+ clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(
roomMemberId = UserId("@alice:server.org"),
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt
index d86fc49925..66e7f6bd90 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt
@@ -17,14 +17,18 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
@@ -153,13 +157,14 @@ class RoomMembersModerationPresenterTest {
}
@Test
- fun `present - Kick removes the user`() = runTest {
+ fun `present - Kick requires confirmation and then kicks the user`() = runTest {
val analyticsService = FakeAnalyticsService()
+ val kickUserResult = lambdaRecorder> { _, _ -> Result.success(Unit) }
val room = aMatrixRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
- kickUserResult = { _, _ -> Result.success(Unit) },
+ kickUserResult = kickUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
@@ -169,6 +174,10 @@ class RoomMembersModerationPresenterTest {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
+ // Confirm
+ confirmingState.eventSink(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty()
@@ -178,17 +187,22 @@ class RoomMembersModerationPresenterTest {
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
+ kickUserResult.assertions().isCalledOnce().with(
+ value(selectedMember.userId),
+ value(A_REASON),
+ )
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
+ val banUserResult = lambdaRecorder> { _, _ -> Result.success(Unit) }
val room = aMatrixRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
- banUserResult = { _, _ -> Result.success(Unit) },
+ banUserResult = banUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
@@ -200,9 +214,8 @@ class RoomMembersModerationPresenterTest {
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
-
// Confirm
- confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
+ confirmingState.eventSink(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
skipItems(1)
val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty()
@@ -213,6 +226,10 @@ class RoomMembersModerationPresenterTest {
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
+ banUserResult.assertions().isCalledOnce().with(
+ value(selectedMember.userId),
+ value(A_REASON),
+ )
}
}
@@ -289,7 +306,7 @@ class RoomMembersModerationPresenterTest {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
- awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
+ awaitItem().eventSink(RoomMembersModerationEvents.DoKickUser(reason = ""))
skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -299,8 +316,7 @@ class RoomMembersModerationPresenterTest {
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
- awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
- awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
+ awaitItem().eventSink(RoomMembersModerationEvents.DoBanUser(reason = ""))
skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationViewTest.kt
index 6fc9b8a20f..14c550d0c4 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationViewTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationViewTest.kt
@@ -10,11 +10,14 @@ package io.element.android.features.roomdetails.impl.members.moderation
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
@@ -95,6 +98,58 @@ class RoomMembersModerationViewTest {
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
}
+ @Test
+ fun `cancelling 'Remove member' confirmation emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val roomMember = anAlice()
+ val state = aRoomMembersModerationState(
+ selectedRoomMember = roomMember,
+ kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
+ eventSink = eventsRecorder
+ )
+ rule.setRoomMembersModerationView(
+ state = state,
+ )
+ // Note: the string key semantics is not perfect here :/
+ rule.clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
+ }
+
+ @Test
+ fun `confirming 'Remove member' reason edition then validation emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val roomMember = anAlice()
+ val state = aRoomMembersModerationState(
+ selectedRoomMember = roomMember,
+ kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
+ eventSink = eventsRecorder
+ )
+ rule.setRoomMembersModerationView(
+ state = state,
+ )
+ val reason = rule.activity.getString(CommonStrings.common_reason)
+ rule.onNodeWithText(reason).performTextInput(A_REASON)
+ rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
+ eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
+ }
+
+ @Test
+ fun `confirming 'Remove member' confirmation emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val roomMember = anAlice()
+ val state = aRoomMembersModerationState(
+ selectedRoomMember = roomMember,
+ kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
+ eventSink = eventsRecorder
+ )
+ rule.setRoomMembersModerationView(
+ state = state,
+ )
+ // Note: the string key semantics is not perfect here :/
+ rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
+ eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = ""))
+ }
+
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on 'Remove and ban member' emits the expected event`() {
@@ -136,6 +191,24 @@ class RoomMembersModerationViewTest {
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
+ @Test
+ fun `confirming 'Remove and ban member' reason edition emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val roomMember = anAlice()
+ val state = aRoomMembersModerationState(
+ selectedRoomMember = roomMember,
+ banUserAsyncAction = AsyncAction.ConfirmingNoParams,
+ eventSink = eventsRecorder
+ )
+ rule.setRoomMembersModerationView(
+ state = state,
+ )
+ val reason = rule.activity.getString(CommonStrings.common_reason)
+ rule.onNodeWithText(reason).performTextInput(A_REASON)
+ rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
+ eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
+ }
+
@Test
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder()
@@ -150,7 +223,7 @@ class RoomMembersModerationViewTest {
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
- eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
+ eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = ""))
}
@Test
diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts
index a39971b415..a6f09ff4ed 100644
--- a/features/roomlist/impl/build.gradle.kts
+++ b/features/roomlist/impl/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
implementation(projects.features.leaveroom.api)
+ implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences)
api(projects.features.roomlist.api)
@@ -60,6 +61,9 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
+ testImplementation(projects.features.invite.test)
+ testImplementation(projects.features.logout.test)
+ testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.dateformatter.test)
@@ -71,7 +75,5 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
- testImplementation(projects.features.networkmonitor.test)
- testImplementation(projects.features.logout.test)
testImplementation(projects.tests.testutils)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
index 64348f623c..7435424b15 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
@@ -11,8 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
+import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentSet
open class RoomListContentStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -29,10 +31,12 @@ internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
+ seenRoomInvites: Set = emptySet(),
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
summaries = summaries,
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index f3e7daf4d7..461204b2a2 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -24,12 +24,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.logout.api.direct.DirectLogoutState
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -49,7 +51,6 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
@@ -57,6 +58,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -91,6 +93,8 @@ class RoomListPresenter @Inject constructor(
private val notificationCleaner: NotificationCleaner,
private val logoutPresenter: Presenter,
private val appPreferencesStore: AppPreferencesStore,
+ private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
private val encryptionService: EncryptionService = client.encryptionService()
@@ -99,10 +103,11 @@ class RoomListPresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val matrixUser = client.userProfile.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -163,6 +168,7 @@ class RoomListPresenter @Inject constructor(
contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
+ canReportBug = canReportBug,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
@@ -224,6 +230,7 @@ class RoomListPresenter @Inject constructor(
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
+ val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
return when {
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
@@ -232,7 +239,8 @@ class RoomListPresenter @Inject constructor(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
- summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
+ summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
}
}
@@ -307,6 +315,8 @@ class RoomListPresenter @Inject constructor(
private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
client.getRoom(roomId)?.use { room ->
+ // Ideally we wouldn't have a live timeline at this point, but right now we instantiate one when retrieving the room
+ room.liveTimeline.close()
room.clearEventCacheStorage()
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
index 07994ef8cb..ae62b88deb 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermiss
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
@Immutable
data class RoomListState(
@@ -29,6 +30,7 @@ data class RoomListState(
val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
+ val canReportBug: Boolean,
val searchState: RoomListSearchState,
val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
@@ -64,9 +66,11 @@ sealed interface RoomListContentState {
data class Empty(
val securityBannerState: SecurityBannerState,
) : RoomListContentState
+
data class Rooms(
val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val summaries: ImmutableList,
+ val seenRoomInvites: ImmutableSet,
) : RoomListContentState
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
index 4c21d797f1..6b075db9df 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
@@ -57,6 +57,7 @@ internal fun aRoomListState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
+ canReportBug: Boolean = true,
contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
directLogoutState: DirectLogoutState = aDirectLogoutState(),
@@ -69,6 +70,7 @@ internal fun aRoomListState(
contextMenu = contextMenu,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
+ canReportBug = canReportBug,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 8a5ade09d2..64ec1f8406 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -127,6 +127,7 @@ private fun RoomListScaffold(
displayMenuItems = state.displayActions,
displayFilters = state.displayFilters,
filtersState = state.filtersState,
+ canReportBug = state.canReportBug,
)
},
content = { padding ->
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index ebd618185c..acdb762fa7 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -46,6 +46,7 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
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
@@ -239,6 +240,8 @@ private fun RoomsViewList(
) { index, room ->
RoomSummaryRow(
room = room,
+ isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
+ state.seenRoomInvites.contains(room.roomId),
onClick = onRoomClick,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
index a0e9078530..1f4c86f579 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
@@ -85,6 +85,7 @@ fun RoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
+ canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
DefaultRoomListTopBar(
@@ -98,6 +99,7 @@ fun RoomListTopBar(
displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
+ canReportBug = canReportBug,
modifier = modifier,
)
}
@@ -115,6 +117,7 @@ private fun DefaultRoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
+ canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
@@ -239,7 +242,7 @@ private fun DefaultRoomListTopBar(
}
)
}
- if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM) {
+ if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem(
onClick = {
showMenu = false
@@ -319,6 +322,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ canReportBug = true,
onMenuActionClick = {},
)
}
@@ -337,6 +341,7 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ canReportBug = true,
onMenuActionClick = {},
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
index 0fbd4d38fe..bfa255af13 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
@@ -57,7 +57,6 @@ import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
-import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender
@@ -69,6 +68,7 @@ internal val minHeight = 84.dp
@Composable
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
+ isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
@@ -86,8 +86,8 @@ internal fun RoomSummaryRow(
Timber.d("Long click on invite room")
},
) {
- InviteNameAndIndicatorRow(name = room.name)
- InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
+ InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
+ InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
InviteSenderView(
@@ -232,13 +232,12 @@ private fun NameAndTimestampRow(
private fun InviteSubtitle(
isDm: Boolean,
inviteSender: InviteSender?,
- canonicalAlias: RoomAlias?,
modifier: Modifier = Modifier
) {
val subtitle = if (isDm) {
inviteSender?.userId?.value
} else {
- canonicalAlias?.value
+ null
}
if (subtitle != null) {
Text(
@@ -302,6 +301,7 @@ private fun LastMessageAndIndicatorRow(
@Composable
private fun InviteNameAndIndicatorRow(
name: String?,
+ isInviteSeen: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -318,9 +318,11 @@ private fun InviteNameAndIndicatorRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- UnreadIndicatorAtom(
- color = ElementTheme.colors.unreadIndicator
- )
+ if (!isInviteSeen) {
+ UnreadIndicatorAtom(
+ color = ElementTheme.colors.unreadIndicator
+ )
+ }
}
}
@@ -386,6 +388,8 @@ private fun MentionIndicatorAtom() {
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
RoomSummaryRow(
room = data,
+ // Set isInviteSeen to true for the preview when the room has name "Bob"
+ isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt
index 81bae26b4a..214ac44cfd 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt
@@ -10,7 +10,6 @@ package io.element.android.features.roomlist.impl.filters
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
@@ -48,7 +47,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
-@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RoomListFiltersView(
state: RoomListFiltersState,
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
index 498ad762a3..cb4c48d3f7 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
@@ -39,12 +39,10 @@ data class RoomListRoomSummary(
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
index 90b4fba44c..4f6e783704 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
@@ -173,6 +173,8 @@ private fun RoomListSearchContent(
) { room ->
RoomSummaryRow(
room = room,
+ // TODO
+ isInviteSeen = false,
onClick = ::onRoomClick,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/main/res/values-nb/translations.xml b/features/roomlist/impl/src/main/res/values-nb/translations.xml
index a97826a0c3..15cfe25708 100644
--- a/features/roomlist/impl/src/main/res/values-nb/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-nb/translations.xml
@@ -5,6 +5,7 @@
"Konfigurer gjenoppretting for å beskytte kontoen din"
"Skriv inn gjenopprettingsnøkkelen din"
"Har du glemt din gjenopprettingsnøkkel?"
+ "Nøkkellagringen din er ikke synkronisert"
"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."
"Forbedre samtaleopplevelsen din"
"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index a48d63cad8..50e7628d09 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -12,13 +12,16 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.logout.api.direct.aDirectLogoutState
+import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
@@ -105,12 +108,14 @@ class RoomListPresenterTest {
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createRoomListPresenter(
client = matrixClient,
+ rageshakeFeatureAvailability = { false },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
+ assertThat(initialState.canReportBug).isFalse()
val withUserState = awaitItem()
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
@@ -135,6 +140,7 @@ class RoomListPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
+ assertThat(initialState.canReportBug).isTrue()
sessionVerificationService.emitNeedsSessionVerification(false)
encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem()
@@ -165,10 +171,11 @@ class RoomListPresenterTest {
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(client = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val presenter = createRoomListPresenter(
+ client = matrixClient,
+ seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
+ )
+ presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
@@ -190,6 +197,7 @@ class RoomListPresenterTest {
timestamp = "0 TimeOrDate true",
)
)
+ assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
cancelAndIgnoreRemainingEvents()
}
}
@@ -675,6 +683,8 @@ class RoomListPresenterTest {
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore()
) = RoomListPresenter(
client = client,
syncService = syncService,
@@ -705,6 +715,8 @@ class RoomListPresenterTest {
notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,
+ rageshakeFeatureAvailability = rageshakeFeatureAvailability,
+ seenInvitesStore = seenInvitesStore,
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
index caa204328b..a7940a203b 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
@@ -65,12 +65,12 @@ class RoomListRoomSummaryTest {
}
@Test
- fun `when display type is invite then isHighlighted and hasNewContent are true`() {
+ fun `when display type is invite then isHighlighted and hasNewContent are false`() {
val sut = createRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
)
- assertThat(sut.isHighlighted).isTrue()
- assertThat(sut.hasNewContent).isTrue()
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isFalse()
}
}
diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml
index 3ba3bd98ee..f03a7d68d6 100644
--- a/features/securebackup/impl/src/main/res/values-cs/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml
@@ -62,9 +62,9 @@
"Nastavení obnovy"
"Ano, resetovat nyní"
"Tento proces je nevratný."
- "Opravdu chcete obnovit šifrování?"
+ "Opravdu chcete obnovit svou identitu?"
"Došlo k neznámé chybě. Zkontrolujte, zda je heslo k účtu správné a zkuste to znovu."
"Zadejte…"
- "Potvrďte, že chcete obnovit šifrování."
+ "Potvrďte, že chcete obnovit svou identitu."
"Pro pokračování zadejte heslo k účtu"
diff --git a/features/securebackup/impl/src/main/res/values-nb/translations.xml b/features/securebackup/impl/src/main/res/values-nb/translations.xml
index 7d7401473e..1123e3ef2c 100644
--- a/features/securebackup/impl/src/main/res/values-nb/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-nb/translations.xml
@@ -1,11 +1,14 @@
+ "Slett nøkkellagring"
"Slå på sikkerhetskopiering"
"Lagre din kryptografiske identitet og meldingsnøkler sikkert på serveren. Dette gjør at du kan se meldingshistorikken din på alle nye enheter. %1$s."
+ "Nøkkellagring"
"Last opp nøkler fra denne enheten"
"Endre gjenopprettingsnøkkel"
"Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."
"Skriv inn gjenopprettingsnøkkel"
+ "Nøkkellagringen din er for øyeblikket ikke synkronisert."
"Konfigurer gjenoppretting"
"Få tilgang til de krypterte meldingene dine hvis du mister alle enhetene dine eller blir logget ut av %1$s overalt."
"Logg på kontoen din igjen"
@@ -25,6 +28,7 @@
"Er du sikker på at du vil slå av sikkerhetskopiering?"
"Du vil ikke ha kryptert meldingshistorikk på nye enheter"
"Du mister tilgangen til de krypterte meldingene dine hvis du er logget ut av %1$s overalt"
+ "Er du sikker på at du vil slå av nøkkellagring og slette den?"
"Få en ny gjenopprettingsnøkkel hvis du har mistet den eksisterende. Etter at du har endret gjenopprettingsnøkkelen, vil den gamle ikke lenger fungere."
"Generer en ny gjenopprettingsnøkkel"
"Ikke del dette med noen!"
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
index 866170daa3..66cb147158 100644
--- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -116,7 +117,8 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom(
diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
index dd7c24ab35..773954c3e0 100644
--- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
+++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
@@ -34,7 +34,9 @@ class SignedOutPresenter @AssistedInject constructor(
@Composable
override fun present(): SignedOutState {
- val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList())
+ val sessions by remember {
+ sessionStore.sessionsFlow()
+ }.collectAsState(initial = emptyList())
val signedOutSession by remember {
derivedStateOf { sessions.firstOrNull { it.userId == sessionId } }
}
diff --git a/features/userprofile/api/build.gradle.kts b/features/userprofile/api/build.gradle.kts
index b2c1068556..8bdaa8d77e 100644
--- a/features/userprofile/api/build.gradle.kts
+++ b/features/userprofile/api/build.gradle.kts
@@ -16,5 +16,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
}
diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
index b7b7ba2561..4a5f6bb415 100644
--- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
+++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
@@ -15,4 +15,5 @@ sealed interface UserProfileEvents {
data object ClearBlockUserError : UserProfileEvents
data object ClearConfirmationDialog : UserProfileEvents
data object WithdrawVerification : UserProfileEvents
+ data class CopyToClipboard(val text: String) : UserProfileEvents
}
diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
index f32033b0a7..b9f08a27f3 100644
--- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
+++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
@@ -9,6 +9,7 @@ package io.element.android.features.userprofile.api
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -23,6 +24,7 @@ data class UserProfileState(
val isCurrentUser: Boolean,
val dmRoomId: RoomId?,
val canCall: Boolean,
+ val snackbarMessage: SnackbarMessage?,
val eventSink: (UserProfileEvents) -> Unit
) {
enum class ConfirmationDialog {
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
index 4216287a70..c098177529 100644
--- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
@@ -120,8 +120,9 @@ class UserProfilePresenter @AssistedInject constructor(
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
- // Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed
- UserProfileEvents.WithdrawVerification -> Unit
+ // Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed
+ UserProfileEvents.WithdrawVerification,
+ is UserProfileEvents.CopyToClipboard -> Unit
}
}
@@ -136,6 +137,7 @@ class UserProfilePresenter @AssistedInject constructor(
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
+ snackbarMessage = null,
eventSink = ::handleEvents
)
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
index 3df57b9eb0..da88c0f508 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
@@ -13,9 +13,11 @@ 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.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -28,6 +30,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRow
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
@@ -46,6 +49,7 @@ fun UserProfileHeaderSection(
userName: String?,
verificationState: UserProfileVerificationState,
openAvatarPreview: (url: String) -> Unit,
+ onUserIdClick: () -> Unit,
withdrawVerificationClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -58,6 +62,7 @@ fun UserProfileHeaderSection(
Avatar(
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
modifier = Modifier
+ .clip(CircleShape)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.memberDetailAvatar)
)
@@ -72,6 +77,7 @@ fun UserProfileHeaderSection(
Spacer(modifier = Modifier.height(6.dp))
}
Text(
+ modifier = Modifier.niceClickable { onUserIdClick() },
text = userId.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
@@ -122,6 +128,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
userName = "Alice",
verificationState = UserProfileVerificationState.VERIFIED,
openAvatarPreview = {},
+ onUserIdClick = {},
withdrawVerificationClick = {},
)
}
@@ -135,6 +142,7 @@ internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = Elemen
userName = "Alice",
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
openAvatarPreview = {},
+ onUserIdClick = {},
withdrawVerificationClick = {},
)
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
index be9fad9c94..7a5cc53239 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
@@ -14,6 +14,7 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -45,6 +46,7 @@ fun aUserProfileState(
isCurrentUser: Boolean = false,
dmRoomId: RoomId? = null,
canCall: Boolean = false,
+ snackbarMessage: SnackbarMessage? = null,
eventSink: (UserProfileEvents) -> Unit = {},
) = UserProfileState(
userId = userId,
@@ -57,5 +59,6 @@ fun aUserProfileState(
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
+ snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
index 0c544cf659..a43478e466 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
@@ -38,6 +38,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
+import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
@@ -55,17 +57,19 @@ fun UserProfileView(
onVerifyClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
+ val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
},
+ snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .verticalScroll(rememberScrollState())
) {
UserProfileHeaderSection(
avatarUrl = state.avatarUrl,
@@ -75,6 +79,9 @@ fun UserProfileView(
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
},
+ onUserIdClick = {
+ state.eventSink(UserProfileEvents.CopyToClipboard(state.userId.value))
+ },
withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) },
)
UserProfileMainActionsSection(
diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml
index 9b59cb2041..bdf73c53f1 100644
--- a/features/verifysession/impl/src/main/res/values-cs/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml
@@ -41,7 +41,7 @@
"Otevřete aplikaci na jiném ověřeném zařízení"
"Pro větší bezpečnost ověřte tohoto uživatele porovnáním sady emotikonů na svých zařízeních. Proveďte to pomocí důvěryhodného způsobu komunikace."
"Ověřte tohoto uživatele?"
- "Pro větší bezpečnost chce jiný uživatel ověřit vaši totožnost. Zobrazí se vám sada emotikonů k porovnání."
+ "Pro větší bezpečnost chce jiný uživatel ověřit vaši identitu. Zobrazí se vám sada emotikonů k porovnání."
"Na druhém zařízení byste měli vidět vyskakovací okno. Začněte s ověrením tam."
"Spusťte ověření na druhém zařízení"
"Čekání na druhé zařízení"
diff --git a/features/verifysession/impl/src/main/res/values-eu/translations.xml b/features/verifysession/impl/src/main/res/values-eu/translations.xml
index 999e7ca75d..2497e73069 100644
--- a/features/verifysession/impl/src/main/res/values-eu/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-eu/translations.xml
@@ -21,11 +21,26 @@
"Saiatu berriro egiaztatzen"
"Prest nago"
"Bat etorriko zain…"
+ "Alderatu emojiak eta egiaztatu ordena berean ageri direla."
"Saioa hasita"
"Egiaztapenak huts egin du"
+ "Egiaztapen hau zeuk hasi baduzu bakarrik jarraitu."
+ "Egiaztatu beste gailua zure mezuen historia seguru mantentzeko."
+ "Orain mezuak modu seguruan irakurri edo bidal ditzakezu beste gailuan."
"Gailua egiaztatu da"
+ "Egiaztapena eskatu da"
"Ez datoz bat"
"Bat datoz"
+ "Ziurtatu aplikazioa irekita duzula beste gailuan hemendik egiaztatzea hasi aurretik."
+ "Ireki aplikazioa egiaztatutako beste gailu batean"
+ "Segurtasun handiagorako, egiaztatu erabiltzailea emoji multzo bat alderatuz. Egin hau komunikatzeko modu fidagarri bat erabiliz."
+ "Erabiltzailea egiaztatu?"
+ "Segurtasun handiagorako, beste erabiltzaile batek zure identitatea egiaztatu nahi du. Emoji sorta bat erakutsiko zaizu konparatzeko."
+ "Beste gailuan laster-menu bat ikusi beharko zenuke. Hasi egiaztapena hortik orain."
+ "Hasi egiaztapena beste gailuan"
+ "Beste gailuaren zain"
+ "Beste erabiltzailearen zain"
+ "Onartutakoan egiaztapenarekin jarraitu ahal izango duzu."
"Jarraitzeko, onartu zure beste saioan egiaztapen-prozesua hasteko eskaera."
"Eskaera onartzeko zain"
"Saioa amaitzen…"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a293b34b65..c5bde3385a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,9 +4,9 @@
[versions]
# Project
android_gradle_plugin = "8.9.1"
-kotlin = "2.1.10"
+kotlin = "2.1.20"
kotlinpoet = "2.1.0"
-ksp = "2.1.10-1.0.31"
+ksp = "2.1.20-1.0.32"
firebaseAppDistribution = "5.1.1"
# AndroidX
@@ -21,11 +21,11 @@ constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
lifecycle = "2.8.7"
activity = "1.10.1"
-media3 = "1.5.1"
-camera = "1.4.1"
+media3 = "1.6.0"
+camera = "1.4.2"
# Compose
-compose_bom = "2025.03.00"
+compose_bom = "2025.03.01"
composecompiler = "1.5.15"
# Coroutines
@@ -39,18 +39,18 @@ test_core = "1.6.1"
# Jetbrain
datetime = "0.6.2"
-serialization_json = "1.8.0"
+serialization_json = "1.8.1"
#other
coil = "3.1.0"
showkase = "1.0.3"
-appyx = "1.6.0"
+appyx = "1.7.0"
sqldelight = "2.0.2"
wysiwyg = "2.38.3"
telephoto = "0.15.1"
# Dependency analysis
-dependencyAnalysis = "2.13.2"
+dependencyAnalysis = "2.14.0"
# DI
dagger = "2.56.1"
@@ -77,7 +77,7 @@ kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", ve
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:33.11.0"
+google_firebase_bom = "com.google.firebase:firebase-bom:33.12.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@@ -164,7 +164,7 @@ coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
-compound = { module = "io.element.android:compound-android", version = "25.2.26" }
+compound = { module = "io.element.android:compound-android", version = "25.4.4" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
@@ -174,7 +174,7 @@ jsoup = "org.jsoup:jsoup:1.19.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.24"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.4.8"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -188,15 +188,15 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
-maplibre = "org.maplibre.gl:android-sdk:11.8.4"
+maplibre = "org.maplibre.gl:android-sdk:11.8.5"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.12.0"
-sentry = "io.sentry:sentry-android:8.5.0"
+posthog = "com.posthog:posthog-android:3.13.1"
+sentry = "io.sentry:sentry-android:8.6.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
@@ -212,7 +212,7 @@ anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref
anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" }
# Element Call
-element_call_embedded = "io.element.android:element-call-embedded:0.9.0-rc.4"
+element_call_embedded = "io.element.android:element-call-embedded:0.9.0"
# Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
@@ -239,7 +239,7 @@ anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.8"
ktlint = "org.jlleitschuh.gradle.ktlint:12.2.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
-dependencycheck = "org.owasp.dependencycheck:12.1.0"
+dependencycheck = "org.owasp.dependencycheck:12.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index 48dbcade47..abd6e10d1f 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -98,3 +98,22 @@ fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERR
fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR)
fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR }
+
+/**
+ * This works around https://github.com/element-hq/element-x-android/issues/2105.
+ * @param maxLength Max characters to retrieve. Defaults to `500`.
+ * @param ellipsize Whether to add an ellipsis (`…`) char at the end or not. Defaults to `false`.
+ * @return The string truncated to [maxLength] characters, with an optional ellipsis if larger.
+ */
+fun String.toSafeLength(
+ maxLength: Int = 500,
+ ellipsize: Boolean = false,
+): String {
+ return if (ellipsize) {
+ ellipsize(maxLength)
+ } else if (length > maxLength) {
+ take(maxLength)
+ } else {
+ this
+ }
+}
diff --git a/libraries/dateformatter/impl/src/main/res/values-el/translations.xml b/libraries/dateformatter/impl/src/main/res/values-el/translations.xml
index 3b337d1e29..63df4a0070 100644
--- a/libraries/dateformatter/impl/src/main/res/values-el/translations.xml
+++ b/libraries/dateformatter/impl/src/main/res/values-el/translations.xml
@@ -1,4 +1,5 @@
+ "%1$s στις %2$s"
"Αυτό το μήνα"
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
index 4da9f58f69..51507064eb 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
@@ -14,14 +14,10 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.badgeInfoBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgeInfoContentColor
import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNeutralContentColor
-import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
object MatrixBadgeAtom {
data class MatrixBadgeData(
@@ -42,22 +38,22 @@ object MatrixBadgeAtom {
data: MatrixBadgeData,
) {
val backgroundColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor
+ Type.Positive -> ElementTheme.colors.bgBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
- Type.Info -> ElementTheme.colors.badgeInfoBackgroundColor
+ Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val textColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.badgePositiveContentColor
+ Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
- Type.Info -> ElementTheme.colors.badgeInfoContentColor
+ Type.Info -> ElementTheme.colors.textBadgeInfo
}
val iconColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.iconSuccessPrimary
+ Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.iconSecondary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
- Type.Info -> ElementTheme.colors.iconInfoPrimary
+ Type.Info -> ElementTheme.colors.textBadgeInfo
}
Badge(
text = data.text,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
index d49e0a41e0..5e2160a01f 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
@@ -25,8 +25,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@@ -76,9 +74,9 @@ internal fun BadgePreview() {
Badge(
text = "Trusted",
icon = CompoundIcons.Verified(),
- backgroundColor = ElementTheme.colors.badgePositiveBackgroundColor,
- textColor = ElementTheme.colors.badgePositiveContentColor,
- iconColor = ElementTheme.colors.iconSuccessPrimary,
+ backgroundColor = ElementTheme.colors.bgBadgeAccent,
+ textColor = ElementTheme.colors.textBadgeAccent,
+ iconColor = ElementTheme.colors.textBadgeAccent,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
index 169403104a..3638556da3 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
@@ -135,7 +135,7 @@ fun ClickableLinkText(
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this
- val spannable = SpannableString(this.text)
+ val spannable = SpannableString.valueOf(this.text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt
index 0f8f1e43d8..944fca1cf4 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.designsystem.components.dialogs
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@@ -38,6 +39,7 @@ fun ListDialog(
cancelText: String = stringResource(CommonStrings.action_cancel),
submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true,
+ applyPaddingToContents: Boolean = true,
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@@ -61,6 +63,7 @@ fun ListDialog(
onSubmitClick = onSubmit,
enabled = enabled,
listItems = listItems,
+ applyPaddingToContents = applyPaddingToContents,
)
}
}
@@ -72,8 +75,9 @@ private fun ListDialogContent(
onSubmitClick: () -> Unit,
cancelText: String,
submitText: String,
- title: String? = null,
- enabled: Boolean = true,
+ title: String?,
+ enabled: Boolean,
+ applyPaddingToContents: Boolean,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@@ -84,10 +88,13 @@ private fun ListDialogContent(
onCancelClick = onDismissRequest,
onSubmitClick = onSubmitClick,
enabled = enabled,
- applyPaddingToContents = false,
+ applyPaddingToContents = applyPaddingToContents,
) {
+ // No start padding if padding is already applied to the content
+ val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
LazyColumn(
- modifier = Modifier.padding(start = 8.dp)
+ modifier = Modifier.padding(horizontal = horizontalPadding),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) { listItems() }
}
}
@@ -111,6 +118,8 @@ internal fun ListDialogContentPreview() {
onSubmitClick = {},
cancelText = "Cancel",
submitText = "Save",
+ enabled = true,
+ applyPaddingToContents = true,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt
new file mode 100644
index 0000000000..bb31edd313
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.designsystem.components.dialogs
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.components.list.TextFieldListItem
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun TextFieldDialog(
+ title: String,
+ onSubmit: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+ value: String?,
+ placeholder: String?,
+ modifier: Modifier = Modifier,
+ validation: (String?) -> Boolean = { true },
+ onValidationErrorMessage: String? = null,
+ autoSelectOnDisplay: Boolean = true,
+ maxLines: Int = 1,
+ content: String? = null,
+ label: String? = null,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ submitText: String = stringResource(CommonStrings.action_ok),
+) {
+ val focusRequester = remember { FocusRequester() }
+ var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(
+ TextFieldValue(
+ value.orEmpty(),
+ selection = TextRange(value.orEmpty().length)
+ )
+ )
+ }
+ var error by rememberSaveable { mutableStateOf(if (!validation(value.orEmpty())) onValidationErrorMessage else null) }
+ var canRequestFocus by rememberSaveable { mutableStateOf(false) }
+ val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
+ ListDialog(
+ title = title,
+ onSubmit = { onSubmit(textFieldContents.text) },
+ onDismissRequest = onDismissRequest,
+ enabled = canSubmit,
+ submitText = submitText,
+ modifier = modifier,
+ ) {
+ if (content != null) {
+ item {
+ Text(
+ text = content,
+ style = ElementTheme.materialTypography.bodyMedium,
+ )
+ }
+ }
+ item {
+ TextFieldListItem(
+ placeholder = placeholder.orEmpty(),
+ label = label,
+ text = textFieldContents,
+ onTextChange = {
+ error = if (!validation(it.text)) onValidationErrorMessage else null
+ textFieldContents = it
+ },
+ error = error,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = KeyboardActions(onAny = {
+ if (validation(textFieldContents.text)) {
+ onSubmit(textFieldContents.text)
+ }
+ }),
+ maxLines = maxLines,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ )
+ canRequestFocus = true
+ }
+ }
+
+ if (autoSelectOnDisplay && canRequestFocus) {
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun TextFieldDialogPreview() = ElementPreview {
+ TextFieldDialog(
+ title = "Title",
+ value = "",
+ placeholder = "Placeholder",
+ onSubmit = {},
+ onDismissRequest = {},
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun TextFieldDialogWithErrorPreview() = ElementPreview {
+ TextFieldDialog(
+ title = "Title",
+ content = "Some content",
+ onSubmit = {},
+ validation = { false },
+ onDismissRequest = {},
+ value = "Value",
+ placeholder = "Placeholder",
+ label = "Label",
+ onValidationErrorMessage = "Error message",
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt
index 299b41ad94..f9d61b6593 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt
@@ -9,17 +9,14 @@ package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
-import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
-import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextField
+import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
@Composable
fun TextFieldListItem(
@@ -29,26 +26,19 @@ fun TextFieldListItem(
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
+ label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
- val textFieldStyle = ElementTheme.materialTypography.bodyLarge
-
- OutlinedTextField(
+ TextField(
value = text,
onValueChange = { onTextChange(it) },
- placeholder = placeholder?.let { @Composable { Text(it) } },
- colors = OutlinedTextFieldDefaults.colors(
- disabledBorderColor = Color.Transparent,
- errorBorderColor = Color.Transparent,
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- ),
- isError = error != null,
- supportingText = error?.let { @Composable { Text(it) } },
+ placeholder = placeholder,
+ label = label,
+ validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
+ supportingText = error,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
- textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
@@ -63,26 +53,19 @@ fun TextFieldListItem(
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
+ label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
- val textFieldStyle = ElementTheme.materialTypography.bodyLarge
-
- OutlinedTextField(
+ TextField(
value = text,
onValueChange = { onTextChange(it) },
- placeholder = placeholder?.let { @Composable { Text(it) } },
- colors = OutlinedTextFieldDefaults.colors(
- disabledBorderColor = Color.Transparent,
- errorBorderColor = Color.Transparent,
- focusedBorderColor = Color.Transparent,
- unfocusedBorderColor = Color.Transparent,
- ),
- isError = error != null,
- supportingText = error?.let { @Composable { Text(it) } },
+ placeholder = placeholder,
+ label = label,
+ validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
+ supportingText = error,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
- textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
index 8810c83f54..98f324f88a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt
@@ -7,24 +7,15 @@
package io.element.android.libraries.designsystem.components.preferences
-import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.text.TextRange
-import androidx.compose.ui.text.input.TextFieldValue
-import io.element.android.libraries.designsystem.components.dialogs.ListDialog
+import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
-import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@@ -74,58 +65,3 @@ fun PreferenceTextField(
)
}
}
-
-@Composable
-private fun TextFieldDialog(
- title: String,
- onSubmit: (String) -> Unit,
- onDismissRequest: () -> Unit,
- value: String?,
- placeholder: String?,
- validation: (String?) -> Boolean = { true },
- onValidationErrorMessage: String? = null,
- autoSelectOnDisplay: Boolean = true,
- maxLines: Int = 1,
- keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
-) {
- val focusRequester = remember { FocusRequester() }
- var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
- mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
- }
- var error by rememberSaveable { mutableStateOf(null) }
- var canRequestFocus by rememberSaveable { mutableStateOf(false) }
- val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
- ListDialog(
- title = title,
- onSubmit = { onSubmit(textFieldContents.text) },
- onDismissRequest = onDismissRequest,
- enabled = canSubmit,
- ) {
- item {
- TextFieldListItem(
- placeholder = placeholder.orEmpty(),
- text = textFieldContents,
- onTextChange = {
- error = if (!validation(it.text)) onValidationErrorMessage else null
- textFieldContents = it
- },
- error = error,
- keyboardOptions = keyboardOptions,
- keyboardActions = KeyboardActions(onAny = {
- if (validation(textFieldContents.text)) {
- onSubmit(textFieldContents.text)
- }
- }),
- maxLines = maxLines,
- modifier = Modifier.focusRequester(focusRequester),
- )
- canRequestFocus = true
- }
- }
-
- if (autoSelectOnDisplay && canRequestFocus) {
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
- }
-}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
index c8f71f16cc..3931d8d8d0 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
@@ -8,7 +8,11 @@
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then(
if (onClick != null) {
@@ -17,3 +21,11 @@ fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then(
Modifier
}
)
+
+fun Modifier.niceClickable(
+ onClick: () -> Unit,
+): Modifier {
+ return clip(RoundedCornerShape(4.dp))
+ .clickable { onClick() }
+ .padding(horizontal = 4.dp)
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
index 36ac7d71bd..79f70cc2f5 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
@@ -8,7 +8,7 @@
package io.element.android.libraries.designsystem.text
import android.graphics.Typeface
-import android.text.SpannableString
+import android.text.SpannedString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
@@ -26,7 +26,7 @@ import io.element.android.compound.theme.LinkColor
fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
append(this@toAnnotatedString)
- val spannable = SpannableString(this@toAnnotatedString)
+ val spannable = SpannedString.valueOf(this@toAnnotatedString)
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index 503acdb6a4..5576a56519 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -38,77 +38,42 @@ val SemanticColors.placeholderBackground
get() = bgSubtleSecondary
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.messageFromMeBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray400
- Color(0xFFE1E6EC)
- } else {
- // We want DarkDesignTokens.colorGray500
- Color(0xFF323539)
- }
+ get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray500
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.messageFromOtherBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray300
- Color(0xFFF0F2F5)
- } else {
- // We want DarkDesignTokens.colorGray400
- Color(0xFF26282D)
- }
+ get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.progressIndicatorTrackColor
- get() = if (isLight) {
- // We want LightDesignTokens.colorAlphaGray500
- Color(0x33052448)
- } else {
- // We want DarkDesignTokens.colorAlphaGray500
- Color(0x25F4F7FA)
- }
+ get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.iconSuccessPrimaryBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGreen300
- Color(0xffe3f7ed)
- } else {
- // We want DarkDesignTokens.colorGreen300
- Color(0xff002513)
- }
+ get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.bgSubtleTertiary
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray100
- Color(0xfffbfcfd)
- } else {
- // We want DarkDesignTokens.colorGray100
- Color(0xff14171b)
- }
+ get() = if (isLight) LightColorTokens.colorGray100 else DarkColorTokens.colorGray100
// Temporary color, which is not in the token right now
val SemanticColors.temporaryColorBgSpecial
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.pinDigitBg
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray300
- Color(0xFFF0F2F5)
- } else {
- // We want DarkDesignTokens.colorGray400
- Color(0xFF26282D)
- }
+ get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
+@OptIn(CoreColorToken::class)
val SemanticColors.currentUserMentionPillText
- get() = if (isLight) {
- // We want LightDesignTokens.colorGreen1100
- Color(0xff005c45)
- } else {
- // We want DarkDesignTokens.colorGreen1100
- Color(0xff1fc090)
- }
+ get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
val SemanticColors.currentUserMentionPillBackground
get() = if (isLight) {
@@ -141,14 +106,6 @@ val SemanticColors.highlightedMessageBackgroundColor
// Badge colors
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgePositiveBackgroundColor
- get() = if (isLight) LightColorTokens.colorAlphaGreen300 else DarkColorTokens.colorAlphaGreen300
-
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgePositiveContentColor
- get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
-
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNeutralBackgroundColor
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
@@ -165,14 +122,6 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgeInfoBackgroundColor
- get() = if (isLight) LightColorTokens.colorAlphaBlue300 else DarkColorTokens.colorAlphaBlue300
-
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgeInfoContentColor
- get() = if (isLight) LightColorTokens.colorBlue1100 else DarkColorTokens.colorBlue1100
-
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
diff --git a/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml b/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml
index ecd1489c3d..20c4c8e160 100644
--- a/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml
@@ -49,6 +49,8 @@
"Finkatutako mezuak aldatu dituzu"
"%1$s(e)k mezu bat finkatu du"
"Mezu bat finkatu duzu"
+ "%1$s(e)k mezu bat finkatzeari utzi dio"
+ "Mezu bat finkatzeari utzi diozu"
"%1$s(e)k gonbidapena baztertu du"
"Gonbidapena baztertu duzu"
"%1$s(e)k %2$s kendu du"
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index c19793fb8d..6a85cf45f5 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -42,10 +42,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
-import java.io.Closeable
import java.util.Optional
-interface MatrixClient : Closeable {
+interface MatrixClient {
val sessionId: SessionId
val deviceId: DeviceId
val userProfile: StateFlow
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 915e1584a9..2c77f70d0a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -9,12 +9,14 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData(
val eventId: EventId,
+ val threadId: ThreadId?,
val roomId: RoomId,
// mxc url
val senderAvatarUrl: String?,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index ec94ec0b92..a2a737a019 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -138,7 +139,8 @@ interface MatrixRoom : Closeable {
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendVideo(
@@ -147,7 +149,8 @@ interface MatrixRoom : Closeable {
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendAudio(
@@ -156,6 +159,7 @@ interface MatrixRoom : Closeable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendFile(
@@ -164,8 +168,36 @@ interface MatrixRoom : Closeable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
+ suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result
+
+ /**
+ * Share a location message in the room.
+ *
+ * @param body A human readable textual representation of the location.
+ * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
+ * Respectively: latitude, longitude, and (optional) uncertainty.
+ * @param description Optional description of the location to display to the user.
+ * @param zoomLevel Optional zoom level to display the map at.
+ * @param assetType Optional type of the location asset.
+ * Set to SENDER if sharing own location. Set to PIN if sharing any location.
+ */
+ suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String? = null,
+ zoomLevel: Int? = null,
+ assetType: AssetType? = null,
+ ): Result
+
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
@@ -235,25 +267,6 @@ interface MatrixRoom : Closeable {
*/
suspend fun clearEventCacheStorage(): Result
- /**
- * Share a location message in the room.
- *
- * @param body A human readable textual representation of the location.
- * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
- * Respectively: latitude, longitude, and (optional) uncertainty.
- * @param description Optional description of the location to display to the user.
- * @param zoomLevel Optional zoom level to display the map at.
- * @param assetType Optional type of the location asset.
- * Set to SENDER if sharing own location. Set to PIN if sharing any location.
- */
- suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String? = null,
- zoomLevel: Int? = null,
- assetType: AssetType? = null,
- ): Result
-
/**
* Create a poll in the room.
*
@@ -302,13 +315,6 @@ interface MatrixRoom : Closeable {
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result
- suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result
-
/**
* Send a typing notification.
* @param isTyping True if the user is typing, false otherwise.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt
new file mode 100644
index 0000000000..6157989a43
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.room.message
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+data class ReplyParameters(
+ val inReplyToEventId: EventId,
+ val enforceThreadReply: Boolean,
+ val replyWithinThread: Boolean,
+)
+
+fun replyInThread(eventId: EventId, explicitReply: Boolean = false) = ReplyParameters(
+ inReplyToEventId = eventId,
+ enforceThreadReply = true,
+ replyWithinThread = explicitReply,
+)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
index 21554dbbd6..13cb54b500 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
@@ -7,7 +7,6 @@
package io.element.android.libraries.matrix.api.sync
-import io.element.android.libraries.core.coroutine.mapState
import kotlinx.coroutines.flow.StateFlow
interface SyncService {
@@ -25,6 +24,6 @@ interface SyncService {
* Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes.
*/
val syncState: StateFlow
-}
-fun SyncService.isOnline(): StateFlow = syncState.mapState { it != SyncState.Offline }
+ val isOnline: StateFlow
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index c74f7b2c00..a940a0981f 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -49,7 +50,10 @@ interface Timeline : AutoCloseable {
val membershipChangeEventReceived: Flow
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result
suspend fun paginate(direction: PaginationDirection): Result
- fun paginationStatus(direction: PaginationDirection): StateFlow
+
+ val backwardPaginationStatus: StateFlow
+ val forwardPaginationStatus: StateFlow
+
val timelineItems: Flow>
suspend fun sendMessage(
@@ -72,7 +76,7 @@ interface Timeline : AutoCloseable {
): Result
suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -85,7 +89,8 @@ interface Timeline : AutoCloseable {
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendVideo(
@@ -94,18 +99,18 @@ interface Timeline : AutoCloseable {
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
- suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
-
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ): Result
+ replyParameters: ReplyParameters?,
+ ): Result
suspend fun sendFile(
file: File,
@@ -113,15 +118,9 @@ interface Timeline : AutoCloseable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
- suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
-
- suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
-
- suspend fun cancelSend(transactionId: TransactionId): Result =
- redactEvent(transactionId.toEventOrTransactionId(), reason = null)
-
/**
* Share a location message in the room.
*
@@ -141,6 +140,23 @@ interface Timeline : AutoCloseable {
assetType: AssetType? = null,
): Result
+ suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result
+
+ suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
+
+ suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
+
+ suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
+
+ suspend fun cancelSend(transactionId: TransactionId): Result =
+ redactEvent(transactionId.toEventOrTransactionId(), reason = null)
+
/**
* Create a poll in the room.
*
@@ -189,13 +205,6 @@ interface Timeline : AutoCloseable {
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result
- suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result
-
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
/**
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt
new file mode 100644
index 0000000000..b0a67a9e92
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.tracing
+
+enum class TraceLogPack(val key: String) {
+ EVENT_CACHE("event_cache") {
+ override val title: String = "Event Cache"
+ },
+ SEND_QUEUE("send_queue") {
+ override val title: String = "Send Queue"
+ },
+ TIMELINE("timeline") {
+ override val title: String = "Timeline"
+ };
+
+ abstract val title: String
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt
index db2c946bf5..f559176fa6 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.api.tracing
data class TracingConfiguration(
val logLevel: LogLevel,
val extraTargets: List,
+ val traceLogPacks: Set,
val writesToLogcat: Boolean,
val writesToFilesConfiguration: WriteToFilesConfiguration,
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 6a69511fd3..d3a004115f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -476,16 +476,13 @@ class RustMatrixClient(
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
- override fun close() {
+ internal suspend fun destroy() {
innerNotificationClient.close()
- appCoroutineScope.launch {
- roomFactory.destroy()
- rustSyncService.destroy()
- notificationSettingsService.destroy()
- // This is sync, but it can destroy the `Client` instance and block stopping the sync service
- notificationProcessSetup.destroy()
- }
+ roomFactory.destroy()
+ rustSyncService.destroy()
+ notificationSettingsService.destroy()
+ notificationProcessSetup.destroy()
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -504,7 +501,7 @@ class RustMatrixClient(
override suspend fun clearCache() {
innerClient.clearCaches()
- close()
+ destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
@@ -527,7 +524,7 @@ class RustMatrixClient(
}
}
}
- close()
+ destroy()
deleteSessionDirectory()
if (userInitiated) {
@@ -577,7 +574,7 @@ class RustMatrixClient(
throw it
}
}
- close()
+ destroy()
deleteSessionDirectory()
sessionStore.removeSession(sessionId.value)
}.onFailure {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index deca69e1e9..b302784528 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -105,7 +105,7 @@ class RustMatrixClientFactory @Inject constructor(
cachePath = sessionPaths.cacheDirectory.absolutePath,
)
.setSessionDelegate(sessionDelegate)
- .passphrase(passphrase)
+ .sessionPassphrase(passphrase)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
index b253985942..ec220112de 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
@@ -9,12 +9,9 @@ package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.matrix.rustcomponents.sdk.OidcConfiguration
-import java.io.File
import javax.inject.Inject
-class OidcConfigurationProvider @Inject constructor(
- private val baseDirectory: File,
-) {
+class OidcConfigurationProvider @Inject constructor() {
fun get(): OidcConfiguration = OidcConfiguration(
clientName = "Element",
redirectUri = OidcConfig.REDIRECT_URI,
@@ -29,6 +26,5 @@ class OidcConfigurationProvider @Inject constructor(
staticRegistrations = mapOf(
"https://id.thirdroom.io/realms/thirdroom" to "elementx",
),
- dynamicRegistrationsFile = File(baseDirectory, "oidc/registrations.json").absolutePath,
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 2549fc622a..33af371569 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -324,7 +324,7 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
- .passphrase(passphrase)
+ .sessionPassphrase(passphrase)
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
index 8b21648071..7fdb4a9fdc 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
@@ -36,6 +36,8 @@ class NotificationMapper(
)
NotificationData(
eventId = eventId,
+ // FIXME once the `NotificationItem` in the SDK returns the thread id
+ threadId = null,
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 5b742e83f8..7ed2ad5eb6 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
@@ -497,8 +498,17 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
- return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback)
+ return liveTimeline.sendImage(
+ file = file,
+ thumbnailFile = thumbnailFile,
+ imageInfo = imageInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters
+ )
}
override suspend fun sendVideo(
@@ -508,8 +518,17 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
- return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
+ return liveTimeline.sendVideo(
+ file = file,
+ thumbnailFile = thumbnailFile,
+ videoInfo = videoInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters
+ )
}
override suspend fun sendAudio(
@@ -518,6 +537,7 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return liveTimeline.sendAudio(
file = file,
@@ -525,6 +545,7 @@ class RustMatrixRoom(
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
@@ -534,16 +555,44 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return liveTimeline.sendFile(
- file,
- fileInfo,
- caption,
- formattedCaption,
- progressCallback,
+ file = file,
+ fileInfo = fileInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result {
+ return liveTimeline.sendVoiceMessage(
+ file = file,
+ audioInfo = audioInfo,
+ waveform = waveform,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
+ )
+ }
+
+ override suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String?,
+ zoomLevel: Int?,
+ assetType: AssetType?,
+ ): Result {
+ return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
+ }
+
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result {
return liveTimeline.toggleReaction(emoji, eventOrTransactionId)
}
@@ -631,16 +680,6 @@ class RustMatrixRoom(
}
}
- override suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String?,
- zoomLevel: Int?,
- assetType: AssetType?,
- ): Result {
- return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
- }
-
override suspend fun createPoll(
question: String,
answers: List,
@@ -674,15 +713,6 @@ class RustMatrixRoom(
return liveTimeline.endPoll(pollStartId, text)
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result {
- return liveTimeline.sendVoiceMessage(file, audioInfo, waveform, progressCallback)
- }
-
override suspend fun typingNotice(isTyping: Boolean) = withContext(roomDispatcher) {
runCatching {
innerRoom.typingNotice(isTyping)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
index 2bcd15c142..a65112fca9 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
@@ -81,6 +81,10 @@ class RustRoomFactory(
withContext(NonCancellable + dispatcher) {
mutex.withLock {
Timber.d("Destroying room factory")
+ cache.snapshot().values.forEach { (listItem, innerRoom) ->
+ innerRoom.destroy()
+ listItem.destroy()
+ }
cache.evictAll()
isDestroyed = true
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt
new file mode 100644
index 0000000000..415d493e7d
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.room.message
+
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
+
+fun ReplyParameters.map() = org.matrix.rustcomponents.sdk.ReplyParameters(
+ eventId = inReplyToEventId.value,
+ enforceThread = enforceThreadReply,
+ replyWithinThread = replyWithinThread,
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
index 3318e1384f..1d8eb7c0ea 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.sync
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineDispatcher
@@ -73,4 +74,6 @@ class RustSyncService(
}
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
+
+ override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 46c2bfebd4..f13b5465fb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.location.toInner
+import io.element.android.libraries.matrix.impl.room.message.map
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
@@ -54,7 +56,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -127,11 +128,11 @@ class RustTimeline(
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
- private val backPaginationStatus = MutableStateFlow(
+ override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
)
- private val forwardPaginationStatus = MutableStateFlow(
+ override val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT)
)
@@ -167,7 +168,7 @@ class RustTimeline(
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.getAndUpdate(update)
+ Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
}
@@ -185,7 +186,7 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
- Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}")
+ Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
@@ -199,21 +200,14 @@ class RustTimeline(
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
if (!isTimelineInitialized.value) return false
return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
+ Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.value.canPaginate
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
}
}
- override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow {
- return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
- Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
- }
- }
-
override val timelineItems: Flow> = combine(
_timelineItems,
- backPaginationStatus,
+ backwardPaginationStatus,
forwardPaginationStatus,
matrixRoom.roomInfoFlow.map { it.creator to it.isDm }.distinctUntilChanged(),
isTimelineInitialized,
@@ -336,7 +330,7 @@ class RustTimeline(
}
override suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -344,7 +338,10 @@ class RustTimeline(
): Result = withContext(dispatcher) {
runCatching {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
- inner.sendReply(msg, eventId.value)
+ inner.sendReply(
+ msg = msg,
+ replyParams = replyParameters.map(),
+ )
}
}
@@ -355,6 +352,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@@ -367,6 +365,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
thumbnailPath = thumbnailFile?.path,
imageInfo = imageInfo.map(),
@@ -382,6 +381,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@@ -394,6 +394,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
thumbnailPath = thumbnailFile?.path,
videoInfo = videoInfo.map(),
@@ -408,6 +409,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
@@ -420,6 +422,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
audioInfo = audioInfo.map(),
progressWatcher = progressCallback?.toProgressWatcher()
@@ -433,6 +436,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
@@ -445,6 +449,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
fileInfo = fileInfo.map(),
progressWatcher = progressCallback?.toProgressWatcher(),
@@ -487,6 +492,32 @@ class RustTimeline(
}
}
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result {
+ val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
+ return sendAttachment(listOf(file)) {
+ inner.sendVoiceMessage(
+ params = UploadParameters(
+ filename = file.path,
+ // Maybe allow a caption in the future?
+ caption = null,
+ formattedCaption = null,
+ useSendQueue = useSendQueue,
+ mentions = null,
+ replyParams = replyParameters?.map(),
+ ),
+ audioInfo = audioInfo.map(),
+ waveform = waveform.toMSC3246range(),
+ progressWatcher = progressCallback?.toProgressWatcher(),
+ )
+ }
+ }
+
override suspend fun createPoll(
question: String,
answers: List,
@@ -550,30 +581,6 @@ class RustTimeline(
}
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result {
- val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
- return sendAttachment(listOf(file)) {
- inner.sendVoiceMessage(
- params = UploadParameters(
- filename = file.path,
- // Maybe allow a caption in the future?
- caption = null,
- formattedCaption = null,
- useSendQueue = useSendQueue,
- mentions = null,
- ),
- audioInfo = audioInfo.map(),
- waveform = waveform.toMSC3246range(),
- progressWatcher = progressCallback?.toProgressWatcher(),
- )
- }
- }
-
private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result {
return runCatching {
MediaUploadHandlerImpl(files, handle())
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index 19e4c36c6f..4d1de2cbfa 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
-import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
@@ -110,7 +109,6 @@ class TimelineEventContentMapper(
}
is TimelineItemContent.CallInvite -> LegacyCallInviteContent
is TimelineItemContent.CallNotify -> CallNotifyContent
- else -> UnknownContent
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
index 8df231bbab..d60d88f168 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
@@ -15,6 +15,7 @@ class VirtualTimelineItemMapper {
return when (virtualTimelineItem) {
is RustVirtualTimelineItem.DateDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong())
RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker
+ RustVirtualTimelineItem.TimelineStart -> VirtualTimelineItem.RoomBeginning
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
index cfc03e50b9..4dded4519d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
@@ -7,8 +7,6 @@
package io.element.android.libraries.matrix.impl.timeline.postprocessor
-import androidx.annotation.VisibleForTesting
-import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -32,55 +30,59 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
return when {
items.isEmpty() -> items
mode == Timeline.Mode.PINNED_EVENTS -> items
- isDm -> processForDM(items, roomCreator)
+ isDm -> processForDM(items, roomCreator, hasMoreToLoadBackwards)
hasMoreToLoadBackwards -> items
else -> processForRoom(items)
}
}
private fun processForRoom(items: List): List {
- val roomBeginningItem = createRoomBeginningItem()
- return listOf(roomBeginningItem) + items
+ // No changes needed, timeline start item is already added by the SDK
+ return items
}
- private fun processForDM(items: List, roomCreator: UserId?): List {
+ private fun processForDM(items: List, roomCreator: UserId?, hasMoreToLoadBackwards: Boolean): List {
+ val roomBeginningItemIndex = if (!hasMoreToLoadBackwards) {
+ items.indexOfFirst { it is MatrixTimelineItem.Virtual && it.virtual is VirtualTimelineItem.RoomBeginning }.takeIf { it >= 0 }
+ } else {
+ null
+ }
+
// Find room creation event.
// This is usually the first MatrixTimelineItem.Event (so index 1, index 0 is a date)
val roomCreationEventIndex = items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
stateEventContent?.content is OtherState.RoomCreate
- }
+ }.takeIf { it >= 0 }
// If the parameter roomCreator is null, the creator is the sender of the RoomCreate Event.
- val roomCreatorUserId = roomCreator ?: (items.getOrNull(roomCreationEventIndex) as? MatrixTimelineItem.Event)?.event?.sender
+ val roomCreatorUserId = roomCreator ?: roomCreationEventIndex?.let {
+ (items.getOrNull(it) as? MatrixTimelineItem.Event)?.event?.sender
+ }
// Find self-join event for the room creator.
// This is usually the second MatrixTimelineItem.Event (so index 2)
val selfUserJoinedEventIndex = roomCreatorUserId?.let { creatorUserId ->
items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? RoomMembershipContent
stateEventContent?.change == MembershipChange.JOINED && stateEventContent.userId == creatorUserId
- }
- } ?: -1
+ }.takeIf { it >= 0 }
+ }
- if (roomCreationEventIndex == -1 && selfUserJoinedEventIndex == -1) {
+ val indicesToRemove = listOfNotNull(
+ roomBeginningItemIndex,
+ roomCreationEventIndex,
+ selfUserJoinedEventIndex,
+ )
+ if (indicesToRemove.isEmpty()) {
+ // Nothing to do, return the list as is
return items
}
+
// Remove items at the indices we found
val newItems = items.toMutableList()
- if (selfUserJoinedEventIndex in newItems.indices) {
- newItems.removeAt(selfUserJoinedEventIndex)
- }
- if (roomCreationEventIndex in newItems.indices) {
- newItems.removeAt(roomCreationEventIndex)
+ indicesToRemove.sortedDescending().forEach { index ->
+ newItems.removeAt(index)
}
return newItems
}
-
- @VisibleForTesting
- fun createRoomBeginningItem(): MatrixTimelineItem.Virtual {
- return MatrixTimelineItem.Virtual(
- uniqueId = UniqueId("RoomBeginning"),
- virtual = VirtualTimelineItem.RoomBeginning
- )
- }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt
index 60ee29dc57..07ee1dc1b9 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt
@@ -51,7 +51,6 @@ fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfigurati
writeToStdoutOrSystem = writesToLogcat,
logLevel = logLevel.toRustLogLevel(),
extraTargets = extraTargets,
- // WARNING: this should be used only to debug issues, changes to this value should *never* be published
- traceLogPacks = emptyList(),
+ traceLogPacks = traceLogPacks.map(),
writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(),
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt
new file mode 100644
index 0000000000..11187a90a3
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.tracing
+
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
+import org.matrix.rustcomponents.sdk.TraceLogPacks as RustTraceLogPack
+
+fun TraceLogPack.map(): RustTraceLogPack = when (this) {
+ TraceLogPack.SEND_QUEUE -> RustTraceLogPack.SEND_QUEUE
+ TraceLogPack.EVENT_CACHE -> RustTraceLogPack.EVENT_CACHE
+ TraceLogPack.TIMELINE -> RustTraceLogPack.TIMELINE
+}
+
+fun Collection.map(): List {
+ return map { it.map() }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
index b761dd7e3b..7f179ad2f7 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
@@ -29,7 +29,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
- val isAnalyticsEnabled = analyticsService.getUserConsent().first()
+ val isAnalyticsEnabled = analyticsService.userConsentFlow.first()
val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl,
widgetId = widgetId,
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
index 887155e5d7..c051aebbac 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
@@ -31,7 +31,7 @@ class RustMatrixClientFactoryTest {
val sut = createRustMatrixClientFactory()
val result = sut.create(aSessionData())
assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org"))
- result.close()
+ result.destroy()
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
index 33f943cf53..f9a1afb656 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
@@ -28,9 +28,10 @@ import java.io.File
class RustMatrixClientTest {
@Test
fun `ensure that sessionId and deviceId can be retrieved from the client`() = runTest {
- createRustMatrixClient().use { sut ->
- assertThat(sut.sessionId).isEqualTo(A_USER_ID)
- assertThat(sut.deviceId).isEqualTo(A_DEVICE_ID)
+ createRustMatrixClient().run {
+ assertThat(sessionId).isEqualTo(A_USER_ID)
+ assertThat(deviceId).isEqualTo(A_DEVICE_ID)
+ destroy()
}
}
@@ -38,16 +39,16 @@ class RustMatrixClientTest {
fun `clear cache invokes the method clearCaches from the client and close it`() = runTest {
val clearCachesResult = lambdaRecorder { }
val closeResult = lambdaRecorder { }
- createRustMatrixClient(
+ val client = createRustMatrixClient(
client = FakeRustClient(
clearCachesResult = clearCachesResult,
closeResult = closeResult,
)
- ).use { sut ->
- sut.clearCache()
- clearCachesResult.assertions().isCalledOnce()
- closeResult.assertions().isCalledOnce()
- }
+ )
+ client.clearCache()
+ clearCachesResult.assertions().isCalledOnce()
+ closeResult.assertions().isCalledOnce()
+ client.destroy()
}
private fun TestScope.createRustMatrixClient(
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
index 0164cfeb20..01321fcdf6 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
@@ -10,12 +10,11 @@ package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.junit.Test
-import java.io.File
class OidcConfigurationProviderTest {
@Test
fun get() {
- val result = OidcConfigurationProvider(File("/base")).get()
+ val result = OidcConfigurationProvider().get()
assertThat(result.redirectUri).isEqualTo(OidcConfig.REDIRECT_URI)
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
index 61df8e6455..e2a9d883ee 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
@@ -48,7 +48,7 @@ class RustMatrixAuthenticationServiceTest {
sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory,
passphraseGenerator = FakePassphraseGenerator(),
- oidcConfigurationProvider = OidcConfigurationProvider(baseDirectory),
+ oidcConfigurationProvider = OidcConfigurationProvider(),
)
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
index 21c62cf3eb..8852734a71 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
@@ -30,7 +30,7 @@ class FakeRustClientBuilder : ClientBuilder(NoPointer) {
override fun roomDecryptionTrustRequirement(trustRequirement: TrustRequirement) = this
override fun disableSslVerification() = this
override fun homeserverUrl(url: String) = this
- override fun passphrase(passphrase: String?) = this
+ override fun sessionPassphrase(passphrase: String?) = this
override fun proxy(url: String) = this
override fun requestConfig(config: RequestConfig) = this
override fun roomKeyRecipientStrategy(strategy: CollectStrategy) = this
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
index 00e93e0e66..34cb1ac9e1 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
@@ -19,6 +19,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+internal val timelineStartEvent = MatrixTimelineItem.Virtual(
+ uniqueId = UniqueId("timeline_start"),
+ virtual = VirtualTimelineItem.RoomBeginning,
+)
internal val roomCreateEvent = MatrixTimelineItem.Event(
uniqueId = UniqueId("m.room.create"),
event = anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
index 5d72a68a68..bb1e4581a1 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
@@ -50,8 +50,9 @@ class RoomBeginningPostProcessorTest {
}
@Test
- fun `processor removes room creation event and self-join event from DM timeline`() {
+ fun `processor removes timeline start, room creation event and self-join event from DM timeline`() {
val timelineItems = listOf(
+ timelineStartEvent,
roomCreateEvent,
roomCreatorJoinEvent,
)
@@ -98,43 +99,6 @@ class RoomBeginningPostProcessorTest {
assertThat(processedItems).isEqualTo(expected)
}
- @Test
- fun `processor will add beginning of room item if it's not a DM`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false)
- assertThat(processedItems).isEqualTo(
- listOf(processor.createRoomBeginningItem()) + timelineItems
- )
- }
-
- @Test
- fun `processor will not add beginning of room item if it's not a DM but the room has more to load`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
- assertThat(processedItems).isEqualTo(timelineItems)
- }
-
- @Test
- fun `processor will add beginning of room item if it's not a DM, when the parameter roomCreator is null`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = null, hasMoreToLoadBackwards = false)
- assertThat(processedItems).isEqualTo(
- listOf(processor.createRoomBeginningItem()) + timelineItems
- )
- }
-
@Test
fun `processor removes items event it's not at the start of the timeline`() {
val timelineItems = listOf(
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 8a77a60975..446a039bd9 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -181,8 +181,6 @@ class FakeMatrixClient(
deactivateAccountResult(password, eraseData)
}
- override fun close() = Unit
-
override suspend fun getUserProfile(): Result = simulateLongTask {
val result = getProfileResults[sessionId]?.getOrNull() ?: MatrixUser(sessionId, userDisplayName, userAvatarUrl)
_userProfile.tryEmit(result)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index 509fac833c..7f9e218697 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -63,6 +63,7 @@ const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption"
+const val A_REASON = "A reason"
const val A_REDACTION_REASON = "A redaction reason"
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
index f99ff0e355..85473d9367 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.notification
+import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -19,6 +20,7 @@ fun aNotificationData(
content: NotificationContent = NotificationContent.MessageLike.RoomEncrypted,
isDirect: Boolean = false,
hasMention: Boolean = false,
+ threadId: ThreadId? = null,
timestamp: Long = A_TIMESTAMP,
senderDisplayName: String? = A_USER_NAME_2,
senderIsNameAmbiguous: Boolean = false,
@@ -26,6 +28,7 @@ fun aNotificationData(
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
+ threadId = threadId,
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = senderDisplayName,
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
index 487b898db3..c00ab8f113 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
@@ -12,13 +12,13 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkParser(
- private var result: () -> PermalinkData = { lambdaError() }
+ private var result: (String) -> PermalinkData = { lambdaError() }
) : PermalinkParser {
fun givenResult(result: PermalinkData) {
this.result = { result }
}
override fun parse(uriString: String): PermalinkData {
- return result()
+ return result(uriString)
}
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 7d9bbd1bfc..b7d226d499 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -87,16 +88,16 @@ class FakeMatrixRoom(
private val canRedactOtherResult: (UserId) -> Result = { lambdaError() },
private val canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() },
private val canUserSendMessageResult: (UserId, MessageEventType) -> Result = { _, _ -> lambdaError() },
- private val sendImageResult: (File, File?, ImageInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendImageResult: (File, File?, ImageInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
+ { _, _, _, _, _, _, _ -> lambdaError() },
+ private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
+ { _, _, _, _, _, _, _ -> lambdaError() },
+ private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _, _ -> lambdaError() },
- private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _, _ -> lambdaError() },
- private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendVoiceMessageResult: (File, AudioInfo, List, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _ -> lambdaError() },
- private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result =
- { _, _, _, _, _ -> lambdaError() },
- private val sendVoiceMessageResult: (File, AudioInfo, List, ProgressCallback?) -> Result =
- { _, _, _, _ -> lambdaError() },
private val setNameResult: (String) -> Result = { lambdaError() },
private val setTopicResult: (String) -> Result = { lambdaError() },
private val updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() },
@@ -332,7 +333,8 @@ class FakeMatrixRoom(
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendImageResult(
@@ -342,6 +344,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -351,7 +354,8 @@ class FakeMatrixRoom(
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendVideoResult(
@@ -361,6 +365,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -369,7 +374,8 @@ class FakeMatrixRoom(
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendAudioResult(
@@ -378,6 +384,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -386,7 +393,8 @@ class FakeMatrixRoom(
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendFileResult(
@@ -395,6 +403,40 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
+ )
+ }
+
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result = simulateLongTask {
+ simulateSendMediaProgress(progressCallback)
+ sendVoiceMessageResult(
+ file,
+ audioInfo,
+ waveform,
+ progressCallback,
+ replyParameters,
+ )
+ }
+
+ override suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String?,
+ zoomLevel: Int?,
+ assetType: AssetType?,
+ ): Result = simulateLongTask {
+ return sendLocationResult(
+ body,
+ geoUri,
+ description,
+ zoomLevel,
+ assetType,
)
}
@@ -464,22 +506,6 @@ class FakeMatrixRoom(
return Result.success(Unit)
}
- override suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String?,
- zoomLevel: Int?,
- assetType: AssetType?,
- ): Result = simulateLongTask {
- return sendLocationResult(
- body,
- geoUri,
- description,
- zoomLevel,
- assetType,
- )
- }
-
override suspend fun createPoll(
question: String,
answers: List,
@@ -524,21 +550,6 @@ class FakeMatrixRoom(
return endPollResult(pollStartId, text)
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result = simulateLongTask {
- simulateSendMediaProgress(progressCallback)
- sendVoiceMessageResult(
- file,
- audioInfo,
- waveform,
- progressCallback,
- )
- }
-
override suspend fun typingNotice(isTyping: Boolean): Result {
return typingNoticeResult(isTyping)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
index 9019eea646..5469e5b4aa 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.sync
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +30,8 @@ class FakeSyncService(
override val syncState: StateFlow = syncStateFlow
+ override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline }
+
suspend fun emitSyncState(syncState: SyncState) {
syncStateFlow.emit(syncState)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
index 1c335057b5..859f81b5c3 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -28,19 +29,18 @@ import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import java.io.File
class FakeTimeline(
private val name: String = "FakeTimeline",
override val timelineItems: Flow> = MutableStateFlow(emptyList()),
- private val backwardPaginationStatus: MutableStateFlow = MutableStateFlow(
+ override val backwardPaginationStatus: MutableStateFlow = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = true
)
),
- private val forwardPaginationStatus: MutableStateFlow = MutableStateFlow(
+ override val forwardPaginationStatus: MutableStateFlow = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = false
@@ -111,7 +111,7 @@ class FakeTimeline(
)
var replyMessageLambda: (
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -121,13 +121,13 @@ class FakeTimeline(
}
override suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean,
): Result = replyMessageLambda(
- eventId,
+ replyParameters,
body,
htmlBody,
intentionalMentions,
@@ -141,7 +141,8 @@ class FakeTimeline(
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -152,13 +153,15 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendImageLambda(
file,
thumbnailFile,
imageInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendVideoLambda: (
@@ -168,7 +171,8 @@ class FakeTimeline(
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -179,13 +183,15 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendVideoLambda(
file,
thumbnailFile,
videoInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendAudioLambda: (
@@ -194,7 +200,8 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -204,12 +211,14 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendAudioLambda(
file,
audioInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendFileLambda: (
@@ -218,7 +227,8 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -228,22 +238,39 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendFileLambda(
file,
fileInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
- var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result = { _, _ -> Result.success(Unit) }
- override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = toggleReactionLambda(
- emoji,
- eventOrTransactionId
- )
+ var sendVoiceMessageLambda: (
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
- var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> Result.success(Unit) }
- override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = forwardEventLambda(eventId, roomIds)
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result = sendVoiceMessageLambda(
+ file,
+ audioInfo,
+ waveform,
+ progressCallback,
+ replyParameters,
+ )
var sendLocationLambda: (
body: String,
@@ -269,6 +296,17 @@ class FakeTimeline(
assetType
)
+ var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result = { _, _ -> Result.success(Unit) }
+
+ override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = toggleReactionLambda(
+ emoji,
+ eventOrTransactionId
+ )
+
+ var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> Result.success(Unit) }
+
+ override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = forwardEventLambda(eventId, roomIds)
+
var createPollLambda: (
question: String,
answers: List,
@@ -338,27 +376,6 @@ class FakeTimeline(
text: String,
): Result = endPollLambda(pollStartId, text)
- var sendVoiceMessageLambda: (
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _ ->
- Result.success(FakeMediaUploadHandler())
- }
-
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result = sendVoiceMessageLambda(
- file,
- audioInfo,
- waveform,
- progressCallback
- )
-
var sendReadReceiptLambda: (
eventId: EventId,
receiptType: ReceiptType,
@@ -377,13 +394,6 @@ class FakeTimeline(
override suspend fun paginate(direction: Timeline.PaginationDirection): Result = paginateLambda(direction)
- override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow {
- return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus
- Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
- }
- }
-
var loadReplyDetailsLambda: (eventId: EventId) -> InReplyTo = {
InReplyTo.NotLoaded(it)
}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt
index 008438305d..452f6ed5a2 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt
@@ -34,8 +34,8 @@ fun InviteSenderView(
modifier = modifier,
) {
Box(modifier = Modifier.padding(vertical = 2.dp)) {
- Avatar(avatarData = inviteSender.avatarData)
- }
+ Avatar(avatarData = inviteSender.avatarData)
+ }
Text(
text = inviteSender.annotatedString(),
style = ElementTheme.typography.fontBodyMdRegular,
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt
index c71e5679cb..0c02a24a0e 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt
@@ -7,32 +7,26 @@
package io.element.android.libraries.matrix.ui.messages
-import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.runningFold
import javax.inject.Inject
@SingleIn(RoomScope::class)
class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf())
+ val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
- private val _lastCacheUpdate = MutableStateFlow(0L)
- val lastCacheUpdate: StateFlow = _lastCacheUpdate
-
- fun replace(items: List) {
+ suspend fun replace(items: List) = coroutineScope {
cache.value = items.associateBy { it.userId }
- _lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1)
}
fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName
}
}
-
-val LocalRoomMemberProfilesCache = staticCompositionLocalOf {
- RoomMemberProfilesCache()
-}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt
new file mode 100644
index 0000000000..598b7c28da
--- /dev/null
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.ui.messages
+
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.runningFold
+import javax.inject.Inject
+
+@SingleIn(RoomScope::class)
+class RoomNamesCache @Inject constructor() {
+ private val cache = MutableStateFlow(mapOf())
+ val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
+
+ suspend fun replace(items: List) = coroutineScope {
+ val roomNamesByRoomIdOrAlias = LinkedHashMap(items.size * 2)
+ items
+ .forEach { summary ->
+ roomNamesByRoomIdOrAlias[summary.info.id.toRoomIdOrAlias()] = summary.info.name
+ val canonicalAlias = summary.info.canonicalAlias
+ if (canonicalAlias != null) {
+ roomNamesByRoomIdOrAlias[canonicalAlias.toRoomIdOrAlias()] = summary.info.name
+ }
+ }
+ cache.value = roomNamesByRoomIdOrAlias
+ }
+
+ fun getDisplayName(roomIdOrAlias: RoomIdOrAlias): String? {
+ return cache.value[roomIdOrAlias]
+ }
+}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
index 3fb4ddd74a..d3135d9fd8 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -152,8 +153,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed)
InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key)
- is InReplyToMetadata.Text -> metadata.text
- is InReplyToMetadata.Thumbnail -> metadata.text
+ // Add a limit to the text length to avoid a crash in Compose
+ is InReplyToMetadata.Text -> metadata.text.toSafeLength()
+ // Add a limit to the text length to avoid a crash in Compose
+ is InReplyToMetadata.Thumbnail -> metadata.text.toSafeLength()
null -> ""
}
val iconResourceId = when (metadata) {
diff --git a/libraries/matrixui/src/main/res/values-eu/translations.xml b/libraries/matrixui/src/main/res/values-eu/translations.xml
index 15f4977395..3bea664f97 100644
--- a/libraries/matrixui/src/main/res/values-eu/translations.xml
+++ b/libraries/matrixui/src/main/res/values-eu/translations.xml
@@ -1,4 +1,6 @@
+ "Bidali gonbidapena"
+ "Gonbidapena bidali?"
"%1$s(e)k (%2$s) gonbidatu zaitu"
diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
index 584539eacc..b68f077df9 100644
--- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
+++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
@@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.flatMapCatching
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -46,12 +47,14 @@ class MediaSender @Inject constructor(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return room.sendMedia(
uploadInfo = mediaUploadInfo,
progressCallback = progressCallback,
caption = caption,
- formattedCaption = formattedCaption
+ formattedCaption = formattedCaption,
+ replyParameters = replyParameters,
)
.handleSendResult()
}
@@ -61,7 +64,8 @@ class MediaSender @Inject constructor(
mimeType: String,
caption: String? = null,
formattedCaption: String? = null,
- progressCallback: ProgressCallback? = null
+ progressCallback: ProgressCallback? = null,
+ replyParameters: ReplyParameters? = null,
): Result {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
@@ -76,7 +80,8 @@ class MediaSender @Inject constructor(
uploadInfo = info,
progressCallback = progressCallback,
caption = caption,
- formattedCaption = formattedCaption
+ formattedCaption = formattedCaption,
+ replyParameters = replyParameters,
)
}
.handleSendResult()
@@ -86,7 +91,8 @@ class MediaSender @Inject constructor(
uri: Uri,
mimeType: String,
waveForm: List,
- progressCallback: ProgressCallback? = null
+ progressCallback: ProgressCallback? = null,
+ replyParameters: ReplyParameters? = null,
): Result {
return preProcessor
.process(
@@ -106,7 +112,8 @@ class MediaSender @Inject constructor(
uploadInfo = newInfo,
progressCallback = progressCallback,
caption = null,
- formattedCaption = null
+ formattedCaption = null,
+ replyParameters = replyParameters,
)
}
.handleSendResult()
@@ -128,6 +135,7 @@ class MediaSender @Inject constructor(
progressCallback: ProgressCallback?,
caption: String?,
formattedCaption: String?,
+ replyParameters: ReplyParameters?,
): Result {
val handler = when (uploadInfo) {
is MediaUploadInfo.Image -> {
@@ -137,7 +145,8 @@ class MediaSender @Inject constructor(
imageInfo = uploadInfo.imageInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.Video -> {
@@ -147,7 +156,8 @@ class MediaSender @Inject constructor(
videoInfo = uploadInfo.videoInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.Audio -> {
@@ -156,7 +166,8 @@ class MediaSender @Inject constructor(
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.VoiceMessage -> {
@@ -164,7 +175,8 @@ class MediaSender @Inject constructor(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
waveform = uploadInfo.waveform,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.AnyFile -> {
@@ -173,7 +185,8 @@ class MediaSender @Inject constructor(
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
}
diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
index 48640da8ea..bd5795af51 100644
--- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
+++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
@@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@@ -46,7 +47,7 @@ class MediaSenderTest {
@Test
fun `given an attachment when sending it the MatrixRoom will call sendMedia`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -74,8 +75,8 @@ class MediaSenderTest {
@Test
fun `given a failure in the media upload when sending the whole process fails`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
- Result.failure(Exception())
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
+ Result.failure(Exception())
}
val room = FakeMatrixRoom(
sendImageResult = sendImageResult
@@ -91,7 +92,8 @@ class MediaSenderTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
index 4ad36d8827..1dc81a11e3 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
@@ -85,11 +86,13 @@ class TimelineMediaGalleryDataSource @Inject constructor(
}
}.flatMapLatest {
timelineMediaItemsFactory.timelineItems
- }.map { timelineItems ->
- mediaItemsPostProcessor.process(mediaItems = timelineItems)
- }.map {
- mediaTimeline.orCache(it)
- }.onEach { groupedMediaItems ->
+ }
+ .distinctUntilChanged()
+ .map { timelineItems ->
+ val groupedItems = mediaItemsPostProcessor.process(mediaItems = timelineItems)
+ mediaTimeline.orCache(groupedItems)
+ }
+ .onEach { groupedMediaItems ->
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
}
.onCompletion {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt
index 8750647d97..56af3ca2c8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.mediaviewer.impl.local
+import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentValues
@@ -26,6 +27,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
+import androidx.core.content.PermissionChecker
import androidx.core.net.toFile
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent
@@ -119,7 +121,14 @@ class AndroidLocalMediaActions @Inject constructor(
when (localMedia.info.mimeType) {
MimeTypes.Apk -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- if (activityContext?.packageManager?.canRequestPackageInstalls() == false) {
+ if (PermissionChecker.checkPermission(
+ context,
+ Manifest.permission.REQUEST_INSTALL_PACKAGES,
+ -1,
+ -1,
+ context.packageName
+ ) == PermissionChecker.PERMISSION_GRANTED &&
+ activityContext?.packageManager?.canRequestPackageInstalls() == false) {
pendingMedia = localMedia
activityContext?.startInstallFromSourceIntent(apkInstallLauncher!!).let { }
} else {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
index 10ac3a7c2a..bec02c7fd8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
@@ -31,6 +31,7 @@ fun LocalMediaView(
textFileViewer: TextFileViewer,
modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
+ isUserSelected: Boolean = false,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
@@ -47,6 +48,7 @@ fun LocalMediaView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
+ autoplay = isUserSelected,
modifier = modifier,
)
mimeType == MimeTypes.PlainText -> TextFileView(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
index 684b96cb9f..028a25999f 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
@@ -111,6 +111,7 @@ private fun ExoPlayerMediaAudioView(
MediaPlayerControllerState(
isVisible = true,
isPlaying = false,
+ isReady = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = false,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
index b89f6a324f..a098a53174 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
@@ -187,10 +187,6 @@ class ExoPlayerForPreview(
override fun setDeviceMuted(muted: Boolean) {}
override fun setDeviceMuted(muted: Boolean, flags: Int) {}
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {}
- override fun getAudioComponent(): ExoPlayer.AudioComponent? = throw NotImplementedError()
- override fun getVideoComponent(): ExoPlayer.VideoComponent? = throw NotImplementedError()
- override fun getTextComponent(): ExoPlayer.TextComponent? = throw NotImplementedError()
- override fun getDeviceComponent(): ExoPlayer.DeviceComponent? = throw NotImplementedError()
override fun addAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun removeAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun getAnalyticsCollector(): AnalyticsCollector = throw NotImplementedError()
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
index 349044439e..6160b6758c 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
@@ -12,6 +12,7 @@ import androidx.annotation.FloatRange
data class MediaPlayerControllerState(
val isVisible: Boolean,
val isPlaying: Boolean,
+ val isReady: Boolean,
val progressInMillis: Long,
val durationInMillis: Long,
val canMute: Boolean,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt
index 88e4c2f7c7..2ce66a79e3 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt
@@ -27,6 +27,7 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider> {
+ internal fun dataFlow(): Flow> {
return galleryDataSource.groupedMediaItemsFlow()
.map { groupedItems ->
when (groupedItems) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
index f897984578..858e3e7916 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
@@ -148,6 +148,7 @@ class MediaViewerPresenter @AssistedInject constructor(
}
return MediaViewerState(
+ initiallySelectedEventId = inputs.eventId,
listData = data.value,
currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
index 32c22b0470..5a0c4c2307 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState(
+ val initiallySelectedEventId: EventId?,
val listData: ImmutableList,
val currentIndex: Int,
val snackbarMessage: SnackbarMessage?,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
index 6686cd9fce..324b093325 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
@@ -202,6 +203,7 @@ fun aMediaViewerState(
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState(
+ initiallySelectedEventId = EventId("\$a:b"),
listData = listData.toPersistentList(),
currentIndex = currentIndex,
snackbarMessage = null,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
index 501c434674..a55846106b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
@@ -142,8 +142,13 @@ fun MediaViewerView(
Box(
modifier = Modifier.fillMaxSize()
) {
+ val isDisplayed = remember(pagerState.settledPage) {
+ // This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
+ // So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
+ page == pagerState.settledPage
+ }
MediaViewerPage(
- isDisplayed = page == pagerState.settledPage,
+ isDisplayed = isDisplayed,
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
data = dataForPage,
@@ -157,7 +162,8 @@ fun MediaViewerView(
},
onShowOverlayChange = {
showOverlay = it
- }
+ },
+ isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
)
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
@@ -273,6 +279,7 @@ private fun MediaViewerPage(
bottomPaddingInPixels: Int,
data: MediaViewerPageData.MediaViewerData,
textFileViewer: TextFileViewer,
+ isUserSelected: Boolean,
onDismiss: () -> Unit,
onRetry: () -> Unit,
onDismissError: () -> Unit,
@@ -328,6 +335,7 @@ private fun MediaViewerPage(
currentOnShowOverlayChange(!currentShowOverlay)
}
},
+ isUserSelected = isUserSelected,
)
ThumbnailView(
mediaInfo = data.mediaInfo,
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
index 3e5e3b7407..e9072e3666 100644
--- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -56,13 +57,13 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
// To reset the store: ResetStore()
- val isAlreadyDenied: Boolean by permissionsStore
- .isPermissionDenied(permission)
- .collectAsState(initial = false)
+ val isAlreadyDenied: Boolean by remember {
+ permissionsStore.isPermissionDenied(permission)
+ }.collectAsState(initial = false)
- val isAlreadyAsked: Boolean by permissionsStore
- .isPermissionAsked(permission)
- .collectAsState(initial = false)
+ val isAlreadyAsked: Boolean by remember {
+ permissionsStore.isPermissionAsked(permission)
+ }.collectAsState(initial = false)
var permissionState: PermissionState? = null
diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
index 537c8d6040..c072229626 100644
--- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
+++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.preferences.api.store
import io.element.android.libraries.matrix.api.tracing.LogLevel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.coroutines.flow.Flow
interface AppPreferencesStore {
@@ -26,5 +27,8 @@ interface AppPreferencesStore {
suspend fun setTracingLogLevel(logLevel: LogLevel)
fun getTracingLogLevelFlow(): Flow
+ suspend fun setTracingLogPacks(targets: Set)
+ fun getTracingLogPacksFlow(): Flow>
+
suspend fun reset()
}
diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
index 06f4b05912..a05e9c48da 100644
--- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
+++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.tracing.LogLevel
+import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -32,6 +33,7 @@ private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseU
private val themeKey = stringPreferencesKey("theme")
private val hideImagesAndVideosKey = booleanPreferencesKey("hideImagesAndVideos")
private val logLevelKey = stringPreferencesKey("logLevel")
+private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore @Inject constructor(
@@ -105,6 +107,23 @@ class DefaultAppPreferencesStore @Inject constructor(
}
}
+ override suspend fun setTracingLogPacks(targets: Set) {
+ val value = targets.joinToString(",") { it.key }
+ store.edit { prefs ->
+ prefs[traceLogPacksKey] = value
+ }
+ }
+
+ override fun getTracingLogPacksFlow(): Flow