Merge branch 'release/25.03.3'

This commit is contained in:
Jorge Martín 2025-03-26 11:48:15 +01:00
commit 490e76c85f
285 changed files with 2650 additions and 1428 deletions

View file

@ -46,6 +46,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} 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 }}
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
@ -65,7 +69,7 @@ jobs:
retention-days: 5 retention-days: 5
overwrite: true overwrite: true
if-no-files-found: error if-no-files-found: error
- uses: rnkdsh/action-upload-diawi@v1.5.7 - uses: rnkdsh/action-upload-diawi@v1.5.8
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
continue-on-error: true continue-on-error: true

View file

@ -54,6 +54,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} 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 }}
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs - name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}

View file

@ -30,6 +30,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} 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_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

@ -36,6 +36,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} 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_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

@ -32,6 +32,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} 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 }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View file

@ -1,3 +1,68 @@
Changes in Element X v25.03.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.03.2 -->
## What's Changed
### ✨ Features
* Implement user verification by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4294
* Add user verification and verification state violation badges by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4392
* Open txt document inside the application by @bmarty in https://github.com/element-hq/element-x-android/pull/4414
* Add timeline item prefetching by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4399
### 🐛 Bugfixes
* fix(read receipt): track read receipts for focused timeline by @ganfra in https://github.com/element-hq/element-x-android/pull/4374
* Discard timed out verification requests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4385
* Ensure the snackbar "No more media to show" is not rendered when opening the media viewer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4397
* Disable click effect on Stickers by @bmarty in https://github.com/element-hq/element-x-android/pull/4401
* Ensure that a click on a media open the correct media. by @bmarty in https://github.com/element-hq/element-x-android/pull/4413
* Display user verification violation icon in DM rooms too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4423
* Add a filter to avoid stack overflow when pressing the back button several times. by @bmarty in https://github.com/element-hq/element-x-android/pull/4430
* Make verification screens scrollable and emoji labels multiline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4449
### 🗣 Translations
* Sync Strings - New translations in Basque by @ElementBot in https://github.com/element-hq/element-x-android/pull/4381
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4421
### 🧱 Build
* More PR checks by @bmarty in https://github.com/element-hq/element-x-android/pull/4384
* "Core Team" is a team of matrix-org. Use team "Vector Core" instead. by @bmarty in https://github.com/element-hq/element-x-android/pull/4393
* Fix warnings in tests for push provider modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4398
* Update Gradle Wrapper from 8.12.1 to 8.13 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4308
* Revert agp to 8.8.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/4451
### Dependency upgrades
* Update rnkdsh/action-upload-diawi action to v1.5.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4354
* fix(deps): update dependency com.posthog:posthog-android to v3.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4387
* fix(deps): update dependencyanalysis to v2.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4395
* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4407
* fix(deps): update dependency androidx.webkit:webkit to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4408
* fix(deps): update dependency net.java.dev.jna:jna to v5.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4419
* fix(deps): update dependencyanalysis to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4409
* Add Google Tink dependency, replacing `androidx.security.crypto` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4405
* fix(deps): update dependency io.sentry:sentry-android to v8.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4411
* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4427
* chore(deps): update webfactory/ssh-agent action to v0.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4426
* fix(deps): update android.gradle.plugin to v8.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4320
* Update SDK version to `25.03.13` and fix breaking changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4406
* Update dagger to v2.56 by @renovate in https://github.com/element-hq/element-x-android/pull/4440
* Update dependency io.sentry:sentry-android to v8.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4433
* Update dependencyAnalysis to v2.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4442
* Update dependency com.google.crypto.tink:tink-android to v1.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4422
* deps(rust sdk) : update to 25.03.20 and fix api change by @ganfra in https://github.com/element-hq/element-x-android/pull/4452
### Others
* Migrate some icons to Compound icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4375
* Long press link to copy URL to clipboard by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4376
* Use public icon from Compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4386
* Be able to correctly render the UI with other colors. by @bmarty in https://github.com/element-hq/element-x-android/pull/4378
* Let EnterpriseService provides push gateways by @bmarty in https://github.com/element-hq/element-x-android/pull/4400
* Add feature flag to let the application prints logs to logcat in release builds. by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4402
* Hide "unencrypted" lock for redacted messages by @Xant3s in https://github.com/element-hq/element-x-android/pull/4410
* Hide unencrypted lock for redacted msgs by @bmarty in https://github.com/element-hq/element-x-android/pull/4429
* Clear SDK cache properly by @bmarty in https://github.com/element-hq/element-x-android/pull/4396
## New Contributors
* @ShadowRZ made their first contribution in https://github.com/element-hq/element-x-android/pull/4376
* @Xant3s made their first contribution in https://github.com/element-hq/element-x-android/pull/4410
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.1...v25.03.2
Changes in Element X v25.03.1 Changes in Element X v25.03.1
============================= =============================

View file

@ -13,7 +13,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider
class ContributesNodeProcessorProvider : SymbolProcessorProvider { class ContributesNodeProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
val enableLogging = environment.options["enableLogging"]?.toBoolean() ?: false val enableLogging = environment.options["enableLogging"]?.toBoolean() == true
return ContributesNodeProcessor( return ContributesNodeProcessor(
logger = environment.logger, logger = environment.logger,
codeGenerator = environment.codeGenerator, codeGenerator = environment.codeGenerator,

View file

@ -59,21 +59,27 @@ android {
splits { splits {
// Configures multiple APKs based on ABI. // Configures multiple APKs based on ABI.
abi { abi {
// Enables building multiple APKs per ABI. val buildingAppBundle = gradle.startParameter.taskNames.any { it.contains("bundle") }
isEnable = true
// Enables building multiple APKs per ABI. This should be disabled when building an AAB.
isEnable = !buildingAppBundle
// By default all ABIs are included, so use reset() and include to specify that we only // By default all ABIs are included, so use reset() and include to specify that we only
// want APKs for armeabi-v7a, x86, arm64-v8a and x86_64. // want APKs for armeabi-v7a, x86, arm64-v8a and x86_64.
// Resets the list of ABIs that Gradle should create APKs for to none. // Resets the list of ABIs that Gradle should create APKs for to none.
reset() reset()
// Specifies a list of ABIs that Gradle should create APKs for.
include("armeabi-v7a", "x86", "arm64-v8a", "x86_64") if (!buildingAppBundle) {
// Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default. // Specifies a list of ABIs that Gradle should create APKs for.
isUniversalApk = true include("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
// Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default.
isUniversalApk = true
}
} }
} }
defaultConfig { androidResources {
resourceConfigurations += locales localeFilters += locales
} }
} }

View file

@ -8,11 +8,6 @@
package io.element.android.appconfig package io.element.android.appconfig
object ElementCallConfig { object ElementCallConfig {
/**
* The default base URL for the Element Call service.
*/
const val DEFAULT_BASE_URL = "https://call.element.io"
/** /**
* The default duration of a ringing call in seconds before it's automatically dismissed. * The default duration of a ringing call in seconds before it's automatically dismissed.
*/ */

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Amaitu saioa eta bertsio-berritu"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Zure zerbitzaria ez da bateragarria protokolo zaharrarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Zure zerbitzaria ez da bateragarria protokolo zaharrarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."</string>
</resources> </resources>

View file

@ -0,0 +1,2 @@
Main changes in this version: improvements to the event cache, Element Call now uses an embedded implementation, several bugfixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,3 +1,4 @@
import extension.readLocalProperty
import extension.setupAnvil import extension.setupAnvil
/* /*
@ -23,6 +24,49 @@ android {
testOptions { testOptions {
unitTests.isIncludeAndroidResources = true unitTests.isIncludeAndroidResources = true
} }
defaultConfig {
buildConfigField(
type = "String",
name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_USER_ID",
value = (System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
?: readLocalProperty("features.call.posthog.userid")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_API_HOST",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
?: readLocalProperty("features.call.posthog.api.host")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_API_KEY",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
?: readLocalProperty("features.call.posthog.api.key")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "RAGESHAKE_URL",
value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url")
?: ""
).let { "\"$it\"" }
)
}
} }
setupAnvil() setupAnvil()
@ -47,6 +91,7 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.network.retrofit) implementation(libs.network.retrofit)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
api(projects.features.call.api) api(projects.features.call.api)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.impl.BuildConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallAnalyticCredentialsProvider @Inject constructor() : CallAnalyticCredentialsProvider {
override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() }
override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() }
override val posthogApiKey: String? = BuildConfig.POSTHOG_API_KEY.takeIf { it.isNotBlank() }
override val rageshakeSubmitUrl: String? = BuildConfig.RAGESHAKE_URL.takeIf { it.isNotBlank() }
override val sentryDsn: String? = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() }
}

View file

@ -8,10 +8,8 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
@ -19,12 +17,13 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject import javax.inject.Inject
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor( class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider, private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider, private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
) : CallWidgetProvider { ) : CallWidgetProvider {
override suspend fun getWidget( override suspend fun getWidget(
sessionId: SessionId, sessionId: SessionId,
@ -35,9 +34,10 @@ class DefaultCallWidgetProvider @Inject constructor(
): Result<CallWidgetProvider.GetWidgetResult> = runCatching { ): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = matrixClient.getRoom(roomId) ?: error("Room not found") val room = matrixClient.getRoom(roomId) ?: error("Room not found")
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
?: elementCallBaseUrlProvider.provides(matrixClient) val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
?: ElementCallConfig.DEFAULT_BASE_URL val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted) val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
val callUrl = room.generateWidgetWebViewUrl( val callUrl = room.generateWidgetWebViewUrl(
@ -46,9 +46,10 @@ class DefaultCallWidgetProvider @Inject constructor(
languageTag = languageTag, languageTag = languageTag,
theme = theme, theme = theme,
).getOrThrow() ).getOrThrow()
CallWidgetProvider.GetWidgetResult( CallWidgetProvider.GetWidgetResult(
driver = room.getWidgetDriver(widgetSettings).getOrThrow(), driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
url = callUrl url = callUrl,
) )
} }
} }

View file

@ -16,6 +16,8 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.core.net.toUri
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig import io.element.android.features.call.impl.BuildConfig
@ -37,6 +39,10 @@ class WebViewWidgetMessageInterceptor(
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10) override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10)
init { init {
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
.build()
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@ -117,6 +123,14 @@ class WebViewWidgetMessageInterceptor(
super.onReceivedSslError(view, handler, error) super.onReceivedSslError(view, handler, error)
} }
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url)
}
override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(url.toUri())
}
} }
// Create a WebMessageListener, which will receive messages from the WebView and reply to them // Create a WebMessageListener, which will receive messages from the WebView and reply to them

View file

@ -9,9 +9,7 @@ package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -22,8 +20,6 @@ import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsPro
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -104,42 +100,13 @@ class DefaultCallWidgetProviderTest {
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io") assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
} }
@Test
fun `getWidget - will use a wellknown base url if it exists`() = runTest {
val aCustomUrl = "https://custom.element.io"
val providesLambda = lambdaRecorder<MatrixClient, String?> { _ -> aCustomUrl }
val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { matrixClient ->
providesLambda(matrixClient)
}
val room = FakeMatrixRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider,
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
)
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
assertThat(settingsProvider.providedBaseUrls).containsExactly(aCustomUrl)
providesLambda.assertions()
.isCalledOnce()
.with(value(client))
}
private fun createProvider( private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
) = DefaultCallWidgetProvider( ) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider, matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider, callWidgetSettingsProvider = callWidgetSettingsProvider,
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
) )
} }

View file

@ -1,20 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallBaseUrlProvider(
private val providesLambda: (MatrixClient) -> String? = { lambdaError() }
) : ElementCallBaseUrlProvider {
override suspend fun provides(matrixClient: MatrixClient): String? {
return providesLambda(matrixClient)
}
}

View file

@ -21,4 +21,10 @@
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string> <string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_room_directory_search_title">"Каталог комнат"</string> <string name="screen_room_directory_search_title">"Каталог комнат"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string> <string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string>
<string name="screen_start_chat_join_room_by_address_action">"Присоединиться к комнате по адресу"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Недействительный адрес"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Ввести…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Соответствующая комната найдена"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Комната не найдена"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"прим. #room-name:matrix.org"</string>
</resources> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_delete_all_messages">"Ezabatu nire mezu guztiak"</string>
<string name="screen_deactivate_account_list_item_2">"Kendu zure burua txat gela guztietatik."</string> <string name="screen_deactivate_account_list_item_2">"Kendu zure burua txat gela guztietatik."</string>
<string name="screen_deactivate_account_title">"Desaktibatu kontua"</string> <string name="screen_deactivate_account_title">"Desaktibatu kontua"</string>
</resources> </resources>

View file

@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import config.BuildTimeConfig
import extension.readLocalProperty import extension.readLocalProperty
plugins { plugins {
@ -19,24 +20,36 @@ android {
resValue( resValue(
type = "string", type = "string",
name = "maptiler_api_key", name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.apikey") BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
}
?: "" ?: ""
) )
resValue( resValue(
type = "string", type = "string",
name = "maptiler_light_map_id", name = "maptiler_light_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.lightMapId") BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
// fall back to maptiler's default light map. } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
}
// fall back to maptiler's default light map.
?: "basic-v2" ?: "basic-v2"
) )
resValue( resValue(
type = "string", type = "string",
name = "maptiler_dark_map_id", name = "maptiler_dark_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.darkMapId") BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
// fall back to maptiler's default dark map. } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
}
// fall back to maptiler's default dark map.
?: "basic-v2-dark" ?: "basic-v2-dark"
) )
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api
interface LocationService {
fun isServiceAvailable(): Boolean
}

View file

@ -103,6 +103,7 @@ fun StaticMapView(
} else { } else {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(), showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
contentDescription = contentDescription, contentDescription = contentDescription,
width = maxWidth, width = maxWidth,
height = maxHeight, height = maxHeight,

View file

@ -57,6 +57,8 @@ internal class MapTilerStaticMapUrlBuilder(
// to keep the perceived content size constant at the expense of sharpness. // 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 "$MAPTILER_BASE_URL/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
} }
override fun isServiceAvailable() = apiKey.isNotEmpty()
} }
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> { private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {

View file

@ -9,8 +9,10 @@ package io.element.android.features.location.api.internal
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -18,7 +20,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -28,12 +29,12 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.BooleanProvider
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun StaticMapPlaceholder( internal fun StaticMapPlaceholder(
showProgress: Boolean, showProgress: Boolean,
canReload: Boolean,
contentDescription: String?, contentDescription: String?,
width: Dp, width: Dp,
height: Dp, height: Dp,
@ -54,7 +55,7 @@ internal fun StaticMapPlaceholder(
) )
if (showProgress) { if (showProgress) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } else if (canReload) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -70,14 +71,24 @@ internal fun StaticMapPlaceholder(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun StaticMapPlaceholderPreview( internal fun StaticMapPlaceholderPreview() = ElementPreview {
@PreviewParameter(BooleanProvider::class) values: Boolean Column(
) = ElementPreview { modifier = Modifier.padding(8.dp),
StaticMapPlaceholder( verticalArrangement = Arrangement.spacedBy(8.dp)
showProgress = values, ) {
contentDescription = null, listOf(
width = 400.dp, true to false,
height = 400.dp, false to true,
onLoadMapClick = {}, false to false,
) ).forEach { (showProgress, canReload) ->
StaticMapPlaceholder(
showProgress = showProgress,
canReload = canReload,
contentDescription = null,
width = 400.dp,
height = 200.dp,
onLoadMapClick = {},
)
}
}
} }

View file

@ -22,6 +22,8 @@ interface StaticMapUrlBuilder {
height: Int, height: Int,
density: Float, density: Float,
): String ): String
fun isServiceAvailable(): Boolean
} }
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context) fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)

View file

@ -17,6 +17,21 @@ class MapTilerStaticMapUrlBuilderTest {
darkMapId = "aDarkMapId", darkMapId = "aDarkMapId",
) )
@Test
fun `isServiceAvailable returns true if api key is not empty`() {
assertThat(builder.isServiceAvailable()).isTrue()
}
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
apiKey = "",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
assertThat(builderWithoutKey.isServiceAvailable()).isFalse()
}
@Test @Test
fun `static map 1x density`() { fun `static map 1x density`() {
assertThat( assertThat(

View file

@ -49,6 +49,7 @@ dependencies {
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl
import com.squareup.anvil.annotations.ContributesBinding
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 {
override fun isServiceAvailable(): Boolean {
return stringProvider.getString(R.string.maptiler_api_key).isNotEmpty()
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.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 org.junit.Test
class DefaultLocationServiceTest {
@Test
fun `if apiKey is empty, isServiceAvailable should return false`() {
val fakeStringProvider = FakeStringProvider(
defaultResult = ""
)
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()
}
}

View file

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

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.test
import io.element.android.features.location.api.LocationService
class FakeLocationService(
private val isServiceAvailable: Boolean,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
}

View file

@ -19,16 +19,22 @@ import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.seconds
class CreateAccountPresenter @AssistedInject constructor( class CreateAccountPresenter @AssistedInject constructor(
@Assisted private val url: String, @Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val clientProvider: MatrixClientProvider,
private val defaultLoginUserStory: DefaultLoginUserStory, private val defaultLoginUserStory: DefaultLoginUserStory,
private val messageParser: MessageParser, private val messageParser: MessageParser,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
@ -73,6 +79,12 @@ class CreateAccountPresenter @AssistedInject constructor(
}.flatMap { externalSession -> }.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession) authenticationService.importCreatedSession(externalSession)
}.onSuccess { sessionId -> }.onSuccess { sessionId ->
tryOrNull {
// Wait until the session is verified
val client = clientProvider.getOrRestore(sessionId).getOrThrow()
val sessionVerificationService = client.sessionVerificationService()
withTimeout(10.seconds) { sessionVerificationService.sessionVerifiedStatus.first { it.isVerified() } }
}
// We will not navigate to the WaitList screen, so the login user story is done // We will not navigate to the WaitList screen, so the login user story is done
defaultLoginUserStory.setLoginFlowIsDone(true) defaultLoginUserStory.setLoginFlowIsDone(true)
loggedInState.value = AsyncAction.Success(sessionId) loggedInState.value = AsyncAction.Success(sessionId)

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -135,11 +136,13 @@ class CreateAccountPresenterTest {
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
messageParser: MessageParser = FakeMessageParser(), messageParser: MessageParser = FakeMessageParser(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
) = CreateAccountPresenter( ) = CreateAccountPresenter(
url = url, url = url,
authenticationService = authenticationService, authenticationService = authenticationService,
defaultLoginUserStory = defaultLoginUserStory, defaultLoginUserStory = defaultLoginUserStory,
messageParser = messageParser, messageParser = messageParser,
buildMeta = buildMeta, buildMeta = buildMeta,
clientProvider = clientProvider,
) )
} }

View file

@ -75,6 +75,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)

View file

@ -28,6 +28,7 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -96,6 +97,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val mentionSpanTheme: MentionSpanTheme, private val mentionSpanTheme: MentionSpanTheme,
@ -409,7 +411,7 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.LocationViewer( NavTarget.LocationViewer(
location = event.content.location, location = event.content.location,
description = event.content.description, description = event.content.description,
) ).takeIf { locationService.isServiceAvailable() }
} }
else -> null else -> null
} }

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -96,6 +97,7 @@ class MessagesPresenter @AssistedInject constructor(
@Assisted private val timelinePresenter: Presenter<TimelineState>, @Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>, private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val linkPresenter: Presenter<LinkState>,
@Assisted private val actionListPresenter: Presenter<ActionListState>, @Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>, private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>, private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@ -136,6 +138,7 @@ class MessagesPresenter @AssistedInject constructor(
val timelineProtectionState = timelineProtectionPresenter.present() val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present() val identityChangeState = identityChangeStatePresenter.present()
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
@ -245,6 +248,7 @@ class MessagesPresenter @AssistedInject constructor(
timelineState = timelineState, timelineState = timelineState,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState, identityChangeState = identityChangeState,
linkState = linkState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
@ -38,6 +39,7 @@ data class MessagesState(
val timelineState: TimelineState, val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState, val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState, val identityChangeState: IdentityChangeState,
val linkState: LinkState,
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,

View file

@ -12,6 +12,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -103,6 +105,7 @@ fun aMessagesState(
), ),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(), identityChangeState: IdentityChangeState = anIdentityChangeState(),
linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(), customReactionState: CustomReactionState = aCustomReactionState(),
@ -124,6 +127,7 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState, identityChangeState = identityChangeState,
linkState = linkState,
timelineState = timelineState, timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState, actionListState = actionListState,

View file

@ -56,6 +56,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@ -104,6 +106,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
import kotlin.random.Random import kotlin.random.Random
@ -207,7 +210,14 @@ fun MessagesView(
onContentClick = ::onContentClick, onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } }, onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
onLinkClick = onLinkClick, onLinkClick = { link, customTab ->
if (customTab) {
onLinkClick(link.url, true)
// Do not check those links, they are internal link only
} else {
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
}
},
onReactionClick = ::onEmojiReactionClick, onReactionClick = ::onEmojiReactionClick,
onReactionLongClick = ::onEmojiReactionLongClick, onReactionLongClick = ::onEmojiReactionLongClick,
onMoreReactionsClick = ::onMoreReactionsClick, onMoreReactionsClick = ::onMoreReactionsClick,
@ -258,6 +268,12 @@ fun MessagesView(
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
) )
ReinviteDialog(state = state) ReinviteDialog(state = state)
LinkView(
onLinkValid = { link ->
onLinkClick(link.url, false)
},
state = state.linkState,
)
} }
@Composable @Composable
@ -279,7 +295,7 @@ private fun MessagesViewContent(
state: MessagesState, state: MessagesState,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (Link, Boolean) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -353,7 +369,7 @@ private fun MessagesViewContent(
state = state.timelineState, state = state.timelineState,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = { url -> onLinkClick(url, false) }, onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick, onContentClick = onContentClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
@ -388,7 +404,7 @@ private fun MessagesViewContent(
MessagesViewComposerBottomSheetContents( MessagesViewComposerBottomSheetContents(
subcomposing = subcomposing, subcomposing = subcomposing,
state = state, state = state,
onLinkClick = onLinkClick, onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
) )
}, },
sheetContentKey = sheetResizeContentKey.intValue, sheetContentKey = sheetResizeContentKey.intValue,

View file

@ -73,7 +73,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState( val textEditorState by rememberUpdatedState(
TextEditorState.Markdown(markdownTextEditorState) TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null)
) )
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }

View file

@ -43,7 +43,7 @@ fun IdentityChangeStateView(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
textId = CommonStrings.crypto_identity_change_pin_violation_new, textId = CommonStrings.crypto_identity_change_pin_violation_new,
isCritical = false, isCritical = false,
submitTextId = CommonStrings.action_ok, submitTextId = CommonStrings.action_dismiss,
onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) }, onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) },
modifier = modifier, modifier = modifier,
) )

View file

@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.link.LinkPresenter
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@ -46,6 +48,9 @@ interface MessagesModule {
@Binds @Binds
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState> fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
@Binds
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
@Binds @Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState> fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>

View file

@ -0,0 +1,15 @@
/*
* 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.link
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
data class ConfirmingLinkClick(
val link: Link,
) : AsyncAction.Confirming

View file

@ -0,0 +1,39 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.link
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.containsRtLOverride
import io.element.android.libraries.di.AppScope
import io.element.android.wysiwyg.link.Link
import java.net.URI
import javax.inject.Inject
interface LinkChecker {
fun isSafe(link: Link): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultLinkChecker @Inject constructor() : LinkChecker {
override fun isSafe(link: Link): Boolean {
return if (link.url.containsRtLOverride()) {
false
} else {
val textUrl = tryOrNull { URI(link.text).toURL() }
val urlUrl = tryOrNull { URI(link.url).toURL() }
if (textUrl == null || urlUrl == null) {
// The text is not a Url, or the url is not valid
true
} else {
// the hosts must match
textUrl.host == urlUrl.host
}
}
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.link
import io.element.android.wysiwyg.link.Link
sealed interface LinkEvents {
data class OnLinkClick(val link: Link) : LinkEvents
data object Confirm : LinkEvents
data object Cancel : LinkEvents
}

View file

@ -0,0 +1,53 @@
/*
* 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.link
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.wysiwyg.link.Link
import javax.inject.Inject
class LinkPresenter @Inject constructor(
private val linkChecker: LinkChecker,
) : Presenter<LinkState> {
@Composable
override fun present(): LinkState {
val linkClick: MutableState<AsyncAction<Link>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvents(linkEvents: LinkEvents) {
when (linkEvents) {
is LinkEvents.OnLinkClick -> {
linkClick.value = AsyncAction.Loading
val result = linkChecker.isSafe(linkEvents.link)
if (result) {
linkClick.value = AsyncAction.Success(linkEvents.link)
} else {
// Confirm first
linkClick.value = ConfirmingLinkClick(linkEvents.link)
}
}
LinkEvents.Confirm -> {
linkClick.value = (linkClick.value as? ConfirmingLinkClick)
?.let { AsyncAction.Success(it.link) }
?: AsyncAction.Uninitialized
}
LinkEvents.Cancel -> {
linkClick.value = AsyncAction.Uninitialized
}
}
}
return LinkState(
linkClick = linkClick.value,
eventSink = ::handleEvents,
)
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.link
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
data class LinkState(
val linkClick: AsyncAction<Link>,
val eventSink: (LinkEvents) -> Unit,
)

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.link
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
open class LinkStateProvider : PreviewParameterProvider<LinkState> {
override val values: Sequence<LinkState>
get() = sequenceOf(
aLinkState(),
aLinkState(
linkClick = ConfirmingLinkClick(
Link(
url = "https://evil.io",
text = "https://element.io"
),
),
),
)
}
fun aLinkState(
linkClick: AsyncAction<Link> = AsyncAction.Uninitialized,
eventSink: (LinkEvents) -> Unit = {},
) = LinkState(
linkClick = linkClick,
eventSink = eventSink,
)

View file

@ -0,0 +1,73 @@
/*
* 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.link
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.extensions.ensureEndsLeftToRight
import io.element.android.libraries.core.extensions.filterDirectionOverrides
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@Composable
fun LinkView(
state: LinkState,
onLinkValid: (Link) -> Unit,
modifier: Modifier = Modifier,
) {
when (state.linkClick) {
AsyncAction.Uninitialized,
AsyncAction.Loading,
is AsyncAction.Failure -> Unit
is AsyncAction.Confirming -> {
if (state.linkClick is ConfirmingLinkClick) {
ConfirmationDialog(
modifier = modifier,
title = stringResource(CommonStrings.dialog_confirm_link_title),
content = stringResource(
CommonStrings.dialog_confirm_link_message,
state.linkClick.link.text.ensureEndsLeftToRight(),
state.linkClick.link.url.filterDirectionOverrides(),
),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = {
state.eventSink(LinkEvents.Confirm)
},
onDismiss = {
state.eventSink(LinkEvents.Cancel)
},
)
}
}
is AsyncAction.Success -> {
val latestOnLinkValid by rememberUpdatedState(onLinkValid)
LaunchedEffect(state.linkClick.data) {
latestOnLinkValid(state.linkClick.data)
state.eventSink(LinkEvents.Cancel)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview {
LinkView(
state = state,
onLinkValid = {},
)
}

View file

@ -30,6 +30,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
@ -104,6 +105,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val messageComposerContext: DefaultMessageComposerContext, private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource, private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
@ -139,6 +141,8 @@ class MessageComposerPresenter @AssistedInject constructor(
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState()
val richTextEditorState = richTextEditorStateFactory.remember() val richTextEditorState = richTextEditorStateFactory.remember()
if (isTesting) { if (isTesting) {
richTextEditorState.isReadyToProcessActions = true richTextEditorState.isReadyToProcessActions = true
@ -155,7 +159,8 @@ class MessageComposerPresenter @AssistedInject constructor(
val canShareLocation = remember { mutableStateOf(false) } val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing) canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing) &&
locationService.isServiceAvailable()
} }
val canCreatePoll = remember { mutableStateOf(false) } val canCreatePoll = remember { mutableStateOf(false) }
@ -239,9 +244,9 @@ class MessageComposerPresenter @AssistedInject constructor(
val textEditorState by rememberUpdatedState( val textEditorState by rememberUpdatedState(
if (showTextFormatting) { if (showTextFormatting) {
TextEditorState.Rich(richTextEditorState) TextEditorState.Rich(richTextEditorState, roomInfo.isEncrypted == true)
} else { } else {
TextEditorState.Markdown(markdownTextEditorState) TextEditorState.Markdown(markdownTextEditorState, roomInfo.isEncrypted == true)
} }
) )

View file

@ -109,13 +109,13 @@ class PinnedMessagesListNode @AssistedInject constructor(
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onEventClick = ::onEventClick, onEventClick = ::onEventClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onLinkClick = { url -> onLinkClick(context, url) }, onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = { onLinkLongClick = {
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( context.copyToClipboard(
it, it.url,
context.getString(CommonStrings.common_copied_to_clipboard) context.getString(CommonStrings.common_copied_to_clipboard)
) )
}, },

View file

@ -25,6 +25,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@ -63,6 +64,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
timelineItemsFactoryCreator: TimelineItemsFactory.Creator, timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider, private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val linkPresenter: Presenter<LinkState>,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter<ActionListState>, @Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
@ -106,6 +108,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
) )
} }
val timelineProtectionState = timelineProtectionPresenter.present() val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
@ -127,6 +130,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
return pinnedMessagesListState( return pinnedMessagesListState(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
linkState = linkState,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems, timelineItems = pinnedMessageItems,
eventSink = ::handleEvents eventSink = ::handleEvents
@ -223,6 +227,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private fun pinnedMessagesListState( private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
linkState: LinkState,
userEventPermissions: UserEventPermissions, userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>, timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit eventSink: (PinnedMessagesListEvents) -> Unit
@ -238,6 +243,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
PinnedMessagesListState.Filled( PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
linkState = linkState,
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data, timelineItems = timelineItems.data,
actionListState = actionListState, actionListState = actionListState,

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
@ -31,6 +32,7 @@ sealed interface PinnedMessagesListState {
val userEventPermissions: UserEventPermissions, val userEventPermissions: UserEventPermissions,
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState, val actionListState: ActionListState,
val linkState: LinkState,
val eventSink: (PinnedMessagesListEvents) -> Unit, val eventSink: (PinnedMessagesListEvents) -> Unit,
) : PinnedMessagesListState { ) : PinnedMessagesListState {
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@ -86,6 +88,7 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState( fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
linkState: LinkState = aLinkState(),
timelineItems: List<TimelineItem> = emptyList(), timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
@ -93,6 +96,7 @@ fun aLoadedPinnedMessagesListState(
) = PinnedMessagesListState.Filled( ) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
linkState = linkState,
timelineItems = timelineItems.toImmutableList(), timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState, actionListState = actionListState,
userEventPermissions = aUserEventPermissions, userEventPermissions = aUserEventPermissions,

View file

@ -28,6 +28,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
@ -50,6 +52,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun PinnedMessagesListView( fun PinnedMessagesListView(
@ -57,8 +60,8 @@ fun PinnedMessagesListView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -113,8 +116,8 @@ private fun PinnedMessagesListContent(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onErrorDismiss: () -> Unit, onErrorDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -169,8 +172,8 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled, state: PinnedMessagesListState.Filled,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
@ -220,7 +223,9 @@ private fun PinnedMessagesListLoaded(
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
focusedEventId = null, focusedEventId = null,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = { link ->
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
},
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick, onContentClick = onEventClick,
onLongClick = ::onMessageLongClick, onLongClick = ::onMessageLongClick,
@ -238,7 +243,9 @@ private fun PinnedMessagesListLoaded(
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) }, onContentClick = { onEventClick(event) },
onLongClick = { onMessageLongClick(event) }, onLongClick = { onMessageLongClick(event) },
onLinkClick = onLinkClick, onLinkClick = { link ->
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
},
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
@ -247,6 +254,10 @@ private fun PinnedMessagesListLoaded(
) )
} }
} }
LinkView(
state.linkState,
onLinkValid = onLinkClick,
)
} }
@Composable @Composable
@ -254,8 +265,8 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit, onContentClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -92,7 +93,7 @@ fun TimelineView(
state: TimelineState, state: TimelineState,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
@ -134,12 +135,12 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId)) state.eventSink(TimelineEvents.FocusOnEvent(eventId))
} }
fun onLinkLongClick(link: String) { fun onLinkLongClick(link: Link) {
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( context.copyToClipboard(
link, link.url,
context.getString(CommonStrings.common_copied_to_clipboard) context.getString(CommonStrings.common_copied_to_clipboard)
) )
} }

View file

@ -94,6 +94,7 @@ 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.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -117,8 +118,8 @@ fun TimelineItemEventRow(
isHighlighted: Boolean, isHighlighted: Boolean,
onEventClick: () -> Unit, onEventClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemGroupedEventsRow( fun TimelineItemGroupedEventsRow(
@ -45,8 +46,8 @@ fun TimelineItemGroupedEventsRow(
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -114,8 +115,8 @@ private fun TimelineItemGroupedEventsRowContent(
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.link.Link
@Composable @Composable
internal fun TimelineItemRow( internal fun TimelineItemRow(
@ -47,8 +48,8 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
@ -39,8 +40,8 @@ fun TimelineItemEventContentView(
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onLinkClick: (url: String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},

View file

@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -64,8 +65,8 @@ fun TimelineItemImageView(
hideMediaContent: Boolean, hideMediaContent: Boolean,
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -41,12 +41,13 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable @Composable
fun TimelineItemTextView( fun TimelineItemTextView(
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) { ) {

View file

@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -73,8 +74,8 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (String) -> Unit, onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {

View file

@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@ -1159,6 +1160,7 @@ class MessagesPresenterTest {
reactionSummaryPresenter = { aReactionSummaryState() }, reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() }, identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
syncService = FakeSyncService(), syncService = FakeSyncService(),

View file

@ -44,11 +44,11 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("identity appears to have changed", substring = true).assertExists("should display pin violation warning") rule.onNodeWithText("identity was reset", substring = true).assertExists("should display pin violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.action_ok) rule.clickOn(res = CommonStrings.action_dismiss)
eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost"))) eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost")))
} }
@ -67,7 +67,7 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("verified identity has changed", substring = true).assertExists("should display verification violation warning") rule.onNodeWithText("identity was reset", substring = true).assertExists("should display verification violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
@ -92,8 +92,7 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("identity appears to have changed", substring = true).assertDoesNotExist() rule.onNodeWithText("identity was reset", substring = true).assertDoesNotExist()
rule.onNodeWithText("verified identity has changed", substring = true).assertDoesNotExist()
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView(

View file

@ -0,0 +1,51 @@
/*
* 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.link
import com.google.common.truth.Truth.assertThat
import io.element.android.wysiwyg.link.Link
import org.junit.Test
class DefaultLinkCheckerTest {
private val sut = DefaultLinkChecker()
@Test
fun `when url and text are identical, the link is safe`() {
assertThat(sut.isSafe(Link("url", "url"))).isTrue()
}
@Test
fun `when url is not safe, the link is safe`() {
assertThat(sut.isSafe(Link("url", "https://example.org"))).isTrue()
}
@Test
fun `when text is a url, and url is identical the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "https://example.org"))).isTrue()
}
@Test
fun `when url contains RtL char, the link is not safe`() {
assertThat(sut.isSafe(Link("https://example\u202E.org", "text"))).isFalse()
}
@Test
fun `when text is not a url, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "url"))).isTrue()
}
@Test
fun `when text is a url and hosts match, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org/some/path", "https://example.org"))).isTrue()
}
@Test
fun `when text is a url and hosts do not match, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "https://evil.org"))).isFalse()
}
}

View file

@ -0,0 +1,17 @@
/*
* 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.link
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.wysiwyg.link.Link
class FakeLinkChecker(
private val isSafeResult: (Link) -> Boolean = { lambdaError() }
) : LinkChecker {
override fun isSafe(link: Link) = isSafeResult(link)
}

View file

@ -0,0 +1,96 @@
/*
* 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.link
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.tests.testutils.WarmUpRule
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.wysiwyg.link.Link
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
val aLink = Link(url = "url", text = "text")
class LinkPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - safe link case`() = runTest {
val isSafeResult = lambdaRecorder<Link, Boolean> {
true
}
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = isSafeResult)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(AsyncAction.Success(aLink))
isSafeResult.assertions().isCalledOnce().with(value(aLink))
}
}
@Test
fun `present - suspicious link case - cancel`() = runTest {
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = { false })
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink))
state.eventSink(LinkEvents.Cancel)
val finalState = awaitItem()
assertThat(finalState.linkClick).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - suspicious link case - confirm`() = runTest {
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = { false })
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink))
state.eventSink(LinkEvents.Confirm)
val finalState = awaitItem()
assertThat(finalState.linkClick).isEqualTo(AsyncAction.Success(aLink))
}
}
private fun createPresenter(
linkChecker: LinkChecker = FakeLinkChecker(),
) = LinkPresenter(
linkChecker = linkChecker,
)
}

View file

@ -0,0 +1,89 @@
/*
* 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.link
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.wysiwyg.link.Link
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LinkViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on cancel emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
rule.setLinkView(
aLinkState(
linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(
LinkEvents.Cancel
)
}
@Test
fun `clicking on continue emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
rule.setLinkView(
aLinkState(
linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(
LinkEvents.Confirm
)
}
@Test
fun `success state invokes the callback and emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
ensureCalledOnceWithParam(aLink) { callback ->
rule.setLinkView(
aLinkState(
linkClick = AsyncAction.Success(aLink),
eventSink = eventsRecorder,
),
onLinkValid = callback,
)
}
eventsRecorder.assertSingle(
LinkEvents.Cancel
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkView(
state: LinkState,
onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
LinkView(
state = state,
onLinkValid = onLinkValid,
)
}
}

View file

@ -18,6 +18,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.impl.FakeMessagesNavigator import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
@ -1536,6 +1538,7 @@ class MessageComposerPresenterTest {
navigator: MessagesNavigator = FakeMessagesNavigator(), navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this.pickerProvider, pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService, featureFlagService: FeatureFlagService = this.featureFlagService,
locationService: LocationService = FakeLocationService(true),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
@ -1558,6 +1561,7 @@ class MessageComposerPresenterTest {
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService, analyticsService = analyticsService,
locationService = locationService,
messageComposerContext = DefaultMessageComposerContext(), messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),

View file

@ -12,6 +12,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
@ -315,6 +316,7 @@ class PinnedMessagesListPresenterTest {
timelineProtectionPresenter = { aTimelineProtectionState() }, timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
actionListPresenter = { anActionListState() }, actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() },
analyticsService = analyticsService, analyticsService = analyticsService,
appCoroutineScope = this, appCoroutineScope = this,
) )

View file

@ -29,6 +29,7 @@ import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import io.element.android.wysiwyg.link.Link
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -99,8 +100,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setSafeContent { setSafeContent {
PinnedMessagesListView( PinnedMessagesListView(

View file

@ -34,6 +34,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import org.junit.Rule import org.junit.Rule
@ -175,7 +176,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
state: TimelineState, state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@ -112,8 +111,7 @@ class DeveloperSettingsPresenter @Inject constructor(
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) } triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
) )
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch {
// If the URL is either empty or the default one, we want to save 'null' to remove the custom URL val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
} }
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
@ -133,7 +131,6 @@ class DeveloperSettingsPresenter @Inject constructor(
rageshakeState = rageshakeState, rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState( customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl, baseUrl = customElementCallBaseUrl,
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
validator = ::customElementCallUrlValidator, validator = ::customElementCallUrlValidator,
), ),
hideImagesAndVideos = hideImagesAndVideos, hideImagesAndVideos = hideImagesAndVideos,

View file

@ -27,6 +27,5 @@ data class DeveloperSettingsState(
data class CustomElementCallBaseUrlState( data class CustomElementCallBaseUrlState(
val baseUrl: String?, val baseUrl: String?,
val defaultUrl: String,
val validator: (String?) -> Boolean, val validator: (String?) -> Boolean,
) )

View file

@ -47,10 +47,8 @@ fun aDeveloperSettingsState(
fun aCustomElementCallBaseUrlState( fun aCustomElementCallBaseUrlState(
baseUrl: String? = null, baseUrl: String? = null,
defaultUrl: String = "https://call.element.io",
validator: (String?) -> Boolean = { true }, validator: (String?) -> Boolean = { true },
) = CustomElementCallBaseUrlState( ) = CustomElementCallBaseUrlState(
baseUrl = baseUrl, baseUrl = baseUrl,
defaultUrl = defaultUrl,
validator = validator, validator = validator,
) )

View file

@ -136,22 +136,20 @@ private fun ElementCallCategory(
) { ) {
PreferenceCategory(title = "Element Call", showTopDivider = true) { PreferenceCategory(title = "Element Call", showTopDivider = true) {
val callUrlState = state.customElementCallBaseUrlState val callUrlState = state.customElementCallBaseUrlState
fun isUsingDefaultUrl(value: String?): Boolean {
return value.isNullOrEmpty() || value == callUrlState.defaultUrl
}
val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) { val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) {
stringResource(R.string.screen_advanced_settings_element_call_base_url_description) stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
} else { } else {
callUrlState.baseUrl callUrlState.baseUrl
} }
PreferenceTextField( PreferenceTextField(
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url), headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
value = callUrlState.baseUrl ?: callUrlState.defaultUrl, value = callUrlState.baseUrl,
placeholder = "https://.../room",
supportingText = supportingText, supportingText = supportingText,
validation = callUrlState.validator, validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !isUsingDefaultUrl(value) }, displayValue = { value -> !value.isNullOrEmpty() },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) } onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) }
) )

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Vlastní URL pro Element Call"</string> <string name="screen_advanced_settings_element_call_base_url">"Vlastní URL pro Element Call"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Nastavte vlastní URL pro Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Nastavte vlastní URL pro Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Skrýt avatary v žádostech o pozvání do místnosti"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Skrýt náhledy médií na časové ose"</string>
<string name="screen_advanced_settings_media_compression_description">"Rychlejší nahrávání fotografií a videí a snížení spotřeby dat"</string> <string name="screen_advanced_settings_media_compression_description">"Rychlejší nahrávání fotografií a videí a snížení spotřeby dat"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimalizace kvality médií"</string> <string name="screen_advanced_settings_media_compression_title">"Optimalizace kvality médií"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderování a bezpečnost"</string>
<string name="screen_advanced_settings_push_provider_android">"Poskytovatel push oznámení"</string> <string name="screen_advanced_settings_push_provider_android">"Poskytovatel push oznámení"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Vypněte editor formátovaného textu pro ruční zadání Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Vypněte editor formátovaného textu pro ruční zadání Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potvrzení o přečtení"</string> <string name="screen_advanced_settings_send_read_receipts">"Potvrzení o přečtení"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Benutzerdefinierte Element-Aufruf-Basis-URL"</string> <string name="screen_advanced_settings_element_call_base_url">"Benutzerdefinierte Element-Aufruf-Basis-URL"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Lege eine eigene Basis-URL für Element Call fest."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Lege eine eigene Basis-URL für Element Call fest."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Ungültige URL, bitte stelle sicher, dass du das Protokoll (http/https) und die richtige Adresse angibst."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"Ungültige URL, bitte stelle sicher, dass du das Protokoll (http/https) und die richtige Adresse angibst."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Einladungsanfragen im Raum ausblenden"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschauen in der Zeitleiste ausblenden"</string>
<string name="screen_advanced_settings_media_compression_description">"Laden Sie Fotos und Videos schneller hoch und reduzieren Sie die Datennutzung"</string> <string name="screen_advanced_settings_media_compression_description">"Laden Sie Fotos und Videos schneller hoch und reduzieren Sie die Datennutzung"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimieren Sie die Medienqualität"</string> <string name="screen_advanced_settings_media_compression_title">"Optimieren Sie die Medienqualität"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string>
<string name="screen_advanced_settings_push_provider_android">"Anbieter für Push-Benachrichtigungen"</string> <string name="screen_advanced_settings_push_provider_android">"Anbieter für Push-Benachrichtigungen"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben."</string>
<string name="screen_advanced_settings_send_read_receipts">"Lesebestätigungen"</string> <string name="screen_advanced_settings_send_read_receipts">"Lesebestätigungen"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"URL de base pour Element Call personnalisée"</string> <string name="screen_advanced_settings_element_call_base_url">"URL de base pour Element Call personnalisée"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Configurer une URL de base pour Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Configurer une URL de base pour Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL invalide, assurez-vous dinclure le protocol (http/https) et ladresse correcte."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"URL invalide, assurez-vous dinclure le protocol (http/https) et ladresse correcte."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Masquer les avatars des salons dans les invitations"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Masquer les aperçus des médias dans les discussions"</string>
<string name="screen_advanced_settings_media_compression_description">"Téléchargez des photos et des vidéos plus rapidement et réduisez la consommation de données"</string> <string name="screen_advanced_settings_media_compression_description">"Téléchargez des photos et des vidéos plus rapidement et réduisez la consommation de données"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimisez la qualité des médias"</string> <string name="screen_advanced_settings_media_compression_title">"Optimisez la qualité des médias"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Modération et sécurité"</string>
<string name="screen_advanced_settings_push_provider_android">"Fournisseur de Push"</string> <string name="screen_advanced_settings_push_provider_android">"Fournisseur de Push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Désactivez léditeur de texte enrichi pour saisir manuellement du Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Désactivez léditeur de texte enrichi pour saisir manuellement du Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Accusés de lecture"</string> <string name="screen_advanced_settings_send_read_receipts">"Accusés de lecture"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Egyéni Element Call alapwebcím"</string> <string name="screen_advanced_settings_element_call_base_url">"Egyéni Element Call alapwebcím"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Egyéni alapwebcím beállítása az Element Callhoz."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Egyéni alapwebcím beállítása az Element Callhoz."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Érvénytelen webcím, győződjön meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"Érvénytelen webcím, győződjön meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Profilképek elrejtése a szobameghívókban"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Médiaelőnézetek elrejtése az idővonalon"</string>
<string name="screen_advanced_settings_media_compression_description">"Töltse fel gyorsabban a fényképeket és videókat, valamint csökkentse az adatforgalmat"</string> <string name="screen_advanced_settings_media_compression_description">"Töltse fel gyorsabban a fényképeket és videókat, valamint csökkentse az adatforgalmat"</string>
<string name="screen_advanced_settings_media_compression_title">"Média minőségének optimalizálása"</string> <string name="screen_advanced_settings_media_compression_title">"Média minőségének optimalizálása"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderálás és biztonság"</string>
<string name="screen_advanced_settings_push_provider_android">"Leküldéses értesítések szolgáltatója"</string> <string name="screen_advanced_settings_push_provider_android">"Leküldéses értesítések szolgáltatója"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"A formázott szöveges szerkesztő letiltása, hogy kézzel írhasson Markdownt."</string> <string name="screen_advanced_settings_rich_text_editor_description">"A formázott szöveges szerkesztő letiltása, hogy kézzel írhasson Markdownt."</string>
<string name="screen_advanced_settings_send_read_receipts">"Olvasási visszaigazolások"</string> <string name="screen_advanced_settings_send_read_receipts">"Olvasási visszaigazolások"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"URL base para Element Call personalizado"</string> <string name="screen_advanced_settings_element_call_base_url">"URL base para Element Call personalizado"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Define um URL base para a Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Define um URL base para a Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Esconder avatares nos pedidos de acesso a salas"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Esconder pré-visualizações de multimédia na cronologia"</string>
<string name="screen_advanced_settings_media_compression_description">"Carrega fotos e vídeos mais rapidamente e reduz a utilização de dados"</string> <string name="screen_advanced_settings_media_compression_description">"Carrega fotos e vídeos mais rapidamente e reduz a utilização de dados"</string>
<string name="screen_advanced_settings_media_compression_title">"Otimiza a qualidade da mídia"</string> <string name="screen_advanced_settings_media_compression_title">"Otimiza a qualidade da mídia"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderação e Segurança"</string>
<string name="screen_advanced_settings_push_provider_android">"Fornecedor de envio"</string> <string name="screen_advanced_settings_push_provider_android">"Fornecedor de envio"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Desativa o editor de texto rico para poderes escrever Markdown manualmente."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Desativa o editor de texto rico para poderes escrever Markdown manualmente."</string>
<string name="screen_advanced_settings_send_read_receipts">"Recibos de leitura"</string> <string name="screen_advanced_settings_send_read_receipts">"Recibos de leitura"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Anpassad bas-URL för Element Call"</string> <string name="screen_advanced_settings_element_call_base_url">"Anpassad bas-URL för Element Call"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Ange en anpassad bas-URL för Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Ange en anpassad bas-URL för Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Dölj avatarer i förfrågningar om rumsinbjudningar"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Dölj förhandsgranskningar av media i tidslinjen"</string>
<string name="screen_advanced_settings_media_compression_description">"Ladda upp foton och videor snabbare och minska dataanvändningen"</string> <string name="screen_advanced_settings_media_compression_description">"Ladda upp foton och videor snabbare och minska dataanvändningen"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimera mediekvaliteten"</string> <string name="screen_advanced_settings_media_compression_title">"Optimera mediekvaliteten"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderering och säkerhet"</string>
<string name="screen_advanced_settings_push_provider_android">"Pushnotisleverantör"</string> <string name="screen_advanced_settings_push_provider_android">"Pushnotisleverantör"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."</string>
<string name="screen_advanced_settings_send_read_receipts">"Läskvitton"</string> <string name="screen_advanced_settings_send_read_receipts">"Läskvitton"</string>

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Custom Element Call base URL"</string> <string name="screen_advanced_settings_element_call_base_url">"Custom Element Call base URL"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Set a custom base URL for Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Set a custom base URL for Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Invalid URL, please make sure you include the protocol (http/https) and the correct address."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"Invalid URL, please make sure you include the protocol (http/https) and the correct address."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Hide avatars in room invite requests"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Hide media previews in timeline"</string>
<string name="screen_advanced_settings_media_compression_description">"Upload photos and videos faster and reduce data usage"</string> <string name="screen_advanced_settings_media_compression_description">"Upload photos and videos faster and reduce data usage"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimise media quality"</string> <string name="screen_advanced_settings_media_compression_title">"Optimise media quality"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation and Safety"</string>
<string name="screen_advanced_settings_push_provider_android">"Push notification provider"</string> <string name="screen_advanced_settings_push_provider_android">"Push notification provider"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>
<string name="screen_advanced_settings_send_read_receipts">"Read receipts"</string> <string name="screen_advanced_settings_send_read_receipts">"Read receipts"</string>

View file

@ -10,7 +10,6 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
@ -130,7 +129,6 @@ class DeveloperSettingsPresenterTest {
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy") assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
} }
} }
} }

View file

@ -8,10 +8,16 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isEditable
import androidx.compose.ui.test.isFocusable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
@ -56,8 +62,10 @@ class DeveloperSettingsViewTest {
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url) rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
textInputNode.performTextInput("https://call.element.dev")
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io")) eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")

View file

@ -469,14 +469,14 @@ private fun RoomBadge.toMatrixBadgeData(): MatrixBadgeAtom.MatrixBadgeData {
MatrixBadgeAtom.MatrixBadgeData( MatrixBadgeAtom.MatrixBadgeData(
text = stringResource(R.string.screen_room_details_badge_not_encrypted), text = stringResource(R.string.screen_room_details_badge_not_encrypted),
icon = CompoundIcons.LockOff(), icon = CompoundIcons.LockOff(),
type = MatrixBadgeAtom.Type.Neutral, type = MatrixBadgeAtom.Type.Info,
) )
} }
RoomBadge.PUBLIC -> { RoomBadge.PUBLIC -> {
MatrixBadgeAtom.MatrixBadgeData( MatrixBadgeAtom.MatrixBadgeData(
text = stringResource(R.string.screen_room_details_badge_public), text = stringResource(R.string.screen_room_details_badge_public),
icon = CompoundIcons.Public(), icon = CompoundIcons.Public(),
type = MatrixBadgeAtom.Type.Neutral, type = MatrixBadgeAtom.Type.Info,
) )
} }
} }

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"Konfiguratu berreskurapena"</string> <string name="banner_set_up_recovery_submit">"Konfiguratu berreskurapena"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Sartu zure berreskuratze-gakoa"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Berreskuratze-gakoa ahaztu al duzu?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Berreskuratze-gakoa ahaztu al duzu?"</string>
<string name="full_screen_intent_banner_message">"Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko."</string> <string name="full_screen_intent_banner_message">"Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko."</string>
<string name="full_screen_intent_banner_title">"Hobetu deien esperientzia"</string> <string name="full_screen_intent_banner_title">"Hobetu deien esperientzia"</string>

View file

@ -35,6 +35,7 @@
<string name="screen_recovery_key_confirm_key_placeholder">"Sartu…"</string> <string name="screen_recovery_key_confirm_key_placeholder">"Sartu…"</string>
<string name="screen_recovery_key_confirm_lost_recovery_key">"Berreskuratze-gakoa galdu duzu?"</string> <string name="screen_recovery_key_confirm_lost_recovery_key">"Berreskuratze-gakoa galdu duzu?"</string>
<string name="screen_recovery_key_confirm_success">"Berreskuratze-gakoa berretsi da"</string> <string name="screen_recovery_key_confirm_success">"Berreskuratze-gakoa berretsi da"</string>
<string name="screen_recovery_key_confirm_title">"Sartu zure berreskuratze-gakoa"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Berreskuratze-gakoa kopiatu da"</string> <string name="screen_recovery_key_copied_to_clipboard">"Berreskuratze-gakoa kopiatu da"</string>
<string name="screen_recovery_key_generating_key">"Sortzen…"</string> <string name="screen_recovery_key_generating_key">"Sortzen…"</string>
<string name="screen_recovery_key_save_action">"Gorde berreskuratze-gakoa"</string> <string name="screen_recovery_key_save_action">"Gorde berreskuratze-gakoa"</string>

View file

@ -3,7 +3,7 @@
[versions] [versions]
# Project # Project
android_gradle_plugin = "8.8.1" android_gradle_plugin = "8.9.1"
kotlin = "2.1.10" kotlin = "2.1.10"
kotlinpoet = "2.1.0" kotlinpoet = "2.1.0"
ksp = "2.1.10-1.0.31" ksp = "2.1.10-1.0.31"
@ -46,11 +46,11 @@ coil = "3.1.0"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.6.0" appyx = "1.6.0"
sqldelight = "2.0.2" sqldelight = "2.0.2"
wysiwyg = "2.38.2" wysiwyg = "2.38.3"
telephoto = "0.15.1" telephoto = "0.15.1"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "2.13.0" dependencyAnalysis = "2.13.1"
# DI # DI
dagger = "2.56" dagger = "2.56"
@ -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" } 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" gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:33.10.0" google_firebase_bom = "com.google.firebase:firebase-bom:33.11.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } 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" } 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" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@ -174,7 +174,7 @@ jsoup = "org.jsoup:jsoup:1.19.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.20" matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.24"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", 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" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -188,7 +188,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.8.3" maplibre = "org.maplibre.gl:android-sdk:11.8.4"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0" opusencoder = "io.element.android:opusencoder:1.1.0"
@ -211,6 +211,9 @@ dagger_compiler = { module = "com.google.dagger:dagger-compiler", version.ref =
anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref = "anvil" } anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref = "anvil" }
anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" } 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.2"
# Auto services # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" }
@ -242,6 +245,6 @@ paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
sonarqube = "org.sonarqube:6.0.1.5171" sonarqube = "org.sonarqube:6.1.0.5360"
licensee = "app.cash.licensee:1.12.0" licensee = "app.cash.licensee:1.13.0"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -89,3 +89,12 @@ fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD) return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "") .replace("\\p{Mn}+".toRegex(), "")
} }
private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D'
fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERRIDE_CHAR" else this
fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR)
fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR }

View file

@ -8,6 +8,8 @@
package io.element.android.libraries.core.extensions package io.element.android.libraries.core.extensions
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class BasicExtensionsTest { class BasicExtensionsTest {
@ -43,4 +45,32 @@ class BasicExtensionsTest {
val output = input.ellipsize(5) val output = input.ellipsize(5)
assertEquals(input, output) assertEquals(input, output)
} }
@Test
fun `given text with RtL unicode override, when checking contains RtL Override, then returns true`() {
val textWithRtlOverride = "hello\u202Eworld"
val result = textWithRtlOverride.containsRtLOverride()
assertTrue(result)
}
@Test
fun `given text without RtL unicode override, when checking contains RtL Override, then returns false`() {
val textWithRtlOverride = "hello world"
val result = textWithRtlOverride.containsRtLOverride()
assertFalse(result)
}
@Test
fun `given text with RtL unicode override, when ensuring ends LtR, then appends a LtR unicode override`() {
val textWithRtlOverride = "123\u202E456"
val result = textWithRtlOverride.ensureEndsLeftToRight()
assertEquals("$textWithRtlOverride\u202D", result)
}
@Test
fun `given text with unicode direction overrides, when filtering direction overrides, then removes all overrides`() {
val textWithDirectionOverrides = "123\u202E456\u202d789"
val result = textWithDirectionOverrides.filterDirectionOverrides()
assertEquals("123456789", result)
}
} }

View file

@ -14,6 +14,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.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.badgeNegativeBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
@ -31,7 +33,8 @@ object MatrixBadgeAtom {
enum class Type { enum class Type {
Positive, Positive,
Neutral, Neutral,
Negative Negative,
Info,
} }
@Composable @Composable
@ -42,16 +45,19 @@ object MatrixBadgeAtom {
Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
Type.Info -> ElementTheme.colors.badgeInfoBackgroundColor
} }
val textColor = when (data.type) { val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.badgePositiveContentColor Type.Positive -> ElementTheme.colors.badgePositiveContentColor
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
Type.Info -> ElementTheme.colors.badgeInfoContentColor
} }
val iconColor = when (data.type) { val iconColor = when (data.type) {
Type.Positive -> ElementTheme.colors.iconSuccessPrimary Type.Positive -> ElementTheme.colors.iconSuccessPrimary
Type.Neutral -> ElementTheme.colors.iconSecondary Type.Neutral -> ElementTheme.colors.iconSecondary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary Type.Negative -> ElementTheme.colors.iconCriticalPrimary
Type.Info -> ElementTheme.colors.iconInfoPrimary
} }
Badge( Badge(
text = data.text, text = data.text,
@ -98,3 +104,15 @@ internal fun MatrixBadgeAtomNegativePreview() = ElementPreview {
) )
) )
} }
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomInfoPreview() = ElementPreview {
MatrixBadgeAtom.View(
MatrixBadgeAtom.MatrixBadgeData(
text = "Not encrypted",
icon = CompoundIcons.LockOff(),
type = MatrixBadgeAtom.Type.Info,
)
)
}

View file

@ -165,6 +165,14 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100 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) @OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600 get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600

View file

@ -1,14 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.call
import io.element.android.libraries.matrix.api.MatrixClient
interface ElementCallBaseUrlProvider {
suspend fun provides(matrixClient: MatrixClient): String?
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.widget
interface CallAnalyticCredentialsProvider {
val posthogUserId: String?
val posthogApiHost: String?
val posthogApiKey: String?
val rageshakeSubmitUrl: String?
val sentryDsn: String?
}

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.api.widget
import java.util.UUID import java.util.UUID
interface CallWidgetSettingsProvider { interface CallWidgetSettingsProvider {
fun provide( suspend fun provide(
baseUrl: String, baseUrl: String,
widgetId: String = UUID.randomUUID().toString(), widgetId: String = UUID.randomUUID().toString(),
encrypted: Boolean, encrypted: Boolean,

View file

@ -477,11 +477,16 @@ class RustMatrixClient(
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
override fun close() { override fun close() {
innerNotificationClient.close()
appCoroutineScope.launch { appCoroutineScope.launch {
roomFactory.destroy() roomFactory.destroy()
rustSyncService.destroy() rustSyncService.destroy()
notificationSettingsService.destroy() notificationSettingsService.destroy()
// This is sync, but it can destroy the `Client` instance and block stopping the sync service
notificationProcessSetup.destroy()
} }
sessionCoroutineScope.cancel() sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle?.cancelAndDestroy()
verificationService.destroy() verificationService.destroy()
@ -489,7 +494,6 @@ class RustMatrixClient(
sessionDelegate.clearCurrentClient() sessionDelegate.clearCurrentClient()
innerRoomListService.close() innerRoomListService.close()
notificationService.close() notificationService.close()
notificationProcessSetup.destroy()
encryptionService.close() encryptionService.close()
innerClient.close() innerClient.close()
} }

View file

@ -189,12 +189,12 @@ class RustMatrixAuthenticationService @Inject constructor(
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first") val client = currentClient ?: error("You need to call `setHomeserver()` first")
val oAuthAuthenticationData = client.urlForOidc( val oAuthAuthorizationData = client.urlForOidc(
oidcConfiguration = oidcConfigurationProvider.get(), oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(), prompt = prompt.toRustPrompt(),
) )
val url = oAuthAuthenticationData.loginUrl() val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthenticationData pendingOAuthAuthorizationData = oAuthAuthorizationData
OidcDetails(url) OidcDetails(url)
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() failure.mapAuthenticationException()
@ -205,7 +205,9 @@ class RustMatrixAuthenticationService @Inject constructor(
override suspend fun cancelOidcLogin(): Result<Unit> { override suspend fun cancelOidcLogin(): Result<Unit> {
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
pendingOAuthAuthorizationData?.close() pendingOAuthAuthorizationData?.use {
currentClient?.abortOidcAuth(it)
}
pendingOAuthAuthorizationData = null pendingOAuthAuthorizationData = null
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() failure.mapAuthenticationException()
@ -221,16 +223,18 @@ class RustMatrixAuthenticationService @Inject constructor(
runCatching { runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first") val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
val urlForOidcLogin = pendingOAuthAuthorizationData ?: error("You need to call `getOidcUrl()` first") client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
val sessionData = client.session().toSessionData( val sessionData = client.session().toSessionData(
isTokenValid = true, isTokenValid = true,
loginType = LoginType.OIDC, loginType = LoginType.OIDC,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close() pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null pendingOAuthAuthorizationData = null
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client)) newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)

View file

@ -1,43 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.call
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultElementCallBaseUrlProvider @Inject constructor(
private val elementWellKnownParser: ElementWellKnownParser,
) : ElementCallBaseUrlProvider {
override suspend fun provides(matrixClient: MatrixClient): String? {
val url = buildString {
append("https://")
append(matrixClient.userIdServerName())
append("/.well-known/element/element.json")
}
return matrixClient.getUrl(url)
.onFailure { failure ->
Timber.w(failure, "Failed to fetch well-known element.json")
}
.getOrNull()
?.let { wellKnownStr ->
elementWellKnownParser.parse(wellKnownStr)
.onFailure { failure ->
// Can be a HTML 404.
Timber.w(failure, "Failed to parse content")
}
.getOrNull()
}
?.call
?.widgetUrl
}
}

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