Merge branch 'release/25.03.3'
This commit is contained in:
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -46,6 +46,10 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
@@ -65,7 +69,7 @@ jobs:
|
||||
retention-days: 5
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
- uses: rnkdsh/action-upload-diawi@v1.5.7
|
||||
- uses: rnkdsh/action-upload-diawi@v1.5.8
|
||||
id: diawi
|
||||
# Do not fail the whole build if Diawi upload fails
|
||||
continue-on-error: true
|
||||
|
||||
4
.github/workflows/build_enterprise.yml
vendored
4
.github/workflows/build_enterprise.yml
vendored
@@ -54,6 +54,10 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug Enterprise APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -30,6 +30,10 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
||||
|
||||
4
.github/workflows/nightly_enterprise.yml
vendored
4
.github/workflows/nightly_enterprise.yml
vendored
@@ -36,6 +36,10 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -32,6 +32,10 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload bundle as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
65
CHANGES.md
65
CHANGES.md
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider
|
||||
|
||||
class ContributesNodeProcessorProvider : SymbolProcessorProvider {
|
||||
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
|
||||
val enableLogging = environment.options["enableLogging"]?.toBoolean() ?: false
|
||||
val enableLogging = environment.options["enableLogging"]?.toBoolean() == true
|
||||
return ContributesNodeProcessor(
|
||||
logger = environment.logger,
|
||||
codeGenerator = environment.codeGenerator,
|
||||
|
||||
@@ -59,21 +59,27 @@ android {
|
||||
splits {
|
||||
// Configures multiple APKs based on ABI.
|
||||
abi {
|
||||
// Enables building multiple APKs per ABI.
|
||||
isEnable = true
|
||||
val buildingAppBundle = gradle.startParameter.taskNames.any { it.contains("bundle") }
|
||||
|
||||
// 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
|
||||
// want APKs for armeabi-v7a, x86, arm64-v8a and x86_64.
|
||||
// Resets the list of ABIs that Gradle should create APKs for to none.
|
||||
reset()
|
||||
// Specifies a list of ABIs that Gradle should create APKs for.
|
||||
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
|
||||
|
||||
if (!buildingAppBundle) {
|
||||
// Specifies a list of ABIs that Gradle should create APKs for.
|
||||
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 {
|
||||
resourceConfigurations += locales
|
||||
androidResources {
|
||||
localeFilters += locales
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
package io.element.android.appconfig
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/202503030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202503030.txt
Normal 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
|
||||
@@ -1,3 +1,4 @@
|
||||
import extension.readLocalProperty
|
||||
import extension.setupAnvil
|
||||
|
||||
/*
|
||||
@@ -23,6 +24,49 @@ android {
|
||||
testOptions {
|
||||
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()
|
||||
@@ -47,6 +91,7 @@ dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.element.call.embedded)
|
||||
api(projects.features.call.api)
|
||||
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -8,10 +8,8 @@
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.libraries.di.AppScope
|
||||
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.SessionId
|
||||
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 javax.inject.Inject
|
||||
|
||||
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultCallWidgetProvider @Inject constructor(
|
||||
private val matrixClientsProvider: MatrixClientProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
|
||||
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
|
||||
) : CallWidgetProvider {
|
||||
override suspend fun getWidget(
|
||||
sessionId: SessionId,
|
||||
@@ -35,9 +34,10 @@ class DefaultCallWidgetProvider @Inject constructor(
|
||||
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
|
||||
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
|
||||
val room = matrixClient.getRoom(roomId) ?: error("Room not found")
|
||||
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
||||
?: elementCallBaseUrlProvider.provides(matrixClient)
|
||||
?: ElementCallConfig.DEFAULT_BASE_URL
|
||||
|
||||
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
||||
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
|
||||
|
||||
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
|
||||
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
|
||||
val callUrl = room.generateWidgetWebViewUrl(
|
||||
@@ -46,9 +46,10 @@ class DefaultCallWidgetProvider @Inject constructor(
|
||||
languageTag = languageTag,
|
||||
theme = theme,
|
||||
).getOrThrow()
|
||||
|
||||
CallWidgetProvider.GetWidgetResult(
|
||||
driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
|
||||
url = callUrl
|
||||
url = callUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.core.net.toUri
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import io.element.android.features.call.impl.BuildConfig
|
||||
@@ -37,6 +39,10 @@ class WebViewWidgetMessageInterceptor(
|
||||
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10)
|
||||
|
||||
init {
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
|
||||
.build()
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
@@ -117,6 +123,14 @@ class WebViewWidgetMessageInterceptor(
|
||||
|
||||
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
|
||||
|
||||
@@ -9,9 +9,7 @@ package io.element.android.features.call.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.call.ElementCallBaseUrlProvider
|
||||
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_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.preferences.api.store.AppPreferencesStore
|
||||
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 org.junit.Test
|
||||
|
||||
@@ -104,42 +100,13 @@ class DefaultCallWidgetProviderTest {
|
||||
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(
|
||||
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
|
||||
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
|
||||
) = DefaultCallWidgetProvider(
|
||||
matrixClientsProvider = matrixClientProvider,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
callWidgetSettingsProvider = callWidgetSettingsProvider,
|
||||
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,10 @@
|
||||
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
|
||||
<string name="screen_room_directory_search_title">"Каталог комнат"</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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_title">"Desaktibatu kontua"</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import config.BuildTimeConfig
|
||||
import extension.readLocalProperty
|
||||
|
||||
plugins {
|
||||
@@ -19,24 +20,36 @@ android {
|
||||
resValue(
|
||||
type = "string",
|
||||
name = "maptiler_api_key",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
|
||||
?: readLocalProperty("services.maptiler.apikey")
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
|
||||
?: readLocalProperty("services.maptiler.apikey")
|
||||
}
|
||||
?: ""
|
||||
)
|
||||
resValue(
|
||||
type = "string",
|
||||
name = "maptiler_light_map_id",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.lightMapId")
|
||||
// fall back to maptiler's default light map.
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.lightMapId")
|
||||
}
|
||||
// fall back to maptiler's default light map.
|
||||
?: "basic-v2"
|
||||
)
|
||||
resValue(
|
||||
type = "string",
|
||||
name = "maptiler_dark_map_id",
|
||||
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.darkMapId")
|
||||
// fall back to maptiler's default dark map.
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.darkMapId")
|
||||
}
|
||||
// fall back to maptiler's default dark map.
|
||||
?: "basic-v2-dark"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -103,6 +103,7 @@ fun StaticMapView(
|
||||
} else {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = collectedState.value.isLoading(),
|
||||
canReload = builder.isServiceAvailable(),
|
||||
contentDescription = contentDescription,
|
||||
width = maxWidth,
|
||||
height = maxHeight,
|
||||
|
||||
@@ -57,6 +57,8 @@ internal class MapTilerStaticMapUrlBuilder(
|
||||
// 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"
|
||||
}
|
||||
|
||||
override fun isServiceAvailable() = apiKey.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
|
||||
|
||||
@@ -9,8 +9,10 @@ package io.element.android.features.location.api.internal
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -18,7 +20,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 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.Icon
|
||||
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
|
||||
|
||||
@Composable
|
||||
internal fun StaticMapPlaceholder(
|
||||
showProgress: Boolean,
|
||||
canReload: Boolean,
|
||||
contentDescription: String?,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
@@ -54,7 +55,7 @@ internal fun StaticMapPlaceholder(
|
||||
)
|
||||
if (showProgress) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
} else if (canReload) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -70,14 +71,24 @@ internal fun StaticMapPlaceholder(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun StaticMapPlaceholderPreview(
|
||||
@PreviewParameter(BooleanProvider::class) values: Boolean
|
||||
) = ElementPreview {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = values,
|
||||
contentDescription = null,
|
||||
width = 400.dp,
|
||||
height = 400.dp,
|
||||
onLoadMapClick = {},
|
||||
)
|
||||
internal fun StaticMapPlaceholderPreview() = ElementPreview {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf(
|
||||
true to false,
|
||||
false to true,
|
||||
false to false,
|
||||
).forEach { (showProgress, canReload) ->
|
||||
StaticMapPlaceholder(
|
||||
showProgress = showProgress,
|
||||
canReload = canReload,
|
||||
contentDescription = null,
|
||||
width = 400.dp,
|
||||
height = 200.dp,
|
||||
onLoadMapClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ interface StaticMapUrlBuilder {
|
||||
height: Int,
|
||||
density: Float,
|
||||
): String
|
||||
|
||||
fun isServiceAvailable(): Boolean
|
||||
}
|
||||
|
||||
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)
|
||||
|
||||
@@ -17,6 +17,21 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
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
|
||||
fun `static map 1x density`() {
|
||||
assertThat(
|
||||
|
||||
@@ -49,6 +49,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.testtags)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
18
features/location/test/build.gradle.kts
Normal file
18
features/location/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,16 +19,22 @@ import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
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.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.core.SessionId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class CreateAccountPresenter @AssistedInject constructor(
|
||||
@Assisted private val url: String,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val clientProvider: MatrixClientProvider,
|
||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||
private val messageParser: MessageParser,
|
||||
private val buildMeta: BuildMeta,
|
||||
@@ -73,6 +79,12 @@ class CreateAccountPresenter @AssistedInject constructor(
|
||||
}.flatMap { externalSession ->
|
||||
authenticationService.importCreatedSession(externalSession)
|
||||
}.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
|
||||
defaultLoginUserStory.setLoginFlowIsDone(true)
|
||||
loggedInState.value = AsyncAction.Success(sessionId)
|
||||
|
||||
@@ -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.test.AN_EXCEPTION
|
||||
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.core.aBuildMeta
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
@@ -135,11 +136,13 @@ class CreateAccountPresenterTest {
|
||||
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
|
||||
messageParser: MessageParser = FakeMessageParser(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
) = CreateAccountPresenter(
|
||||
url = url,
|
||||
authenticationService = authenticationService,
|
||||
defaultLoginUserStory = defaultLoginUserStory,
|
||||
messageParser = messageParser,
|
||||
buildMeta = buildMeta,
|
||||
clientProvider = clientProvider,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ dependencies {
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.features.location.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
||||
@@ -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.knockrequests.api.list.KnockRequestsListEntryPoint
|
||||
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.ShowLocationEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
@@ -96,6 +97,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val locationService: LocationService,
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||
private val mentionSpanTheme: MentionSpanTheme,
|
||||
@@ -409,7 +411,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
NavTarget.LocationViewer(
|
||||
location = event.content.location,
|
||||
description = event.content.description,
|
||||
)
|
||||
).takeIf { locationService.isServiceAvailable() }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -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.model.TimelineItemAction
|
||||
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.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
@@ -96,6 +97,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
@Assisted private val timelinePresenter: Presenter<TimelineState>,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
@@ -136,6 +138,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val identityChangeState = identityChangeStatePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val linkState = linkPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
||||
@@ -245,6 +248,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
timelineState = timelineState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
linkState = linkState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
|
||||
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.link.LinkState
|
||||
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.timeline.TimelineState
|
||||
@@ -38,6 +39,7 @@ data class MessagesState(
|
||||
val timelineState: TimelineState,
|
||||
val timelineProtectionState: TimelineProtectionState,
|
||||
val identityChangeState: IdentityChangeState,
|
||||
val linkState: LinkState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
|
||||
@@ -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.crypto.identity.IdentityChangeState
|
||||
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.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
@@ -103,6 +105,7 @@ fun aMessagesState(
|
||||
),
|
||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
||||
linkState: LinkState = aLinkState(),
|
||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
customReactionState: CustomReactionState = aCustomReactionState(),
|
||||
@@ -124,6 +127,7 @@ fun aMessagesState(
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
linkState = linkState,
|
||||
timelineState = timelineState,
|
||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||
actionListState = actionListState,
|
||||
|
||||
@@ -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.model.TimelineItemAction
|
||||
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.DisabledComposerView
|
||||
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.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import timber.log.Timber
|
||||
import kotlin.random.Random
|
||||
@@ -207,7 +210,14 @@ fun MessagesView(
|
||||
onContentClick = ::onContentClick,
|
||||
onMessageLongClick = ::onMessageLongClick,
|
||||
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,
|
||||
onReactionLongClick = ::onEmojiReactionLongClick,
|
||||
onMoreReactionsClick = ::onMoreReactionsClick,
|
||||
@@ -258,6 +268,12 @@ fun MessagesView(
|
||||
onUserDataClick = onUserDataClick,
|
||||
)
|
||||
ReinviteDialog(state = state)
|
||||
LinkView(
|
||||
onLinkValid = { link ->
|
||||
onLinkClick(link.url, false)
|
||||
},
|
||||
state = state.linkState,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -279,7 +295,7 @@ private fun MessagesViewContent(
|
||||
state: MessagesState,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String, Boolean) -> Unit,
|
||||
onLinkClick: (Link, Boolean) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -353,7 +369,7 @@ private fun MessagesViewContent(
|
||||
state = state.timelineState,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = { url -> onLinkClick(url, false) },
|
||||
onLinkClick = { link -> onLinkClick(link, false) },
|
||||
onContentClick = onContentClick,
|
||||
onMessageLongClick = onMessageLongClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
@@ -388,7 +404,7 @@ private fun MessagesViewContent(
|
||||
MessagesViewComposerBottomSheetContents(
|
||||
subcomposing = subcomposing,
|
||||
state = state,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
|
||||
)
|
||||
},
|
||||
sheetContentKey = sheetResizeContentKey.intValue,
|
||||
|
||||
@@ -73,7 +73,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
val textEditorState by rememberUpdatedState(
|
||||
TextEditorState.Markdown(markdownTextEditorState)
|
||||
TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null)
|
||||
)
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
@@ -43,7 +43,7 @@ fun IdentityChangeStateView(
|
||||
onLinkClick = onLinkClick,
|
||||
textId = CommonStrings.crypto_identity_change_pin_violation_new,
|
||||
isCritical = false,
|
||||
submitTextId = CommonStrings.action_ok,
|
||||
submitTextId = CommonStrings.action_dismiss,
|
||||
onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) },
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@@ -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.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
|
||||
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.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
@@ -46,6 +48,9 @@ interface MessagesModule {
|
||||
@Binds
|
||||
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
|
||||
|
||||
@Binds
|
||||
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
|
||||
|
||||
@Binds
|
||||
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
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.attachments.Attachment
|
||||
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 snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val locationService: LocationService,
|
||||
private val messageComposerContext: DefaultMessageComposerContext,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
|
||||
@@ -139,6 +141,8 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
|
||||
val richTextEditorState = richTextEditorStateFactory.remember()
|
||||
if (isTesting) {
|
||||
richTextEditorState.isReadyToProcessActions = true
|
||||
@@ -155,7 +159,8 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||
|
||||
val canShareLocation = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing) &&
|
||||
locationService.isServiceAvailable()
|
||||
}
|
||||
|
||||
val canCreatePoll = remember { mutableStateOf(false) }
|
||||
@@ -239,9 +244,9 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||
|
||||
val textEditorState by rememberUpdatedState(
|
||||
if (showTextFormatting) {
|
||||
TextEditorState.Rich(richTextEditorState)
|
||||
TextEditorState.Rich(richTextEditorState, roomInfo.isEncrypted == true)
|
||||
} else {
|
||||
TextEditorState.Markdown(markdownTextEditorState)
|
||||
TextEditorState.Markdown(markdownTextEditorState, roomInfo.isEncrypted == true)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -109,13 +109,13 @@ class PinnedMessagesListNode @AssistedInject constructor(
|
||||
onBackClick = ::navigateUp,
|
||||
onEventClick = ::onEventClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onLinkClick = { url -> onLinkClick(context, url) },
|
||||
onLinkClick = { link -> onLinkClick(context, link.url) },
|
||||
onLinkLongClick = {
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
)
|
||||
context.copyToClipboard(
|
||||
it,
|
||||
it.url,
|
||||
context.getString(CommonStrings.common_copied_to_clipboard)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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.actionlist.ActionListState
|
||||
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.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
@@ -63,6 +64,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
|
||||
private val timelineProvider: PinnedEventsTimelineProvider,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
@@ -106,6 +108,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
)
|
||||
}
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val linkState = linkPresenter.present()
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
||||
|
||||
@@ -127,6 +130,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
return pinnedMessagesListState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
linkState = linkState,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = pinnedMessageItems,
|
||||
eventSink = ::handleEvents
|
||||
@@ -223,6 +227,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
private fun pinnedMessagesListState(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
linkState: LinkState,
|
||||
userEventPermissions: UserEventPermissions,
|
||||
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
|
||||
eventSink: (PinnedMessagesListEvents) -> Unit
|
||||
@@ -238,6 +243,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
linkState = linkState,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = timelineItems.data,
|
||||
actionListState = actionListState,
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
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.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
@@ -31,6 +32,7 @@ sealed interface PinnedMessagesListState {
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val linkState: LinkState,
|
||||
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||
|
||||
@@ -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.actionlist.ActionListState
|
||||
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.aTimelineItemDaySeparator
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
@@ -86,6 +88,7 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
|
||||
fun aLoadedPinnedMessagesListState(
|
||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||
linkState: LinkState = aLinkState(),
|
||||
timelineItems: List<TimelineItem> = emptyList(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
|
||||
@@ -93,6 +96,7 @@ fun aLoadedPinnedMessagesListState(
|
||||
) = PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
linkState = linkState,
|
||||
timelineItems = timelineItems.toImmutableList(),
|
||||
actionListState = actionListState,
|
||||
userEventPermissions = aUserEventPermissions,
|
||||
|
||||
@@ -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.ActionListView
|
||||
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.event.TimelineItemEventContentView
|
||||
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.services.analytics.compose.LocalAnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun PinnedMessagesListView(
|
||||
@@ -57,8 +60,8 @@ fun PinnedMessagesListView(
|
||||
onBackClick: () -> Unit,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -113,8 +116,8 @@ private fun PinnedMessagesListContent(
|
||||
state: PinnedMessagesListState,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -169,8 +172,8 @@ private fun PinnedMessagesListLoaded(
|
||||
state: PinnedMessagesListState.Filled,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
|
||||
@@ -220,7 +223,9 @@ private fun PinnedMessagesListLoaded(
|
||||
isLastOutgoingMessage = false,
|
||||
focusedEventId = null,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkClick = { link ->
|
||||
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
|
||||
},
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onEventClick,
|
||||
onLongClick = ::onMessageLongClick,
|
||||
@@ -238,7 +243,9 @@ private fun PinnedMessagesListLoaded(
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
onContentClick = { onEventClick(event) },
|
||||
onLongClick = { onMessageLongClick(event) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkClick = { link ->
|
||||
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
|
||||
},
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
@@ -247,6 +254,10 @@ private fun PinnedMessagesListLoaded(
|
||||
)
|
||||
}
|
||||
}
|
||||
LinkView(
|
||||
state.linkState,
|
||||
onLinkValid = onLinkClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -254,8 +265,8 @@ private fun TimelineItemEventContentViewWrapper(
|
||||
event: TimelineItem.Event,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
onContentClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -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.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -92,7 +93,7 @@ fun TimelineView(
|
||||
state: TimelineState,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClick: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
@@ -134,12 +135,12 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
fun onLinkLongClick(link: String) {
|
||||
fun onLinkLongClick(link: Link) {
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
)
|
||||
context.copyToClipboard(
|
||||
link,
|
||||
link.url,
|
||||
context.getString(CommonStrings.common_copied_to_clipboard)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
@@ -117,8 +118,8 @@ fun TimelineItemEventRow(
|
||||
isHighlighted: Boolean,
|
||||
onEventClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
|
||||
@@ -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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemGroupedEventsRow(
|
||||
@@ -45,8 +46,8 @@ fun TimelineItemGroupedEventsRow(
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -114,8 +115,8 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
|
||||
@@ -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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
internal fun TimelineItemRow(
|
||||
@@ -47,8 +48,8 @@ internal fun TimelineItemRow(
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
focusedEventId: EventId?,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
|
||||
@@ -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.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
@@ -39,8 +40,8 @@ fun TimelineItemEventContentView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (url: String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
|
||||
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -64,8 +65,8 @@ fun TimelineItemImageView(
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onShowContentClick: () -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -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.updateMentionStyles
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemTextView(
|
||||
content: TimelineItemTextBasedContent,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
|
||||
@@ -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.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -73,8 +74,8 @@ fun TimelineItemVideoView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onLinkLongClick: (String) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -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.crypto.identity.anIdentityChangeState
|
||||
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.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
@@ -1159,6 +1160,7 @@ class MessagesPresenterTest {
|
||||
reactionSummaryPresenter = { aReactionSummaryState() },
|
||||
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||
linkPresenter = { aLinkState() },
|
||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||
roomCallStatePresenter = { aStandByCallState() },
|
||||
syncService = FakeSyncService(),
|
||||
|
||||
@@ -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", 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")))
|
||||
}
|
||||
|
||||
@@ -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", 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("verified identity has changed", substring = true).assertDoesNotExist()
|
||||
rule.onNodeWithText("identity was reset", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
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.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
@@ -1536,6 +1538,7 @@ class MessageComposerPresenterTest {
|
||||
navigator: MessagesNavigator = FakeMessagesNavigator(),
|
||||
pickerProvider: PickerProvider = this.pickerProvider,
|
||||
featureFlagService: FeatureFlagService = this.featureFlagService,
|
||||
locationService: LocationService = FakeLocationService(true),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||
@@ -1558,6 +1561,7 @@ class MessageComposerPresenterTest {
|
||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
analyticsService = analyticsService,
|
||||
locationService = locationService,
|
||||
messageComposerContext = DefaultMessageComposerContext(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
|
||||
|
||||
@@ -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.model.TimelineItemAction
|
||||
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.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
@@ -315,6 +316,7 @@ class PinnedMessagesListPresenterTest {
|
||||
timelineProtectionPresenter = { aTimelineProtectionState() },
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
actionListPresenter = { anActionListState() },
|
||||
linkPresenter = { aLinkState() },
|
||||
analyticsService = analyticsService,
|
||||
appCoroutineScope = this,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
@@ -99,8 +100,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkLongClick: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setSafeContent {
|
||||
PinnedMessagesListView(
|
||||
|
||||
@@ -34,6 +34,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import org.junit.Rule
|
||||
@@ -175,7 +176,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
|
||||
state: TimelineState,
|
||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.toLogLevelItem
|
||||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
@@ -112,8 +111,7 @@ class DeveloperSettingsPresenter @Inject constructor(
|
||||
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
|
||||
)
|
||||
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() && it != ElementCallConfig.DEFAULT_BASE_URL }
|
||||
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
|
||||
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
|
||||
}
|
||||
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
|
||||
@@ -133,7 +131,6 @@ class DeveloperSettingsPresenter @Inject constructor(
|
||||
rageshakeState = rageshakeState,
|
||||
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
|
||||
baseUrl = customElementCallBaseUrl,
|
||||
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
|
||||
validator = ::customElementCallUrlValidator,
|
||||
),
|
||||
hideImagesAndVideos = hideImagesAndVideos,
|
||||
|
||||
@@ -27,6 +27,5 @@ data class DeveloperSettingsState(
|
||||
|
||||
data class CustomElementCallBaseUrlState(
|
||||
val baseUrl: String?,
|
||||
val defaultUrl: String,
|
||||
val validator: (String?) -> Boolean,
|
||||
)
|
||||
|
||||
@@ -47,10 +47,8 @@ fun aDeveloperSettingsState(
|
||||
|
||||
fun aCustomElementCallBaseUrlState(
|
||||
baseUrl: String? = null,
|
||||
defaultUrl: String = "https://call.element.io",
|
||||
validator: (String?) -> Boolean = { true },
|
||||
) = CustomElementCallBaseUrlState(
|
||||
baseUrl = baseUrl,
|
||||
defaultUrl = defaultUrl,
|
||||
validator = validator,
|
||||
)
|
||||
|
||||
@@ -136,22 +136,20 @@ private fun ElementCallCategory(
|
||||
) {
|
||||
PreferenceCategory(title = "Element Call", showTopDivider = true) {
|
||||
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)
|
||||
} else {
|
||||
callUrlState.baseUrl
|
||||
}
|
||||
PreferenceTextField(
|
||||
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
|
||||
value = callUrlState.baseUrl ?: callUrlState.defaultUrl,
|
||||
value = callUrlState.baseUrl,
|
||||
placeholder = "https://.../room",
|
||||
supportingText = supportingText,
|
||||
validation = callUrlState.validator,
|
||||
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),
|
||||
onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) }
|
||||
)
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -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_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 d’inclure le protocol (http/https) et l’adresse 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_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_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>
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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_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>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
package io.element.android.features.preferences.impl.developer
|
||||
|
||||
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.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
|
||||
@@ -130,7 +129,6 @@ class DeveloperSettingsPresenterTest {
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
package io.element.android.features.preferences.impl.developer
|
||||
|
||||
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.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.preferences.impl.R
|
||||
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)
|
||||
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
|
||||
textInputNode.performTextInput("https://call.element.dev")
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io"))
|
||||
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
|
||||
@@ -469,14 +469,14 @@ private fun RoomBadge.toMatrixBadgeData(): MatrixBadgeAtom.MatrixBadgeData {
|
||||
MatrixBadgeAtom.MatrixBadgeData(
|
||||
text = stringResource(R.string.screen_room_details_badge_not_encrypted),
|
||||
icon = CompoundIcons.LockOff(),
|
||||
type = MatrixBadgeAtom.Type.Neutral,
|
||||
type = MatrixBadgeAtom.Type.Info,
|
||||
)
|
||||
}
|
||||
RoomBadge.PUBLIC -> {
|
||||
MatrixBadgeAtom.MatrixBadgeData(
|
||||
text = stringResource(R.string.screen_room_details_badge_public),
|
||||
icon = CompoundIcons.Public(),
|
||||
type = MatrixBadgeAtom.Type.Neutral,
|
||||
type = MatrixBadgeAtom.Type.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="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>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<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_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_generating_key">"Sortzen…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Gorde berreskuratze-gakoa"</string>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
[versions]
|
||||
# Project
|
||||
android_gradle_plugin = "8.8.1"
|
||||
android_gradle_plugin = "8.9.1"
|
||||
kotlin = "2.1.10"
|
||||
kotlinpoet = "2.1.0"
|
||||
ksp = "2.1.10-1.0.31"
|
||||
@@ -46,11 +46,11 @@ coil = "3.1.0"
|
||||
showkase = "1.0.3"
|
||||
appyx = "1.6.0"
|
||||
sqldelight = "2.0.2"
|
||||
wysiwyg = "2.38.2"
|
||||
wysiwyg = "2.38.3"
|
||||
telephoto = "0.15.1"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "2.13.0"
|
||||
dependencyAnalysis = "2.13.1"
|
||||
|
||||
# DI
|
||||
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" }
|
||||
gms_google_services = "com.google.gms:google-services:4.4.2"
|
||||
# https://firebase.google.com/docs/android/setup#available-libraries
|
||||
google_firebase_bom = "com.google.firebase:firebase-bom:33.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" }
|
||||
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" }
|
||||
@@ -174,7 +174,7 @@ jsoup = "org.jsoup:jsoup:1.19.1"
|
||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.20"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.24"
|
||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
@@ -188,7 +188,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
||||
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
||||
maplibre = "org.maplibre.gl:android-sdk:11.8.3"
|
||||
maplibre = "org.maplibre.gl:android-sdk:11.8.4"
|
||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
|
||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
||||
opusencoder = "io.element.android:opusencoder:1.1.0"
|
||||
@@ -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_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
|
||||
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" }
|
||||
@@ -242,6 +245,6 @@ paparazzi = "app.cash.paparazzi:1.3.5"
|
||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
|
||||
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
|
||||
sonarqube = "org.sonarqube:6.0.1.5171"
|
||||
licensee = "app.cash.licensee:1.12.0"
|
||||
sonarqube = "org.sonarqube:6.1.0.5360"
|
||||
licensee = "app.cash.licensee:1.13.0"
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
@@ -89,3 +89,12 @@ fun String.withoutAccents(): String {
|
||||
return Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.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 }
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
package io.element.android.libraries.core.extensions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BasicExtensionsTest {
|
||||
@@ -43,4 +45,32 @@ class BasicExtensionsTest {
|
||||
val output = input.ellipsize(5)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.badgeInfoBackgroundColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeInfoContentColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
|
||||
@@ -31,7 +33,8 @@ object MatrixBadgeAtom {
|
||||
enum class Type {
|
||||
Positive,
|
||||
Neutral,
|
||||
Negative
|
||||
Negative,
|
||||
Info,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -42,16 +45,19 @@ object MatrixBadgeAtom {
|
||||
Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor
|
||||
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
|
||||
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
|
||||
Type.Info -> ElementTheme.colors.badgeInfoBackgroundColor
|
||||
}
|
||||
val textColor = when (data.type) {
|
||||
Type.Positive -> ElementTheme.colors.badgePositiveContentColor
|
||||
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
|
||||
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
|
||||
Type.Info -> ElementTheme.colors.badgeInfoContentColor
|
||||
}
|
||||
val iconColor = when (data.type) {
|
||||
Type.Positive -> ElementTheme.colors.iconSuccessPrimary
|
||||
Type.Neutral -> ElementTheme.colors.iconSecondary
|
||||
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
|
||||
Type.Info -> ElementTheme.colors.iconInfoPrimary
|
||||
}
|
||||
Badge(
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,14 @@ val SemanticColors.badgeNegativeBackgroundColor
|
||||
val SemanticColors.badgeNegativeContentColor
|
||||
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeInfoBackgroundColor
|
||||
get() = if (isLight) LightColorTokens.colorAlphaBlue300 else DarkColorTokens.colorAlphaBlue300
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeInfoContentColor
|
||||
get() = if (isLight) LightColorTokens.colorBlue1100 else DarkColorTokens.colorBlue1100
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.pinnedMessageBannerIndicator
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.api.widget
|
||||
import java.util.UUID
|
||||
|
||||
interface CallWidgetSettingsProvider {
|
||||
fun provide(
|
||||
suspend fun provide(
|
||||
baseUrl: String,
|
||||
widgetId: String = UUID.randomUUID().toString(),
|
||||
encrypted: Boolean,
|
||||
|
||||
@@ -477,11 +477,16 @@ class RustMatrixClient(
|
||||
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
|
||||
|
||||
override fun close() {
|
||||
innerNotificationClient.close()
|
||||
|
||||
appCoroutineScope.launch {
|
||||
roomFactory.destroy()
|
||||
rustSyncService.destroy()
|
||||
notificationSettingsService.destroy()
|
||||
// This is sync, but it can destroy the `Client` instance and block stopping the sync service
|
||||
notificationProcessSetup.destroy()
|
||||
}
|
||||
|
||||
sessionCoroutineScope.cancel()
|
||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||
verificationService.destroy()
|
||||
@@ -489,7 +494,6 @@ class RustMatrixClient(
|
||||
sessionDelegate.clearCurrentClient()
|
||||
innerRoomListService.close()
|
||||
notificationService.close()
|
||||
notificationProcessSetup.destroy()
|
||||
encryptionService.close()
|
||||
innerClient.close()
|
||||
}
|
||||
|
||||
@@ -189,12 +189,12 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||
val oAuthAuthenticationData = client.urlForOidc(
|
||||
val oAuthAuthorizationData = client.urlForOidc(
|
||||
oidcConfiguration = oidcConfigurationProvider.get(),
|
||||
prompt = prompt.toRustPrompt(),
|
||||
)
|
||||
val url = oAuthAuthenticationData.loginUrl()
|
||||
pendingOAuthAuthorizationData = oAuthAuthenticationData
|
||||
val url = oAuthAuthorizationData.loginUrl()
|
||||
pendingOAuthAuthorizationData = oAuthAuthorizationData
|
||||
OidcDetails(url)
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
@@ -205,7 +205,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
pendingOAuthAuthorizationData?.close()
|
||||
pendingOAuthAuthorizationData?.use {
|
||||
currentClient?.abortOidcAuth(it)
|
||||
}
|
||||
pendingOAuthAuthorizationData = null
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
@@ -221,16 +223,18 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
runCatching {
|
||||
val client = currentClient ?: 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(urlForOidcLogin, callbackUrl)
|
||||
client.loginWithOidcCallback(callbackUrl)
|
||||
val sessionData = client.session().toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.OIDC,
|
||||
passphrase = pendingPassphrase,
|
||||
sessionPaths = currentSessionPaths,
|
||||
)
|
||||
|
||||
// Free the pending data since we won't use it to abort the flow anymore
|
||||
pendingOAuthAuthorizationData?.close()
|
||||
pendingOAuthAuthorizationData = null
|
||||
|
||||
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
|
||||
sessionStore.storeData(sessionData)
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user