Merge branch 'release/26.05.0'

This commit is contained in:
ganfra 2026-05-07 16:03:03 +02:00
commit cc65a0f114
1432 changed files with 9674 additions and 8136 deletions

View file

@ -32,7 +32,7 @@ jobs:
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
uses: tspascoal/get-user-teams-membership@b1480b119326dde04ceffbeccd98e41892539c74 # v4.0.0
id: teams
with:
username: ${{ github.event.pull_request.user.login }}

View file

@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
- uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
upload_reports:
name: Project Check Suite

View file

@ -17,6 +17,7 @@ jobs:
permissions:
# Need write permissions on PRs to remove the label "Record-Screenshots"
pull-requests: write
contents: write
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'

3
.idea/kotlinc.xml generated
View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.20" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.21" />
</component>
</project>

View file

@ -1,3 +1,45 @@
Changes in Element X v26.04.4
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.04.4 -->
## What's Changed
### 🙌 Improvements
* Natural media viewer swiping order by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6431
* Replace `rustls-platform-verifier-android.aar` with single class by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6610
* Cleanup FetchPushForegroundService by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6577
* cleaning: Remove join button from call notify timelineItemView by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6603
### 🐛 Bugfixes
* Fix crash when going back to threads list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6620
* audio: Let EC decide alone what communication device to use by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6609
* Fix media viewer bottom sheets not being scrollable by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6631
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6626
### 📄 Documentation
* Updates to new features and some refactoring. by @mxandreas in https://github.com/element-hq/element-x-android/pull/6591
### 🚧 In development 🚧
* WIP : live location rendering by @ganfra in https://github.com/element-hq/element-x-android/pull/6611
### Dependency upgrades
* Update dependency io.element.android:element-call-embedded to v0.19.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6593
* Update dependency androidx.annotation:annotation-jvm to v1.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6596
* Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6605
* Update dependency com.google.firebase:firebase-bom to v34.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6604
* Update actions/upload-artifact action to v7.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6614
* Update plugin dependencycheck to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6621
* Update actions/github-script action to v9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6606
* Update peter-evans/create-pull-request action to v8.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6615
* Update dependencyAnalysis to v3.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6616
* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.21 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6635
### Others
* Settings UI update. by @bmarty in https://github.com/element-hq/element-x-android/pull/6602
* Support replying to messages with voice recordings by @kalix127 in https://github.com/element-hq/element-x-android/pull/6464
* Add Black theme option for battery saving on OLED displays by @timurgilfanov in https://github.com/element-hq/element-x-android/pull/6441
* Fix | When selecting earpiece twice in a row the proximity sensor get wrongly disabled by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6627
* Update wording of deactivate account screen by @bmarty in https://github.com/element-hq/element-x-android/pull/6633
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.04.3...v26.04.4
Changes in Element X v26.04.3
=============================

View file

@ -103,13 +103,13 @@ android {
logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName) [$buildType]")
buildTypes {
val oidcRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
val oAuthRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
getByName("debug") {
resValue("string", "app_name", "$baseAppName dbg")
resValue(
"string",
"login_redirect_scheme",
"$oidcRedirectSchemeBase.debug",
"$oAuthRedirectSchemeBase.debug",
)
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug")
@ -120,7 +120,7 @@ android {
resValue(
"string",
"login_redirect_scheme",
oidcRedirectSchemeBase,
oAuthRedirectSchemeBase,
)
signingConfig = signingConfigs.getByName("debug")
@ -157,7 +157,7 @@ android {
resValue(
"string",
"login_redirect_scheme",
"$oidcRedirectSchemeBase.nightly",
"$oAuthRedirectSchemeBase.nightly",
)
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")

View file

@ -75,7 +75,7 @@
android:scheme="elementx" />
</intent-filter>
<!--
Oidc redirection
OAuth redirection
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -10,14 +10,14 @@ package io.element.android.x.oidc
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import io.element.android.libraries.matrix.api.auth.OAuthRedirectUrlProvider
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.x.R
@ContributesBinding(AppScope::class)
class DefaultOidcRedirectUrlProvider(
class DefaultOAuthRedirectUrlProvider(
private val stringProvider: StringProvider,
) : OidcRedirectUrlProvider {
) : OAuthRedirectUrlProvider {
override fun provide() = buildString {
append(stringProvider.getString(R.string.login_redirect_scheme))
append(":/")

View file

@ -13,13 +13,13 @@ import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.x.R
import org.junit.Test
class DefaultOidcRedirectUrlProviderTest {
class DefaultOAuthRedirectUrlProviderTest {
@Test
fun `test provide`() {
val stringProvider = FakeStringProvider(
defaultResult = "str"
)
val sut = DefaultOidcRedirectUrlProvider(
val sut = DefaultOAuthRedirectUrlProvider(
stringProvider = stringProvider,
)
val result = sut.provide()

View file

@ -48,6 +48,8 @@ android {
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appconfig
object ProtectionConfig {
/**
* The maximum length of a room name, to limit attack vectors in room invite.
*/
const val MAX_ROOM_NAME_LENGTH = 128
}

View file

@ -33,13 +33,14 @@ dependencies {
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oidc.api)
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api)
@ -59,7 +60,7 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)

View file

@ -63,8 +63,8 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
@ -95,7 +95,7 @@ class RootFlowNode(
private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val oAuthActionFlow: OAuthActionFlow,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
@ -392,7 +392,7 @@ class RootFlowNode(
navigateTo(resolvedIntent.deeplinkData)
}
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.OAuth -> onOAuthAction(resolvedIntent.oAuthAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.shareIntentData)
}
@ -529,8 +529,8 @@ class RootFlowNode(
}
}
private fun onOidcAction(oidcAction: OidcAction) {
oidcActionFlow.post(oidcAction)
private fun onOAuthAction(oAuthAction: OAuthAction) {
oAuthActionFlow.post(oAuthAction)
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {

View file

@ -18,13 +18,13 @@ import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcIntentResolver
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthIntentResolver
import timber.log.Timber
sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class OAuth(val oAuthAction: OAuthAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent
@ -34,7 +34,7 @@ sealed interface ResolvedIntent {
class IntentResolver(
private val deeplinkParser: DeeplinkParser,
private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver,
private val oAuthIntentResolver: OAuthIntentResolver,
private val permalinkParser: PermalinkParser,
private val shareIntentHandler: ShareIntentHandler,
) {
@ -45,9 +45,9 @@ class IntentResolver(
val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
// Coming during login using Oidc?
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
// Coming during login using OAuth?
val oAuthAction = oAuthIntentResolver.resolve(intent)
if (oAuthAction != null) return ResolvedIntent.OAuth(oAuthAction)
val actionViewData = intent
.takeIf { it.action == Intent.ACTION_VIEW }

View file

@ -1,6 +1,6 @@
<?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">"登出并升级"</string>
<string name="banner_migrate_to_native_sliding_sync_action">"注销并升级"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 不再支持旧协议。请注销并重新登录以继续使用该应用程序。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"你的主服务器不再支持旧协议。请注销并重新登录以继续使用此 app。"</string>
</resources>

View file

@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.test.FakeOidcIntentResolver
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Test
import org.junit.runner.RunWith
@ -170,9 +170,9 @@ class IntentResolverTest {
}
@Test
fun `test resolve oidc`() {
fun `test resolve OAuth`() {
val sut = createIntentResolver(
oidcIntentResolverResult = { OidcAction.GoBack() },
oAuthIntentResolverResult = { OAuthAction.GoBack() },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -180,8 +180,8 @@ class IntentResolverTest {
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
oidcAction = OidcAction.GoBack()
ResolvedIntent.OAuth(
oAuthAction = OAuthAction.GoBack()
)
)
}
@ -194,7 +194,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
loginIntentResolverResult = { null },
permalinkParserResult = { permalinkData },
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -213,7 +213,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -230,7 +230,7 @@ class IntentResolverTest {
)
val sut = createIntentResolver(
permalinkParserResult = { permalinkData },
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_BATTERY_LOW
@ -244,7 +244,7 @@ class IntentResolverTest {
fun `test incoming share simple`() {
val shareIntentData = ShareIntentData.PlainText("Hello")
val sut = createIntentResolver(
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -260,7 +260,7 @@ class IntentResolverTest {
val fileUri = "content://com.example.app/file1.jpg".toUri()
val shareIntentData = ShareIntentData.Uris(text = "Hello", uris = listOf(UriToShare(fileUri, "image/jpg")))
val sut = createIntentResolver(
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -277,7 +277,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -292,7 +292,7 @@ class IntentResolverTest {
val aLoginParams = LoginParams("accountProvider", null)
val sut = createIntentResolver(
loginIntentResolverResult = { aLoginParams },
oidcIntentResolverResult = { null },
oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -306,7 +306,7 @@ class IntentResolverTest {
deeplinkParserResult: DeeplinkData? = null,
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
oAuthIntentResolverResult: (Intent) -> OAuthAction? = { lambdaError() },
onIncomingShareIntent: (Intent) -> ShareIntentData? = { null },
): IntentResolver {
return IntentResolver(
@ -314,8 +314,8 @@ class IntentResolverTest {
loginIntentResolver = FakeLoginIntentResolver(
parseResult = loginIntentResolverResult,
),
oidcIntentResolver = FakeOidcIntentResolver(
resolveResult = oidcIntentResolverResult,
oAuthIntentResolver = FakeOAuthIntentResolver(
resolveResult = oAuthIntentResolverResult,
),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.5.6")
detektPlugins("io.nlopez.compose.rules:detekt:0.5.8")
detektPlugins(project(":tests:detekt-rules"))
}

View file

@ -144,6 +144,11 @@ Prerequisites:
export ANDROID_HOME=$HOME/android/sdk
```
* On macos ensure gnu-getopt is installed
```
brew install gnu-getopt
```
You can then build the Rust SDK by running the script
[`tools/sdk/build-rust-sdk`](../tools/sdk/build-rust-sdk). Type
`./tools/sdk/build-rust-sdk --help` for help.

View file

@ -1,4 +1,4 @@
This file contains some rough notes about Oidc implementation, with some examples of actual data.
This file contains some rough notes about OAuth implementation, with some examples of actual data.
[ios implementation](https://github.com/element-hq/element-x-ios/compare/develop...doug/oidc-temp)
@ -25,7 +25,7 @@ tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
Example of OidcData (from presentUrl callback):
Example of OAuthData (from presentUrl callback):
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
Formatted url:
@ -43,8 +43,8 @@ https://auth-oidc.lab.element.dev/authorize?
state: ex6mNJVFZ5jn9wL8
Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
OAuth client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
OAuth sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
Test server:

@ -1 +1 @@
Subproject commit cdde60c158ecd0987a3ba6fd79a4617551aff463
Subproject commit fb7e9287d9d446012925139842d9aaa8e99a74dc

View file

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

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"問題発見のため、匿名の使用データの共有にご協力ください。"</string>
<string name="screen_analytics_settings_read_terms">"利用規約の全文を%1$sから確認することができます。"</string>
<string name="screen_analytics_settings_help_us_improve">"改善のため、匿名の使用データの共有にご協力ください。"</string>
<string name="screen_analytics_settings_read_terms">"規約の全文は%1$sから確認することができます。"</string>
<string name="screen_analytics_settings_read_terms_content_link">"こちら"</string>
<string name="screen_analytics_settings_share_data">"使用データを共有"</string>
</resources>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"共享匿名使用数据以帮助我们排查问题。"</string>
<string name="screen_analytics_settings_read_terms">"您可以阅读我们的所有条款 %1$s。"</string>
<string name="screen_analytics_settings_read_terms">"你可以点击 %1$s 阅读我们的所有条款。"</string>
<string name="screen_analytics_settings_read_terms_content_link">"此处"</string>
<string name="screen_analytics_settings_share_data">"共享分析数据"</string>
</resources>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"いかなる個人情報も記録, 分析されることはありません"</string>
<string name="screen_analytics_prompt_help_us_improve">"問題発見のため、匿名の使用データの共有にご協力ください。"</string>
<string name="screen_analytics_prompt_read_terms">"利用規約の全文を%1$sから確認することができます。"</string>
<string name="screen_analytics_prompt_help_us_improve">"改善のため、匿名の使用データの共有にご協力ください。"</string>
<string name="screen_analytics_prompt_read_terms">"規約の全文は%1$sから確認することができます。"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"こちら"</string>
<string name="screen_analytics_prompt_settings">"いつでも設定は変更できます"</string>
<string name="screen_analytics_prompt_third_party_sharing">"情報が第三者に共有されることはありません"</string>

View file

@ -2,9 +2,9 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"我们不会记录或分析任何个人数据"</string>
<string name="screen_analytics_prompt_help_us_improve">"共享匿名使用数据以帮助我们排查问题。"</string>
<string name="screen_analytics_prompt_read_terms">"您可以阅读我们的所有条款 %1$s。"</string>
<string name="screen_analytics_prompt_read_terms">"你可以点击 %1$s 阅读我们的所有条款。"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"此处"</string>
<string name="screen_analytics_prompt_settings">"可以随时关闭此功能"</string>
<string name="screen_analytics_prompt_third_party_sharing">"我们不会与第三方共享的数据"</string>
<string name="screen_analytics_prompt_third_party_sharing">"我们不会与第三方共享的数据"</string>
<string name="screen_analytics_prompt_title">"帮助改进 %1$s"</string>
</resources>

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.announcement.impl.fullscreen
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent
@ -20,43 +23,39 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class FullscreenAnnouncementViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back sends a AnnouncementEvent`() {
fun `clicking on back sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
rule.setFullscreenAnnouncementView(
setFullscreenAnnouncementView(
anAnnouncementState(
announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder,
),
)
rule.pressBackKey()
pressBackKey()
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
}
@Test
fun `clicking on Continue sends a AnnouncementEvent`() {
fun `clicking on Continue sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AnnouncementEvent>()
rule.setFullscreenAnnouncementView(
setFullscreenAnnouncementView(
anAnnouncementState(
announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setFullscreenAnnouncementView(
private fun AndroidComposeUiTest<ComponentActivity>.setFullscreenAnnouncementView(
state: AnnouncementState,
) {
setContent {

View file

@ -14,22 +14,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable {
@Parcelize
data class ExternalUrl(val url: String) : CallType {
override fun toString(): String {
return "ExternalUrl"
}
}
@Parcelize
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
val isAudioCall: Boolean
) : CallType {
override fun toString(): String {
return "RoomCall(sessionId=$sessionId, roomId=$roomId, isAudioCall=$isAudioCall)"
}
}
}
@Parcelize
data class CallData(
val sessionId: SessionId,
val roomId: RoomId,
val isAudioCall: Boolean
) : NodeInputs, Parcelable

View file

@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.UserId
interface ElementCallEntryPoint {
/**
* Start a call of the given type.
* @param callType The type of call to start.
* @param callData The data of call to start.
*/
fun startCall(callType: CallType)
fun startCall(callData: CallData)
/**
* Handle an incoming call.
* @param callType The type of call.
* @param callData The data of call.
* @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in.
@ -35,7 +35,7 @@ interface ElementCallEntryPoint {
* @param textContent The text content of the notification. If null the default content from the system will be used.
*/
suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,

View file

@ -30,44 +30,10 @@
<activity
android:name=".ui.ElementCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="true"
android:label="@string/element_call"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:taskAffinity="io.element.android.features.call">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://call.element.io/.well-known/assetlinks.json -->
<data android:host="call.element.io" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element.call" />
</intent-filter>
</activity>
android:taskAffinity="io.element.android.features.call" />
<activity
android:name=".ui.IncomingCallActivity"

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
@ -30,12 +30,12 @@ class DefaultElementCallEntryPoint(
const val REQUEST_CODE = 2255
}
override fun startCall(callType: CallType) {
context.startActivity(IntentProvider.createIntent(context, callType))
override fun startCall(callData: CallData) {
context.startActivity(IntentProvider.createIntent(context, callData))
}
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
@ -47,8 +47,8 @@ class DefaultElementCallEntryPoint(
textContent: String?,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
roomId = callType.roomId,
sessionId = callData.sessionId,
roomId = callData.roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
@ -58,7 +58,7 @@ class DefaultElementCallEntryPoint(
expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId,
textContent = textContent,
audioOnly = callType.isAudioCall
audioOnly = callData.isAudioCall,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}

View file

@ -18,7 +18,7 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
@ -89,7 +89,14 @@ class RingingCallNotificationCreator(
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId, isAudioCall = audioOnly))
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvents {
data class SetPipController(val pipController: PipController) : PictureInPictureEvents
data object EnterPictureInPicture : PictureInPictureEvents
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
sealed interface PictureInPictureEvent {
data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
}

View file

@ -36,17 +36,17 @@ class PictureInPicturePresenter(
var isInPictureInPicture by remember { mutableStateOf(false) }
var pipController by remember { mutableStateOf<PipController?>(null) }
fun handleEvent(event: PictureInPictureEvents) {
fun handleEvent(event: PictureInPictureEvent) {
when (event) {
is PictureInPictureEvents.SetPipController -> {
is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController
}
PictureInPictureEvents.EnterPictureInPicture -> {
PictureInPictureEvent.EnterPictureInPicture -> {
coroutineScope.launch {
switchToPip(pipController)
}
}
is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
is PictureInPictureEvent.OnPictureInPictureModeChanged -> {
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
isInPictureInPicture = event.isInPip
if (event.isInPip) {

View file

@ -11,5 +11,5 @@ package io.element.android.features.call.impl.pip
data class PictureInPictureState(
val supportPip: Boolean,
val isInPictureInPicture: Boolean,
val eventSink: (PictureInPictureEvents) -> Unit,
val eventSink: (PictureInPictureEvent) -> Unit,
)

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl.pip
fun aPictureInPictureState(
supportPip: Boolean = false,
isInPictureInPicture: Boolean = false,
eventSink: (PictureInPictureEvents) -> Unit = {},
eventSink: (PictureInPictureEvent) -> Unit = {},
): PictureInPictureState {
return PictureInPictureState(
supportPip = supportPip,

View file

@ -13,7 +13,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.IntentCompat
import dev.zacsweers.metro.Inject
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
@ -42,7 +42,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
context.bindings<CallBindings>().inject(this)
appCoroutineScope.launch {
activeCallManager.hangUpCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly

View file

@ -9,6 +9,8 @@ package io.element.android.features.call.impl.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@ -34,8 +36,8 @@ internal fun aCallNotificationData(
roomId = RoomId("!1234:matrix.org"),
eventId = EventId("\$asdadadsad:matrix.org"),
senderId = UserId("@bob:matrix.org"),
roomName = "A room",
senderName = "Bob",
roomName = ROOM_NAME,
senderName = USER_NAME_BOB,
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
data class OnWebViewError(val description: String?) : CallScreenEvents
sealed interface CallScreenEvent {
data object Hangup : CallScreenEvent
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvent
data class OnWebViewError(val description: String?) : CallScreenEvent
}

View file

@ -23,7 +23,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
@ -52,7 +52,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CallScreenPresenter(
@Assisted private val callType: CallType,
@Assisted private val callData: CallData,
@Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider,
userAgentProvider: UserAgentProvider,
@ -69,10 +69,9 @@ class CallScreenPresenter(
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
fun create(callData: CallData, navigator: CallScreenNavigator): CallScreenPresenter
}
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
@Composable
@ -90,9 +89,9 @@ class CallScreenPresenter(
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
activeCallManager.joinedCall(callData)
fetchRoomCallUrl(
inputs = callType,
callData = callData,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
languageTag = languageTag,
@ -100,19 +99,10 @@ class CallScreenPresenter(
)
}
onDispose {
appCoroutineScope.launch { activeCallManager.hangUpCall(callType) }
appCoroutineScope.launch { activeCallManager.hangUpCall(callData) }
}
}
when (callType) {
is CallType.ExternalUrl -> {
// No analytics yet for external calls
}
is CallType.RoomCall -> {
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
}
}
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver ->
@ -149,25 +139,22 @@ class CallScreenPresenter(
.launchIn(this)
}
if (callType is CallType.RoomCall) {
// Note: For external calls isWidgetLoaded will always be false
LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
}
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
}
}
}
fun handleEvent(event: CallScreenEvents) {
fun handleEvent(event: CallScreenEvent) {
when (event) {
is CallScreenEvents.Hangup -> {
is CallScreenEvent.Hangup -> {
val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isWidgetLoaded) {
@ -187,10 +174,10 @@ class CallScreenPresenter(
}
}
}
is CallScreenEvents.SetupMessageChannels -> {
is CallScreenEvent.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor
}
is CallScreenEvents.OnWebViewError -> {
is CallScreenEvent.OnWebViewError -> {
if (!ignoreWebViewError) {
webViewError = event.description.orEmpty()
}
@ -204,37 +191,29 @@ class CallScreenPresenter(
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvent,
)
}
private suspend fun fetchRoomCallUrl(
inputs: CallType,
callData: CallData,
urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
languageTag: String?,
theme: String?,
) {
urlState.runCatchingUpdatingState {
when (inputs) {
is CallType.ExternalUrl -> {
inputs.url
}
is CallType.RoomCall -> {
val result = callWidgetProvider.getWidget(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
isAudioCall = inputs.isAudioCall,
languageTag = languageTag,
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
val result = callWidgetProvider.getWidget(
sessionId = callData.sessionId,
roomId = callData.roomId,
clientId = UUID.randomUUID().toString(),
isAudioCall = callData.isAudioCall,
languageTag = languageTag,
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${callData.sessionId}, roomId: ${callData.roomId}")
result.url
}
}
@ -242,12 +221,11 @@ class CallScreenPresenter(
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
val client = matrixClientsProvider.getOrNull(callData.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${callData.sessionId}")
}
coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
Timber.d("Observing sync state in-call for sessionId: ${callData.sessionId}")
client.syncService.syncState
.collect { state ->
if (state != SyncState.Running) {
@ -256,7 +234,7 @@ class CallScreenPresenter(
}
}
onDispose {
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
Timber.d("Stopped observing sync state in-call for sessionId: ${callData.sessionId}")
// Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false)
}

View file

@ -15,6 +15,5 @@ data class CallScreenState(
val webViewError: String?,
val userAgent: String,
val isCallActive: Boolean,
val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit,
val eventSink: (CallScreenEvent) -> Unit,
)

View file

@ -26,15 +26,13 @@ internal fun aCallScreenState(
webViewError: String? = null,
userAgent: String = "",
isCallActive: Boolean = true,
isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {},
eventSink: (CallScreenEvent) -> Unit = {},
): CallScreenState {
return CallScreenState(
urlState = urlState,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink,
)
}

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
@ -66,9 +66,9 @@ internal fun CallScreenView(
) {
fun handleBack() {
if (pipState.supportPip) {
pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture)
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
} else {
state.eventSink(CallScreenEvents.Hangup)
state.eventSink(CallScreenEvent.Hangup)
}
}
@ -84,7 +84,7 @@ internal fun CallScreenView(
append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
},
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
@ -123,16 +123,16 @@ internal fun CallScreenView(
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
@ -147,7 +147,7 @@ internal fun CallScreenView(
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
)
}
is AsyncData.Success -> Unit

View file

@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import io.element.android.features.call.api.CallType
import io.element.android.libraries.matrix.api.core.SessionId
fun CallType.getSessionId(): SessionId? {
return when (this) {
is CallType.ExternalUrl -> null
is CallType.RoomCall -> sessionId
}
}

View file

@ -35,16 +35,14 @@ import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.architecture.Presenter
@ -64,7 +62,6 @@ class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
PipView {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService
@ -80,9 +77,9 @@ class ElementCallActivity :
private val requestPermissionsLauncher = registerPermissionResultLauncher()
private val webViewTarget = mutableStateOf<CallType?>(null)
private val webViewTarget = mutableStateOf<CallData?>(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null
private var eventSink: ((CallScreenEvent) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -98,7 +95,7 @@ class ElementCallActivity :
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
setCallType(intent)
setCallData(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
if (!::presenter.isInitialized) {
return
@ -111,8 +108,8 @@ class ElementCallActivity :
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
val colors by remember(webViewTarget.value?.getSessionId()) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId())
val colors by remember(webViewTarget.value?.sessionId) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
@ -123,9 +120,8 @@ class ElementCallActivity :
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
if (state.isCallActive || !state.isInWidgetMode) {
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
}
}
@ -163,7 +159,7 @@ class ElementCallActivity :
if (requestPermissionCallback != null) {
Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
pipEventSink(PictureInPictureEvent.EnterPictureInPicture)
}
}
addOnUserLeaveHintListener(listener)
@ -173,10 +169,10 @@ class ElementCallActivity :
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup)
eventSink?.invoke(CallScreenEvent.Hangup)
}
}
addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
@ -188,7 +184,7 @@ class ElementCallActivity :
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
setCallData(intent)
}
override fun onDestroy() {
@ -207,25 +203,24 @@ class ElementCallActivity :
finish()
}
private fun setCallType(intent: Intent?) {
val callType = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
private fun setCallData(intent: Intent?) {
val callData = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallData::class.java)
}
val currentCallType = webViewTarget.value
if (currentCallType == null) {
if (callType == null) {
val currentCallData = webViewTarget.value
if (currentCallData == null) {
if (callData == null) {
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
finish()
} else {
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
webViewTarget.value = callData
presenter = presenterFactory.create(callData, this)
}
} else {
if (callType == null) {
if (callData == null) {
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
} else if (callType != currentCallType) {
} else if (callData != currentCallData) {
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
setIntent(intent)
recreate()
@ -236,8 +231,6 @@ class ElementCallActivity :
}
}
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@ -287,7 +280,7 @@ class ElementCallActivity :
}
override fun hangUp() {
eventSink?.invoke(CallScreenEvents.Hangup)
eventSink?.invoke(CallScreenEvent.Hangup)
}
}

View file

@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
@ -118,10 +118,10 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(
CallType.RoomCall(
notificationData.sessionId,
notificationData.roomId,
isAudioCall = notificationData.audioOnly
CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly,
)
)
}
@ -129,7 +129,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch {
activeCallManager.hangUpCall(callType = activeCall.callType)
activeCallManager.hangUpCall(callData = activeCall.callData)
}
}
}

View file

@ -20,7 +20,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
@ -73,20 +73,20 @@ interface ActiveCallManager {
/**
* Called to hang up the active call. It will hang up the call and remove any existing UI and the active call.
* @param callType The type of call that the user hangs up, either an external url one or a room one.
* @param callData The data about the call.
* @param notificationData The data for the incoming call notification.
*/
suspend fun hangUpCall(
callType: CallType,
callData: CallData,
notificationData: CallNotificationData? = null,
)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param callType The type of call that the user joined, either an external url one or a room one.
* @param callData The data about the call.
*/
suspend fun joinedCall(callType: CallType)
suspend fun joinedCall(callData: CallData)
}
@SingleIn(AppScope::class)
@ -143,7 +143,7 @@ class DefaultActiveCallManager(
return
}
activeCall.value = ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly,
@ -198,17 +198,17 @@ class DefaultActiveCallManager(
}
override suspend fun hangUpCall(
callType: CallType,
callData: CallData,
notificationData: CallNotificationData?,
) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType")
Timber.tag(tag).d("Hang up call: $callData")
cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters.
notificationData?.let {
ActiveCall(
callType = callType,
callData = callData,
callState = CallState.Ringing(
notificationData = notificationData,
)
@ -219,8 +219,8 @@ class DefaultActiveCallManager(
return@withLock
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
if (currentActiveCall.callData != callData) {
Timber.tag(tag).w("Call type $callData does not match the active call type, ignoring")
return@withLock
}
if (currentActiveCall.callState is CallState.Ringing) {
@ -244,8 +244,8 @@ class DefaultActiveCallManager(
activeCall.value = null
}
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType")
override suspend fun joinedCall(callData: CallData) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callData")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -254,7 +254,7 @@ class DefaultActiveCallManager(
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
callType = callType,
callData = callData,
callState = CallState.InCall,
)
}
@ -307,15 +307,15 @@ class DefaultActiveCallManager(
private fun observeRingingCall() {
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val callData = activeCall.callData
val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
val client = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
val room = client.getRoom(callType.roomId) ?: run {
val room = client.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
@ -346,17 +346,17 @@ class DefaultActiveCallManager(
// has joined the call from another session.
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val callData = activeCall.callData
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
val room = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull()?.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
it.hasRoomCall to (callData.sessionId in it.activeRoomCallParticipants)
}
}
// We only want to check if the room active call status changes
@ -388,10 +388,7 @@ class DefaultActiveCallManager(
// Nothing to do
}
is CallState.InCall -> {
when (val callType = value.callType) {
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(value.callData.roomId))
}
}
}
@ -404,7 +401,7 @@ class DefaultActiveCallManager(
* Represents an active call.
*/
data class ActiveCall(
val callType: CallType,
val callData: CallData,
val callState: CallState,
)

View file

@ -1,98 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
@Inject
class CallIntentDataParser {
private val validHttpSchemes = sequenceOf("https")
private val knownHosts = sequenceOf(
"call.element.io",
)
fun parse(data: String?): String? {
val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
}
?.takeIf { it.host in knownHosts }
?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
}
}
/**
* Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
}
private const val APP_PROMPT_PARAMETER = "appPrompt"
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"

View file

@ -12,21 +12,21 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
fun createIntent(context: Context, callData: CallData): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callData)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
fun getPendingIntent(context: Context, callData: CallData): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType),
createIntent(context, callData),
PendingIntent.FLAG_CANCEL_CURRENT,
false
)!!

View file

@ -140,26 +140,33 @@ class WebViewWidgetMessageInterceptor(
}
}
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
onMessageReceived(message.data)
}
// Always register JavascriptInterface as the baseline message channel.
// This works on all WebView implementations including Huawei.
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
}
}, LISTENER_NAME)
// Use WebMessageListener if supported, otherwise use JavascriptInterface
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
// Additionally register WebMessageListener on WebViews that reliably support it.
// Huawei WebView (Chromium < 119) reports WEB_MESSAGE_LISTENER as supported
// but silently drops messages, so we only trust it on Chromium 119+.
// See: https://github.com/element-hq/element-x-android/issues/6632
val webViewVersionName = WebViewCompat.getCurrentWebViewPackage(webView.context)?.versionName.orEmpty()
Timber.d("Using WebView version: $webViewVersionName")
val webViewVersionCode = webViewVersionName.split(".").firstOrNull()?.toIntOrNull() ?: 0
if (webViewVersionCode >= 119 &&
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
LISTENER_NAME,
setOf("*"),
webMessageListener
)
} else {
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
WebViewCompat.WebMessageListener { _, message, _, _, _ ->
onMessageReceived(message.data)
}
}, LISTENER_NAME)
)
}
}

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"通话进行中"</string>
<string name="call_foreground_service_message_android">"点按即可返回通话"</string>
<string name="call_foreground_service_message_android">"点击以返回通话"</string>
<string name="call_foreground_service_title_android">"☎️ 通话中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其音频设备。"</string>
<string name="screen_incoming_call_subtitle_android">"Element 来电"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其音频设备。"</string>
<string name="screen_incoming_call_subtitle_android">"Element Call 来电"</string>
</resources>

View file

@ -11,7 +11,7 @@ package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
@ -37,7 +37,7 @@ class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@ -53,7 +53,7 @@ class DefaultElementCallEntryPointTest {
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",

View file

@ -8,11 +8,9 @@
package io.element.android.features.call.impl.pip
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -20,9 +18,7 @@ class PictureInPicturePresenterTest {
@Test
fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse()
}
@ -35,9 +31,7 @@ class PictureInPicturePresenterTest {
supportPip = true,
pipView = FakePipView(setPipParamsResult = { }),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue()
}
@ -53,18 +47,16 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult,
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
}
@ -80,12 +72,10 @@ class PictureInPicturePresenterTest {
handUpResult = handUpResult
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false })))
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
initialState.eventSink(PictureInPictureEvent.SetPipController(FakePipController(canEnterPipResult = { false })))
initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
handUpResult.assertions().isCalledOnce()
}
}
@ -102,12 +92,10 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(
PictureInPictureEvents.SetPipController(
PictureInPictureEvent.SetPipController(
FakePipController(
canEnterPipResult = { true },
enterPipResult = enterPipResult,
@ -115,16 +103,16 @@ class PictureInPicturePresenterTest {
)
)
)
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
enterPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
enterPipResult.assertions().isCalledOnce()
// User stops pip
exitPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
exitPipResult.assertions().isCalledOnce()

View file

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

View file

@ -13,8 +13,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.CallScreenEvents
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
@ -39,6 +39,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
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.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
@ -59,46 +60,19 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse()
assertThat(initialState.isCallActive).isFalse()
analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
fun `present - with CallData sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val joinedCallLambda = lambdaRecorder<CallData, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(1)
@ -107,7 +81,6 @@ class CallScreenPresenterTest {
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
@ -123,19 +96,17 @@ class CallScreenPresenterTest {
fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message")
@ -154,24 +125,22 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup)
initialState.eventSink(CallScreenEvent.Hangup)
// Let background coroutines run and the widget drive be received
runCurrent()
@ -188,22 +157,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
@ -223,22 +190,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage(
"""
{
@ -260,22 +225,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
skipItems(2)
// Wait for the timeout to trigger
@ -300,7 +263,7 @@ class CallScreenPresenterTest {
val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -338,53 +301,8 @@ class CallScreenPresenterTest {
}
}
@Test
fun `present - error from WebView are updating the state`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isEqualTo("A Webview error")
}
}
@Test
fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
val messageInterceptor = FakeWidgetMessageInterceptor()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// Emit a message
messageInterceptor.givenInterceptedMessage("A message")
// WebView emits an error, but it will be ignored
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createCallScreenPresenter(
callType: CallType,
callData: CallData,
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
@ -401,7 +319,7 @@ class CallScreenPresenterTest {
}
val clock = SystemClock { 0 }
return CallScreenPresenter(
callType = callType,
callData = callData,
navigator = navigator,
callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider,

View file

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

View file

@ -1,226 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
doTest("", null)
}
@Test
fun `invalid data returns null`() {
doTest("!", null)
}
@Test
fun `data with no scheme returns null`() {
doTest("test", null)
}
@Test
fun `Element Call http urls returns null`() {
doTest("http://call.element.io", null)
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
@Test
fun `Element Call urls with unknown host returns null`() {
// Check valid host first, should not return null
doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
// Unknown host should return null
doTest("https://unknown.io", null)
doTest("https://call.unknown.io", null)
doTest("https://call.element.com", null)
doTest("https://call.element.io.tld", null)
}
@Test
fun `Element Call urls will be returned as is`() {
doTest(
url = "https://call.element.io",
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
doTest("https://app.element.io", null)
doTest("http://", null)
doTest("https://", null)
}
@Test
fun `Element Call url with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element invalid scheme returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "bad.scheme:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with url extra param appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
}
}

View file

@ -13,7 +13,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
@ -77,7 +77,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,
@ -104,7 +104,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = true,
@ -132,7 +132,7 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
assertThat(manager.activeCall.value?.callData?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
@ -178,7 +178,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hangUpCall - removes existing call if the CallType matches`() = runTest {
fun `hangUpCall - removes existing call if the CallData matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -188,7 +188,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
@ -215,7 +215,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
@ -242,7 +242,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
// Do not register the incoming call, so the manager doesn't know about it
manager.hangUpCall(
callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false),
callData = CallData(notificationData.sessionId, notificationData.roomId, false),
notificationData = notificationData,
)
coVerify {
@ -320,7 +320,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest {
fun `hangUpCall - does nothing if the CallData doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -329,7 +329,13 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.ExternalUrl("https://example.com"))
manager.hangUpCall(
CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID_2,
isAudioCall = true,
)
)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
@ -344,10 +350,10 @@ class DefaultActiveCallManagerTest {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, true))
manager.joinedCall(CallData(A_SESSION_ID, A_ROOM_ID, true))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
isAudioCall = true,
@ -450,7 +456,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,

View file

@ -8,7 +8,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallType) -> Unit = {},
var hangUpCallResult: (CallData, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallData) -> Unit = {},
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
@ -26,12 +26,12 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData)
}
override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callType, notificationData)
override suspend fun hangUpCall(callData: CallData, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callData, notificationData)
}
override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
override suspend fun joinedCall(callData: CallData) = simulateLongTask {
joinedCallResult(callData)
}
fun setActiveCall(value: ActiveCall?) {

View file

@ -8,16 +8,16 @@
package io.element.android.features.call.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = { lambdaError() },
var startCallResult: (CallData) -> Unit = { lambdaError() },
var handleIncomingCallResult: (
CallType.RoomCall,
CallData,
EventId,
UserId,
String?,
@ -27,12 +27,12 @@ class FakeElementCallEntryPoint(
String?,
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint {
override fun startCall(callType: CallType) {
startCallResult(callType)
override fun startCall(callData: CallData) {
startCallResult(callData)
}
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
@ -44,7 +44,7 @@ class FakeElementCallEntryPoint(
textContent: String?,
) {
handleIncomingCallResult(
callType,
callData,
eventId,
senderId,
roomName,

View file

@ -95,7 +95,8 @@ internal fun SelectParentSpaceOptions(
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
}
},
scrollable = false,
) {
SelectParentSpaceBottomSheet(
spaces = spaces,

View file

@ -19,7 +19,7 @@ Du kan ændre dette når som helst i rummets indstillinger."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Anmod om at deltage"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Kun inviterede brugere kan deltage."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan deltage i dette rum"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan deltage."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Offentlig"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Alle i %1$s kan deltage."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>

View file

@ -28,7 +28,8 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chats"</string>
<string name="screen_create_room_space_selection_no_space_description">"(kein Space)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Home"</string>
<string name="screen_create_room_space_selection_no_space_option">"Nicht zu einem Space hinzufügen"</string>
<string name="screen_create_room_space_selection_no_space_title">"Kein Space ausgewählt"</string>
<string name="screen_create_room_space_selection_sheet_title">"Space hinzufügen"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string>

View file

@ -3,7 +3,7 @@
<string name="screen_create_room_action_create_room">"اتاق جدید"</string>
<string name="screen_create_room_add_people_title">"دعوت افراد"</string>
<string name="screen_create_room_error_creating_room">"هنگام ایجاد اتاق خطایی رخ داد"</string>
<string name="screen_create_room_private_option_description">"تنها افراد دعوت شده می‌توانند به این اتاق دسترسی داشته باشند. همهٔ پیام‌ها رمزنگاری سرتاسری شده‌اند."</string>
<string name="screen_create_room_private_option_description">"تنها افراد دعوت شده می‌توانند بپیوندند."</string>
<string name="screen_create_room_public_option_description">"هرکسی می‌تواند اتاق را بیابد.
می‌توانید بعداً در تظیمات اتاق عوضش کنید."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"درخواست دعوت"</string>

View file

@ -3,14 +3,14 @@
<string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_add_people_title">"Invitați prieteni"</string>
<string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string>
<string name="screen_create_room_private_option_description">"Doar persoanele invitate pot accesa această cameră. Toate mesajele sunt criptate end-to-end."</string>
<string name="screen_create_room_private_option_description">"Doar persoanele invitate se pot alătura."</string>
<string name="screen_create_room_public_option_description">"Oricine poate găsi această cameră.
Puteți modifica acest lucru oricând în setări."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Cereți să vă alăturați"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Permite solicitarea de alăturare"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Oricine se poate alătura acestei camere"</string>
<string name="screen_create_room_room_address_section_footer">"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."</string>
<string name="screen_create_room_room_address_section_title">"Adresa camerei"</string>
<string name="screen_create_room_room_address_section_title">"Adresă"</string>
<string name="screen_create_room_room_visibility_section_title">"Vizibilitatea camerei"</string>
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
</resources>

View file

@ -1,36 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"新聊天室"</string>
<string name="screen_create_room_add_people_title">"邀请朋友"</string>
<string name="screen_create_room_error_creating_room">"创建聊天室时出错"</string>
<string name="screen_create_room_action_create_room">"新房间"</string>
<string name="screen_create_room_add_people_title">"邀请人员"</string>
<string name="screen_create_room_error_creating_room">"创建房间时出错"</string>
<string name="screen_create_room_error_creating_space">"由于未知错误,空间创建失败。请稍后再试。"</string>
<string name="screen_create_room_name_placeholder">"添加名称…"</string>
<string name="screen_create_room_new_room_title">"新聊天室"</string>
<string name="screen_create_room_new_room_title">"新房间"</string>
<string name="screen_create_room_new_space_title">"新空间"</string>
<string name="screen_create_room_private_option_description">"仅限受邀加入。"</string>
<string name="screen_create_room_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_private_option_title">"私密"</string>
<string name="screen_create_room_public_option_description">"任何人都能找到此聊天室
你可以随时在聊天室设置中更改。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以找到并加入"</string>
<string name="screen_create_room_public_option_description">"任何人都能找到此房间
你可以随时在房间设置中更改。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入"</string>
<string name="screen_create_room_public_option_title">"公共"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"任何人都可申请加入,但需由管理员或版主批准请求。"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"请加入"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"%1$s 中的任何人都可加入,但其他人必须申请访问权限。"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"任何人都可申请加入,但需由管理员或协管员批准申请。"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"请加入"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"%1$s 中的任何人都可加入,但其他人必须申请访问。"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"申请加入"</string>
<string name="screen_create_room_room_access_section_private_option_description">"仅限受邀加入。"</string>
<string name="screen_create_room_room_access_section_private_option_description">"仅限受邀人员加入。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私密"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公共"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s 中的任何人加入。"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s 中的任何人都可以加入。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"标准"</string>
<string name="screen_create_room_room_access_section_title">"谁有权访问此房间"</string>
<string name="screen_create_room_room_address_section_footer">"要使该聊天室在公共目录中可见,您需要一个聊天室地址。"</string>
<string name="screen_create_room_room_address_section_footer">"要使该房间在公共目录中可见,你需要一个地址。"</string>
<string name="screen_create_room_room_address_section_title">"地址"</string>
<string name="screen_create_room_room_visibility_section_title">"房间可见性"</string>
<string name="screen_create_room_space_selection_no_space_description">"(无空间)"</string>
<string name="screen_create_room_space_selection_no_space_option">"请勿添加至空间"</string>
<string name="screen_create_room_space_selection_no_space_option">"不要添加到空间"</string>
<string name="screen_create_room_space_selection_no_space_title">"未选择空间"</string>
<string name="screen_create_room_space_selection_sheet_title">"添加空间"</string>
<string name="screen_create_room_space_selection_sheet_title">"添加空间"</string>
<string name="screen_create_room_topic_label">"主题(可选)"</string>
<string name="screen_create_room_topic_placeholder">"添加描述…"</string>
</resources>

View file

@ -301,7 +301,9 @@ class ConfigureRoomPresenterTest {
roomName = 0,
roomAvatar = 0,
roomTopic = 0,
spaceChild = 0
spaceChild = 0,
beacon = 0,
beaconInfo = 0,
),
users = persistentMapOf(),
)

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Моля, потвърдете, че искате да деактивирате акаунта си. Това действие не може да бъде отменено."</string>
<string name="screen_deactivate_account_title">"Деактивиране на акаунта"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekræft venligst, at du vil deaktivere din konto. Denne handling kan ikke fortrydes."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekræft venligst, at du ønsker at slette din konto. Denne handling kan ikke fortrydes."</string>
<string name="screen_deactivate_account_delete_all_messages">"Slet alle mine beskeder"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Advarsel: Fremtidige brugere kan muligvis se ufuldstændige samtaler."</string>
<string name="screen_deactivate_account_description">"Deaktivering af din konto er %1$s, det vil:"</string>
<string name="screen_deactivate_account_description">"Sletning af din konto er %1$s, det vil:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s din konto (du kan ikke logge ind igen, og dit ID kan ikke genbruges)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Permanent deaktivere"</string>
<string name="screen_deactivate_account_list_item_2">"Fjerne dig fra alle samtaler"</string>
<string name="screen_deactivate_account_list_item_3">"Slette dine kontooplysninger fra vores identitetsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Dine beskeder vil stadig være synlige for registrerede brugere, men vil ikke være tilgængelige for nye eller uregistrerede brugere, hvis du vælger at slette dem."</string>
<string name="screen_deactivate_account_title">"Deaktiver konto"</string>
<string name="screen_deactivate_account_title">"Slet konto"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bitte bestätige, dass du dein Konto deaktivieren möchtest. Dies kann nicht rückgängig gemacht werden."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Bitte bestätige, dass du dein Konto löschen möchtest. Diese Aktion kann nicht rückgängig gemacht werden."</string>
<string name="screen_deactivate_account_delete_all_messages">"Lösche alle meine Nachrichten"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Warnung: Künftigen Nutzern werden möglicherweise unvollständige Konversationen angezeigt."</string>
<string name="screen_deactivate_account_description">"Dein Konto zu deaktivieren ist %1$s. Folgendes wird passieren:"</string>
<string name="screen_deactivate_account_description">"Das Löschen deines Kontos ist %1$s. Es wird:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s dein Konto (du kannst dich nicht erneut anmelden und deine ID kann nicht wiederverwendet werden)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Dauerhaft deaktivieren"</string>
<string name="screen_deactivate_account_list_item_2">"Du wirst aus allen Chats entfernt."</string>
<string name="screen_deactivate_account_list_item_3">"Lösche deine Kontoinformationen von unserem Identitätsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Deine Nachrichten werden für bereits registrierte Nutzer weiterhin sichtbar sein. Für neue oder unregistrierte Nutzer sind sie nicht verfügbar, wenn du sie löschen solltest."</string>
<string name="screen_deactivate_account_title">"Nutzerkonto deaktivieren"</string>
<string name="screen_deactivate_account_title">"Konto löschen"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"Te eliminará de todas las salas de chat."</string>
<string name="screen_deactivate_account_list_item_3">"Eliminará la información de tu cuenta de nuestro servidor de identidad."</string>
<string name="screen_deactivate_account_list_item_4">"Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos."</string>
<string name="screen_deactivate_account_title">"Desactivar cuenta"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Veuillez confirmer que vous souhaitez désactiver votre compte. Cette action ne peut pas être annulée."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Veuillez confirmer que vous souhaitez supprimer votre compte. Cette action ne peut pas être annulée."</string>
<string name="screen_deactivate_account_delete_all_messages">"Supprimer tous mes messages"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Attention : les futurs utilisateurs pourraient voir des conversations incomplètes."</string>
<string name="screen_deactivate_account_description">"La désactivation de votre compte est %1$s, cela va :"</string>
<string name="screen_deactivate_account_description">"La suppression de votre compte est %1$s, cela va :"</string>
<string name="screen_deactivate_account_description_bold_part">"irréversible"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s votre compte (vous ne pourrez plus vous reconnecter et votre identifiant ne pourra pas être réutilisé)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Désactiver définitivement"</string>
<string name="screen_deactivate_account_list_item_2">"Vous retirer de tous les salons et toutes les discussions."</string>
<string name="screen_deactivate_account_list_item_3">"Supprimer les informations de votre compte du serveur didentité."</string>
<string name="screen_deactivate_account_list_item_4">"Rendre vos messages invisibles aux futurs membres des salons si vous choisissez de les supprimer. Vos messages seront toujours visibles pour les utilisateurs qui les ont déjà récupérés."</string>
<string name="screen_deactivate_account_title">"Désactiver le compte"</string>
<string name="screen_deactivate_account_title">"Supprimer le compte"</string>
</resources>

View file

@ -10,5 +10,5 @@
<string name="screen_deactivate_account_list_item_2">"Ukloniti vas iz svih soba za razgovore."</string>
<string name="screen_deactivate_account_list_item_3">"Izbrisati podatke o vašem računu s našeg poslužitelja identiteta."</string>
<string name="screen_deactivate_account_list_item_4">"Vaše će poruke i dalje biti vidljive registriranim korisnicima, ali neće biti dostupne novim ili neregistriranim korisnicima ako ih odlučite izbrisati."</string>
<string name="screen_deactivate_account_title">"Deaktiviraj račun"</string>
<string name="screen_deactivate_account_title">"Izbriši račun"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Erősítse meg, hogy deaktiválja a fiókját. Ez a művelet nem vonható vissza."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Erősítse meg a fiókja törlését. Ez a művelet nem vonható vissza."</string>
<string name="screen_deactivate_account_delete_all_messages">"Összes saját üzenet törlése"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Figyelmeztetés: A jövőbeli felhasználók hiányos beszélgetéseket láthatnak."</string>
<string name="screen_deactivate_account_description">"A fiók deaktiválása %1$s, a következőket okozza:"</string>
<string name="screen_deactivate_account_description">"Fiókjának törlése: %1$s, ez a következőket eredményezi:"</string>
<string name="screen_deactivate_account_description_bold_part">"visszafordíthatatlan"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Véglegesen letiltja"</string>
<string name="screen_deactivate_account_list_item_2">"Eltávolításra kerül az összes csevegőszobából."</string>
<string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai az azonosítási kiszolgálónkról."</string>
<string name="screen_deactivate_account_list_item_4">"Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."</string>
<string name="screen_deactivate_account_title">"Fiók deaktiválása"</string>
<string name="screen_deactivate_account_title">"Fiók törlése"</string>
</resources>

View file

@ -10,5 +10,5 @@
<string name="screen_deactivate_account_list_item_2">"Ti rimuove da tutte le stanze di chat."</string>
<string name="screen_deactivate_account_list_item_3">"Elimina le informazioni del tuo account dal nostro server di identità."</string>
<string name="screen_deactivate_account_list_item_4">"I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli."</string>
<string name="screen_deactivate_account_title">"Disattiva account"</string>
<string name="screen_deactivate_account_title">"Disattivazione dell\'account"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"アカウントを無効化することを再度確認します。この操作は元に戻せません。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"アカウントを削除しようとしていることを確認しています。この操作は元に戻せません。"</string>
<string name="screen_deactivate_account_delete_all_messages">"メッセージをすべて削除"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"注意: 新しいユーザーには断片的な会話が表示されます"</string>
<string name="screen_deactivate_account_description">"アカウントを無効化することは %1$s であり、次の変化が生じます:"</string>
<string name="screen_deactivate_account_description">"アカウントを削除することは %1$s であり、次の変化が生じます:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"アカウントを %1$s (再度ログイン不可, 同一のIDを再利用不可)"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"恒久的に無効化する"</string>
<string name="screen_deactivate_account_list_item_2">"すべてのチャットルームから退出します。"</string>
<string name="screen_deactivate_account_list_item_3">"アカウント提供元サーバーからアカウント情報を削除します。"</string>
<string name="screen_deactivate_account_list_item_4">"あなたの会話は、既存ユーザーには引き続き表示されますが、新規ユーザーには表示されなくなります。"</string>
<string name="screen_deactivate_account_title">"アカウントを無効化"</string>
<string name="screen_deactivate_account_title">"アカウントを削除"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"모든 채팅방에서 자신을 제거하세요."</string>
<string name="screen_deactivate_account_list_item_3">"당사의 신원 서버에서 귀하의 계정 정보를 삭제하세요."</string>
<string name="screen_deactivate_account_list_item_4">"메시지는 등록된 사용자에게는 계속 표시되지만, 삭제하면 신규 또는 미등록 사용자는 볼 수 없게 됩니다."</string>
<string name="screen_deactivate_account_title">"계정 비활성화"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"Te remover de todas as salas de conversa."</string>
<string name="screen_deactivate_account_list_item_3">"Apague as informações da sua conta do nosso servidor de identidade."</string>
<string name="screen_deactivate_account_list_item_4">"Suas mensagens ainda estarão visíveis para os usuários registrados, mas não estarão disponíveis para usuários novos ou não registrados se você optar por apagá-las."</string>
<string name="screen_deactivate_account_title">"Desativar conta"</string>
</resources>

View file

@ -10,5 +10,5 @@
<string name="screen_deactivate_account_list_item_2">"Видалити вас з усіх чатів."</string>
<string name="screen_deactivate_account_list_item_3">"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."</string>
<string name="screen_deactivate_account_list_item_4">"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."</string>
<string name="screen_deactivate_account_title">"Деактивувати обліковий запис"</string>
<string name="screen_deactivate_account_title">"Відключити обліковий запис"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"آپ کو تمام چیت رومز سے ہٹا دے گا۔"</string>
<string name="screen_deactivate_account_list_item_3">"ہمارے شناختی سرور سے اپنے اکاؤنٹ کی معلومات کو حذف کریں۔"</string>
<string name="screen_deactivate_account_list_item_4">"آپ کے پیغامات اب بھی رجسٹرڈ صارفین کو نظر آئیں گے لیکن اگر آپ انہیں حذف کرنے کا انتخاب کرتے ہیں تو نئے یا غیر رجسٹرڈ صارفین کے لیے دستیاب نہیں ہوں گے۔"</string>
<string name="screen_deactivate_account_title">"اکاؤنٹ کو غیر فعال کریں"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"Sizni barcha chat xonalaridan olib tashlash."</string>
<string name="screen_deactivate_account_list_item_3">"Hisobingiz haqidagi axborotni identifikatsiya serverimizdan ochirib tashlang."</string>
<string name="screen_deactivate_account_list_item_4">"Xabarlaringiz royxatdan otgan foydalanuvchilarga korinadi, lekin ularni ochirishni tanlasangiz, yangi yoki royxatdan otmagan foydalanuvchilarga korinmaydi."</string>
<string name="screen_deactivate_account_title">"Hisobni faolsizlantirish"</string>
</resources>

View file

@ -10,5 +10,4 @@
<string name="screen_deactivate_account_list_item_2">"Loại bỏ bạn khỏi tất cả các phòng chat."</string>
<string name="screen_deactivate_account_list_item_3">"Xóa thông tin tài khoản của bạn khỏi máy chủ nhận dạng của chúng tôi."</string>
<string name="screen_deactivate_account_list_item_4">"Tin nhắn của bạn vẫn sẽ hiển thị cho người dùng đã đăng ký nhưng sẽ không hiển thị cho người dùng mới hoặc chưa đăng ký nếu bạn chọn xóa chúng."</string>
<string name="screen_deactivate_account_title">"Vô hiệu hóa tài khoản"</string>
</resources>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"请确认您要停用您的账户。此操作无法撤消。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"请确认要删除的账户。此操作无法撤消。"</string>
<string name="screen_deactivate_account_delete_all_messages">"删除我的所有消息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未来的用户可能会看到不完整的对话。"</string>
<string name="screen_deactivate_account_description">"停用您的帐户是%1$s它将"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆转的"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s您的账户您无法登录回来并且您的ID无法重复使用)。"</string>
<string name="screen_deactivate_account_description">"正在删除的账户为 %1$s它将"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"你的账户 %1$s将无法再登录并且 ID 无法重复使用)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久禁用"</string>
<string name="screen_deactivate_account_list_item_2">"将从所有聊天房间中移除。"</string>
<string name="screen_deactivate_account_list_item_3">"从我们的身份服务器中删除的账户信息。"</string>
<string name="screen_deactivate_account_list_item_4">"注册用户仍可看到您的消息,但如果您选择删除它们,新用户或未注册用户将无法看到您的消息。"</string>
<string name="screen_deactivate_account_title">"停用账户"</string>
<string name="screen_deactivate_account_list_item_2">"将从所有聊天房间中移除。"</string>
<string name="screen_deactivate_account_list_item_3">"从我们的身份服务器中删除的账户信息。"</string>
<string name="screen_deactivate_account_list_item_4">"注册用户仍可看到你的消息,但如果选择删除它们,新用户或未注册用户将无法看到你的消息。"</string>
<string name="screen_deactivate_account_title">"删除账户"</string>
</resources>

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction
@ -26,33 +29,29 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class AccountDeactivationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false)
ensureCalledOnce {
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder),
onBackClick = it,
)
rule.pressBack()
pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Deactivate emits the expected Event`() {
fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@ -60,14 +59,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_delete)
clickOn(CommonStrings.action_delete)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
}
@Test
fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() {
fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@ -76,14 +75,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
rule.pressTag(TestTags.dialogPositive.value)
pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
}
@Test
fun `clicking on retry on the confirmation dialog emits the expected Event`() {
fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@ -92,26 +91,26 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_retry)
clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true))
}
@Test
fun `switching on the erase all switch emits the expected Event`() {
fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_deactivate_account_delete_all_messages)
clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true))
}
@Test
fun `switching off the erase all switch emits the expected Event`() {
fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
eraseData = true,
@ -119,15 +118,15 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_deactivate_account_delete_all_messages)
clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false))
}
@Config(qualifiers = "h1024dp")
@Test
fun `typing text in the password field emits the expected Event`() {
fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView(
setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@ -135,12 +134,12 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
rule.onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD"))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAccountDeactivationView(
private fun AndroidComposeUiTest<ComponentActivity>.setAccountDeactivationView(
state: AccountDeactivationState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {

View file

@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean

View file

@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
suspend fun tweakMasUrl(url: String): String
suspend fun init()
}

View file

@ -23,7 +23,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true

View file

@ -15,5 +15,6 @@ import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
}

View file

@ -15,6 +15,7 @@ android {
dependencies {
api(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)

View file

@ -30,6 +30,7 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@ -38,6 +39,10 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
tweakMasUrlResult(url, homeserver)
}
override fun defaultHomeserverList(): List<String> {
return defaultHomeserverListResult()
}

View file

@ -14,10 +14,15 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
override suspend fun tweakMasUrl(url: String): String = simulateLongTask {
tweakMasUrlResult(url)
}
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.forward.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
@ -21,34 +24,30 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ForwardMessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `cancel error emits the expected event`() {
fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
rule.setForwardMessagesView(
setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder
),
)
rule.pressTag(TestTags.dialogPositive.value)
pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
}
@Test
fun `success invokes onForwardSuccess`() {
fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest {
val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
rule.setForwardMessagesView(
setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Success(data),
eventSink = eventsRecorder
@ -59,7 +58,7 @@ class ForwardMessagesViewTest {
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setForwardMessagesView(
private fun AndroidComposeUiTest<ComponentActivity>.setForwardMessagesView(
state: ForwardMessagesState,
onForwardSuccess: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(),
) {

View file

@ -19,6 +19,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -90,7 +93,11 @@ fun ChooseSelfVerificationModeView(
Text(
modifier = Modifier
.clickable(onClick = onLearnMore)
.padding(vertical = 4.dp, horizontal = 16.dp),
.padding(vertical = 4.dp, horizontal = 16.dp)
.semantics {
// Note: there is no Role.Link, so we use Role.Button for better accessibility support
role = Role.Button
},
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
)

View file

@ -3,7 +3,7 @@
<string name="screen_identity_confirmation_cannot_confirm">"Bestätigung unmöglich?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Erstelle einen neuen Wiederherstellungsschlüssel"</string>
<string name="screen_identity_confirmation_subtitle">"Wähle eine Verifizierungsmethode, um den sicheren Nachrichtenversand einzurichten."</string>
<string name="screen_identity_confirmation_title">"Bestätige deine Identität"</string>
<string name="screen_identity_confirmation_title">"Bestätige deine digitale Identität"</string>
<string name="screen_identity_confirmation_use_another_device">"Ein anderes Gerät verwenden"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Wiederherstellungsschlüssel verwenden"</string>
<string name="screen_identity_confirmed_subtitle">"Du kannst jetzt verschlüsselte Nachrichten lesen und versenden. Dein Chatpartner vertraut nun diesem Gerät."</string>

View file

@ -11,5 +11,5 @@
<string name="screen_identity_use_another_device">"他の端末を使用"</string>
<string name="screen_identity_waiting_on_other_device">"一方の端末を待機中…"</string>
<string name="screen_notification_optin_subtitle">"設定は後で変更することができます。"</string>
<string name="screen_notification_optin_title">"メッセージを見逃さないため通知を許可"</string>
<string name="screen_notification_optin_title">"メッセージを見逃さないため通知を許可しましょう"</string>
</resources>

View file

@ -3,7 +3,7 @@
<string name="screen_identity_confirmation_cannot_confirm">"Não é possível confirmar?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Criar uma nova chave de recuperação"</string>
<string name="screen_identity_confirmation_subtitle">"Verifica este dispositivo para configurar o envio seguro de mensagens."</string>
<string name="screen_identity_confirmation_title">"Confirma que és tu"</string>
<string name="screen_identity_confirmation_title">"Confirma a tua identidade digital"</string>
<string name="screen_identity_confirmation_use_another_device">"Utilizar outro dispositivo"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Utilizar chave de recuperação"</string>
<string name="screen_identity_confirmed_subtitle">"Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo."</string>

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Nu puteți confirma?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Creați o nouă cheie de recuperare"</string>
<string name="screen_identity_confirmation_subtitle">"Verificați acest dispozitiv pentru a configura mesagerie securizată."</string>
<string name="screen_identity_confirmation_title">"Confirmați că sunteți dumneavoastră"</string>
<string name="screen_identity_confirmation_subtitle">"Alegeți cum doriți să vă verificați pentru a configura mesageria securizată."</string>
<string name="screen_identity_confirmation_title">"Confirmați-vă identitatea digitală"</string>
<string name="screen_identity_confirmation_use_another_device">"Utilizați un alt dispozitiv"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Utilizați cheia de recuperare"</string>
<string name="screen_identity_confirmed_subtitle">"Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv."</string>

View file

@ -3,13 +3,13 @@
<string name="screen_identity_confirmation_cannot_confirm">"无法确认?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"创建新的恢复密钥"</string>
<string name="screen_identity_confirmation_subtitle">"选择验证方式以设置安全的消息传输。"</string>
<string name="screen_identity_confirmation_title">"确认的数字身份"</string>
<string name="screen_identity_confirmation_use_another_device">"使用其设备"</string>
<string name="screen_identity_confirmation_title">"确认的数字身份"</string>
<string name="screen_identity_confirmation_use_another_device">"使用其设备"</string>
<string name="screen_identity_confirmation_use_recovery_key">"使用恢复密钥"</string>
<string name="screen_identity_confirmed_subtitle">"现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。"</string>
<string name="screen_identity_confirmed_subtitle">"现在你可以安全地读取或发送消息,并且与你聊天的任何人也可以信任此设备。"</string>
<string name="screen_identity_confirmed_title">"设备已验证"</string>
<string name="screen_identity_use_another_device">"使用其设备"</string>
<string name="screen_identity_waiting_on_other_device">"正在等待其他设备……"</string>
<string name="screen_notification_optin_subtitle">"可以稍后更改设置。"</string>
<string name="screen_identity_use_another_device">"使用其设备"</string>
<string name="screen_identity_waiting_on_other_device">"正在等待其它设备…"</string>
<string name="screen_notification_optin_subtitle">"可以稍后更改设置。"</string>
<string name="screen_notification_optin_title">"允许通知,绝不错过任何消息"</string>
</resources>

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
@ -18,65 +21,61 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ChooseSessionVerificationModeViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on learn more invokes the expected callback`() {
fun `clicking on learn more invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onLearnMoreClick = callback,
)
rule.clickOn(CommonStrings.action_learn_more)
clickOn(CommonStrings.action_learn_more)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on use another device calls the callback`() {
fun `clicking on use another device calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))),
onUseAnotherDevice = callback,
)
rule.clickOn(R.string.screen_identity_use_another_device)
clickOn(R.string.screen_identity_use_another_device)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on enter recovery key calls the callback`() {
fun `clicking on enter recovery key calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseRecoveryKey = true))),
onEnterRecoveryKey = callback,
)
rule.clickOn(R.string.screen_identity_confirmation_use_recovery_key)
clickOn(R.string.screen_identity_confirmation_use_recovery_key)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on cannot confirm calls the reset keys callback`() {
fun `clicking on cannot confirm calls the reset keys callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView(
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onResetKey = callback,
)
rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm)
clickOn(R.string.screen_identity_confirmation_cannot_confirm)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseSelfVerificationModeView(
private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),

View file

@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)

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