Merge branch 'release/26.05.0'
This commit is contained in:
commit
cc65a0f114
1432 changed files with 9674 additions and 8136 deletions
2
.github/workflows/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/recordScreenshots.yml
vendored
1
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -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
3
.idea/kotlinc.xml
generated
|
|
@ -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>
|
||||
|
|
|
|||
42
CHANGES.md
42
CHANGES.md
|
|
@ -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
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
android:scheme="elementx" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Oidc redirection
|
||||
OAuth redirection
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
|
|
|||
|
|
@ -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(":/")
|
||||
|
|
@ -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()
|
||||
|
|
@ -48,6 +48,8 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
2
fastlane/metadata/android/en-US/changelogs/202605000.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202605000.txt
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
)!!
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ internal fun SelectParentSpaceOptions(
|
|||
sheetState.hide(coroutineScope) {
|
||||
displaySelectSpaceBottomSheet = false
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollable = false,
|
||||
) {
|
||||
SelectParentSpaceBottomSheet(
|
||||
spaces = spaces,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -301,7 +301,9 @@ class ConfigureRoomPresenterTest {
|
|||
roomName = 0,
|
||||
roomAvatar = 0,
|
||||
roomTopic = 0,
|
||||
spaceChild = 0
|
||||
spaceChild = 0,
|
||||
beacon = 0,
|
||||
beaconInfo = 0,
|
||||
),
|
||||
users = persistentMapOf(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 d’identité."</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 o‘chirib tashlang."</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"Xabarlaringiz ro‘yxatdan o‘tgan foydalanuvchilarga ko‘rinadi, lekin ularni o‘chirishni tanlasangiz, yangi yoki ro‘yxatdan o‘tmagan foydalanuvchilarga ko‘rinmaydi."</string>
|
||||
<string name="screen_deactivate_account_title">"Hisobni faolsizlantirish"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue