Integrate Element Call with widget API (#1581)

* Integrate Element Call with widget API.

- Add `appconfig` module and extract constants that can be overridden in forks there.
- Add an Element Call feature flag, disabled by default.
- Refactor the whole `ElementCallActivity`, move most logic out of it.
- Integrate with the Rust Widget Driver API (note the Rust SDK version used in this PR lacks some needed changes to make the calls actually work).
- Handle calls differently based on `CallType`.
- Add UI to create/join a call.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2023-10-19 17:38:43 +02:00
committed by GitHub
parent cf3039c6f3
commit 5e547269e7
102 changed files with 2202 additions and 166 deletions

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("java-library")
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
object AuthenticationConfig {
const val MATRIX_ORG_URL = "https://matrix.org"
const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
object ElementCallConfig {
const val DEFAULT_BASE_URL = "https://call.element.io"
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.config
package io.element.android.appconfig
object MatrixConfiguration {
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"

View File

@@ -18,20 +18,44 @@ plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.call"
buildFeatures {
buildConfig = true
}
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.appnav)
implementation(projects.appconfig)
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.serialization.json)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View File

@@ -27,7 +27,7 @@
<application>
<activity
android:name=".ElementCallActivity"
android:name=".ui.ElementCallActivity"
android:label="@string/element_call"
android:exported="true"
android:taskAffinity="io.element.android.features.call"

View File

@@ -26,6 +26,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.graphics.drawable.IconCompat
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.libraries.designsystem.utils.CommonDrawables
class CallForegroundService : Service() {

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call
import android.os.Parcelable
import io.element.android.libraries.architecture.NodeInputs
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
@Parcelize
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
) : CallType
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WidgetMessage(
@SerialName("api") val direction: Direction,
@SerialName("widgetId") val widgetId: String,
@SerialName("requestId") val requestId: String,
@SerialName("action") val action: Action,
) {
@Serializable
enum class Direction {
@SerialName("fromWidget")
FromWidget,
@SerialName("toWidget")
ToWidget
}
@Serializable
enum class Action {
@SerialName("im.vector.hangup")
HangUp
}
}

View File

@@ -17,7 +17,7 @@
package io.element.android.features.call.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.call.ElementCallActivity
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.ui
import io.element.android.features.call.utils.WidgetMessageInterceptor
sealed interface CallScreeEvents {
data object Hangup : CallScreeEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreeEvents
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.call.CallType
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.utils.CallWidgetProvider
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.utils.WidgetMessageSerializer
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.UUID
class CallScreenPresenter @AssistedInject constructor(
@Assisted private val callType: CallType,
@Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider,
private val userAgentProvider: UserAgentProvider,
private val clock: SystemClock,
private val dispatchers: CoroutineDispatchers,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
}
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
@Composable
override fun present(): CallScreenState {
val coroutineScope = rememberCoroutineScope()
val urlState = remember { mutableStateOf<Async<String>>(Async.Uninitialized) }
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
LaunchedEffect(Unit) {
loadUrl(callType, urlState, callWidgetDriver)
}
callWidgetDriver.value?.let { driver ->
LaunchedEffect(Unit) {
driver.incomingMessages
.onEach {
// Relay message to the WebView
messageInterceptor.value?.sendMessage(it)
}
.launchIn(this)
driver.run()
}
}
messageInterceptor.value?.let { interceptor ->
LaunchedEffect(Unit) {
interceptor.interceptedMessages
.onEach {
// Relay message to Widget Driver
callWidgetDriver.value?.send(it)
val parsedMessage = parseMessage(it)
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget && parsedMessage.action == WidgetMessage.Action.HangUp) {
close(callWidgetDriver.value, navigator)
}
}
.launchIn(this)
}
}
fun handleEvents(event: CallScreeEvents) {
when (event) {
is CallScreeEvents.Hangup -> {
val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null) {
sendHangupMessage(widgetId, interceptor)
}
coroutineScope.launch {
close(callWidgetDriver.value, navigator)
}
}
is CallScreeEvents.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor
}
}
}
return CallScreenState(
urlState = urlState.value,
userAgent = userAgent,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.loadUrl(
inputs: CallType,
urlState: MutableState<Async<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
) = launch {
urlState.runCatchingUpdatingState {
when (inputs) {
is CallType.ExternalUrl -> {
inputs.url
}
is CallType.RoomCall -> {
val (driver, url) = callWidgetProvider.getWidget(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
).getOrThrow()
callWidgetDriver.value = driver
url
}
}
}
}
private fun parseMessage(message: String): WidgetMessage? {
return WidgetMessageSerializer.deserialize(message).getOrNull()
}
private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) {
val message = WidgetMessage(
direction = WidgetMessage.Direction.ToWidget,
widgetId = widgetId,
requestId = "widgetapi-${clock.epochMillis()}",
action = WidgetMessage.Action.HangUp,
)
messageInterceptor.sendMessage(WidgetMessageSerializer.serialize(message))
}
private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) {
navigator.close()
widgetDriver?.close()
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.ui
import io.element.android.libraries.architecture.Async
data class CallScreenState(
val urlState: Async<String>,
val userAgent: String,
val isInWidgetMode: Boolean,
val eventSink: (CallScreeEvents) -> Unit,
)

View File

@@ -14,106 +14,128 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.ui
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.R
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
typealias RequestPermissionCallback = (Array<String>) -> Unit
interface CallScreenNavigator {
fun close()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CallScreenView(
url: String?,
userAgent: String,
state: CallScreenState,
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
ElementTheme {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.element_call)) },
navigationIcon = {
BackButton(
resourceId = CommonDrawables.ic_compound_close,
onClick = onClose
)
}
)
}
) { padding ->
CallWebView(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.fillMaxSize(),
url = url,
userAgent = userAgent,
onPermissionsRequested = { request ->
val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.element_call)) },
navigationIcon = {
BackButton(
resourceId = CommonDrawables.ic_compound_close,
onClick = { state.eventSink(CallScreeEvents.Hangup) }
)
}
)
}
) { padding ->
BackHandler {
state.eventSink(CallScreeEvents.Hangup)
}
CallWebView(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.fillMaxSize(),
url = state.urlState,
userAgent = state.userAgent,
onPermissionsRequested = { request ->
val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onWebViewCreated = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView)
state.eventSink(CallScreeEvents.SetupMessageChannels(interceptor))
}
)
}
}
@Composable
private fun CallWebView(
url: String?,
url: Async<String>,
userAgent: String,
onPermissionsRequested: (PermissionRequest) -> Unit,
onWebViewCreated: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
val isInpectionMode = LocalInspectionMode.current
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
if (!isInpectionMode) {
setup(userAgent, onPermissionsRequested)
if (url != null) {
loadUrl(url)
}
}
}
},
update = { webView ->
if (!isInpectionMode && url != null) {
webView.loadUrl(url)
}
},
onRelease = { webView ->
webView.destroy()
if (LocalInspectionMode.current) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text("WebView - can't be previewed")
}
)
} else {
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
setup(userAgent, onPermissionsRequested)
if (url is Async.Success) {
loadUrl(url.data)
}
onWebViewCreated(this)
}
},
update = { webView ->
if (url is Async.Success && webView.url != url.data) {
webView.loadUrl(url.data)
}
},
onRelease = { webView ->
webView.destroy()
}
)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
private fun WebView.setup(
userAgent: String,
onPermissionsRequested: (PermissionRequest) -> Unit,
) {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
@@ -140,12 +162,15 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview() {
ElementTheme {
ElementPreview {
CallScreenView(
url = "https://call.element.io/some-actual-call?with=parameters",
userAgent = "",
state = CallScreenState(
urlState = Async.Success("https://call.element.io/some-actual-call?with=parameters"),
isInWidgetMode = false,
userAgent = "",
eventSink = {},
),
requestPermissions = { _, _ -> },
onClose = { },
)
}
}

View File

@@ -14,10 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.res.Configuration
import android.media.AudioAttributes
import android.media.AudioFocusRequest
@@ -26,20 +28,40 @@ import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.IntentCompat
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.theme.ElementTheme
import javax.inject.Inject
class ElementCallActivity : ComponentActivity() {
class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
companion object {
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
fun start(
context: Context,
callInputs: CallType,
) {
val intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
@Inject lateinit var userAgentProvider: UserAgentProvider
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
private lateinit var presenter: CallScreenPresenter
private lateinit var audioManager: AudioManager
@@ -51,7 +73,7 @@ class ElementCallActivity : ComponentActivity() {
private val requestPermissionsLauncher = registerPermissionResultLauncher()
private var isDarkMode = false
private val urlState = mutableStateOf<String?>(null)
private val webViewTarget = mutableStateOf<CallType?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -60,10 +82,7 @@ class ElementCallActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
finish()
return
}
setCallType(intent)
if (savedInstanceState == null) {
updateUiMode(resources.configuration)
@@ -72,18 +91,17 @@ class ElementCallActivity : ComponentActivity() {
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus()
val userAgent = userAgentProvider.provide()
setContent {
CallScreenView(
url = urlState.value,
userAgent = userAgent,
onClose = this::finish,
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
val state = presenter.present()
ElementTheme {
CallScreenView(
state = state,
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
}
}
@@ -96,15 +114,7 @@ class ElementCallActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val intentUrl = intent?.dataString?.let(::parseUrl)
when {
// New URL, update it and reload the webview
intentUrl != null -> urlState.value = intentUrl
// Re-opened the activity but we have no url to load or a cached one, finish the activity
intent?.dataString == null && urlState.value == null -> finish()
// Coming back from notification, do nothing
else -> return
}
setCallType(intent)
}
override fun onStart() {
@@ -130,6 +140,32 @@ class ElementCallActivity : ComponentActivity() {
finishAndRemoveTask()
}
override fun close() {
finish()
}
private fun setCallType(intent: Intent?) {
val inputs = intent?.let {
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
}
val intentUrl = intent?.dataString?.let(::parseUrl)
when {
// Re-opened the activity but we have no url to load or a cached one, finish the activity
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
inputs != null -> {
webViewTarget.value = inputs
presenter = presenterFactory.create(inputs, this)
}
intentUrl != null -> {
val fallbackInputs = CallType.ExternalUrl(intentUrl)
webViewTarget.value = fallbackInputs
presenter = presenterFactory.create(fallbackInputs, this)
}
// Coming back from notification, do nothing
else -> return
}
}
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.utils
import android.net.Uri
import javax.inject.Inject

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
interface CallWidgetProvider {
suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String? = null,
theme: String? = null,
): Result<Pair<MatrixWidgetDriver, String>>
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val preferencesStore: PreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String?,
theme: String?,
): Result<Pair<MatrixWidgetDriver, String>> = runCatching {
val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
val baseUrl = preferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl)
val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import android.graphics.Bitmap
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow
class WebViewWidgetMessageInterceptor(
private val webView: WebView,
) : WidgetMessageInterceptor {
companion object {
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this
// 'listenerName' so they can both receive the data from the WebView when
// `${LISTENER_NAME}.postMessage(...)` is called
const val LISTENER_NAME = "elementX"
}
override val interceptedMessages = MutableSharedFlow<String>(replay = 1, extraBufferCapacity = 2)
init {
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// We inject this JS code when the page starts loading to attach a message listener to the window.
// This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
view?.evaluateJavascript(
"""
window.addEventListener('message', function(event) {
let message = {data: event.data, origin: event.origin}
if (message.data.response && message.data.api == "toWidget"
|| !message.data.response && message.data.api == "fromWidget") {
let json = JSON.stringify(event.data)
${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } }
${LISTENER_NAME}.postMessage(json);
} else {
${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } }
}
});
""".trimIndent(),
null
)
}
}
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
onMessageReceived(message.data)
}
// Use WebMessageListener if supported, otherwise use JavascriptInterface
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
LISTENER_NAME,
setOf("*"),
webMessageListener
)
} else {
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
}
}, LISTENER_NAME)
}
}
override fun sendMessage(message: String) {
webView.evaluateJavascript("postMessage($message, '*')", null)
}
private fun onMessageReceived(json: String?) {
// Here is where we would handle the messages from the WebView, passing them to the Rust SDK
json?.let { interceptedMessages.tryEmit(it) }
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import kotlinx.coroutines.flow.Flow
interface WidgetMessageInterceptor {
val interceptedMessages: Flow<String>
fun sendMessage(message: String)
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import io.element.android.features.call.data.WidgetMessage
import kotlinx.serialization.json.Json
object WidgetMessageSerializer {
private val coder = Json { ignoreUnknownKeys = true }
fun deserialize(message: String): Result<WidgetMessage> {
return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) }
}
fun serialize(message: WidgetMessage): String {
return coder.encodeToString(WidgetMessage.serializer(), message)
}
}

View File

@@ -19,6 +19,7 @@ package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.ui.mapWebkitPermissions
import org.junit.Test
class MapWebkitPermissionsTest {

View File

@@ -0,0 +1,194 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.ui
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.CallType
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.widget.FakeWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class CallScreenPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io"))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(Async.Success("https://call.element.io"))
assertThat(initialState.isInWidgetMode).isFalse()
}
}
@Test
fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(Async.Success::class.java)
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
}
}
@Test
fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message")
assertThat(messageInterceptor.sentMessages).containsExactly("A message")
// And incoming message from the WebView is passed to the Widget Driver
messageInterceptor.givenInterceptedMessage("A reply")
assertThat(widgetDriver.sentMessages).containsExactly("A reply")
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreeEvents.Hangup)
// Let background coroutines run
runCurrent()
assertThat(navigator.closeCalled).isTrue()
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run
runCurrent()
assertThat(navigator.closeCalled).isTrue()
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createCallScreenPresenter(
callType: CallType,
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
return "Test"
}
}
val clock = SystemClock { 0 }
return CallScreenPresenter(
callType,
navigator,
widgetProvider,
userAgentProvider,
clock,
dispatchers,
)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.ui
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
private set
override fun close() {
closeCalled = true
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import org.junit.Test

View File

@@ -0,0 +1,121 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if the session does not exist`() = runTest {
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if the room does not exist`() = runTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, null)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
val room = FakeMatrixRoom().apply {
givenGenerateWidgetWebViewUrlResult(Result.failure(Exception("Can't generate URL for widget")))
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if it can't get the widget driver`() = runTest {
val room = FakeMatrixRoom().apply {
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
givenGetWidgetDriverResult(Result.failure(Exception("Can't get a widget driver")))
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
val room = FakeMatrixRoom().apply {
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull()
}
@Test
fun `getWidget - will use a custom base url if it exists`() = runTest {
val room = FakeMatrixRoom().apply {
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val preferencesStore = InMemoryPreferencesStore().apply {
setCustomElementCallBaseUrl("https://custom.element.io")
}
val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider,
preferencesStore = preferencesStore,
)
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
}
private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
preferencesStore: PreferencesStore = InMemoryPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
) = DefaultCallWidgetProvider(
matrixClientProvider,
preferencesStore,
callWidgetSettingsProvider,
)
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
class FakeCallWidgetProvider(
private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
private val url: String = "https://call.element.io",
) : CallWidgetProvider {
var getWidgetCalled = false
private set
override suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String?,
theme: String?
): Result<Pair<MatrixWidgetDriver, String>> {
getWidgetCalled = true
return Result.success(widgetDriver to url)
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
val sentMessages = mutableListOf<String>()
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
override fun sendMessage(message: String) {
sentMessages += message
}
fun givenInterceptedMessage(message: String) {
interceptedMessages.tryEmit(message)
}
}

View File

@@ -38,6 +38,7 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
implementation(projects.appconfig)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)

View File

@@ -17,7 +17,7 @@
package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.appconfig.AuthenticationConfig
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider>
@@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
}
fun anAccountProvider() = AccountProvider(
url = LoginConstants.MATRIX_ORG_URL,
url = AuthenticationConfig.MATRIX_ORG_URL,
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,

View File

@@ -17,9 +17,9 @@
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
@@ -34,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
url = LoginConstants.MATRIX_ORG_URL,
url = AuthenticationConfig.MATRIX_ORG_URL,
subtitle = null,
isPublic = true,
isMatrixOrg = true,

View File

@@ -17,9 +17,9 @@
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
@@ -50,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
}
fun aHomeserverData(
homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): HomeserverData {

View File

@@ -14,12 +14,11 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -48,13 +47,13 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -196,7 +195,7 @@ fun SearchAccountProviderView(
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
return AccountProvider(
url = homeserverUrl,
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,

View File

@@ -16,18 +16,12 @@
package io.element.android.features.login.impl.util
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.accountprovider.AccountProvider
object LoginConstants {
const val MATRIX_ORG_URL = "https://matrix.org"
const val DEFAULT_HOMESERVER_URL = "https://matrix.org"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}
val defaultAccountProvider = AccountProvider(
url = LoginConstants.DEFAULT_HOMESERVER_URL,
url = AuthenticationConfig.DEFAULT_HOMESERVER_URL,
subtitle = null,
isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
isPublic = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
)

View File

@@ -19,9 +19,10 @@ package io.element.android.features.login.impl.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.libraries.core.data.tryOrNull
fun openLearnMorePage(context: Context) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL))
tryOrNull { context.startActivity(intent) }
}

View File

@@ -1,8 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_backing_up_subtitle">"Please wait for this to complete before signing out."</string>
<string name="screen_signout_backing_up_title">"Your keys are still being backed up"</string>
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
<string name="screen_signout_last_session_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string>
<string name="screen_signout_last_session_title">"Have you saved your recovery key?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string>
</resources>

View File

@@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
implementation(projects.features.call)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
implementation(projects.libraries.androidutils)

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -29,6 +30,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
@@ -50,7 +53,9 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
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.UserId
@@ -63,6 +68,8 @@ import kotlinx.parcelize.Parcelize
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
@@ -149,6 +156,14 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onCreatePollClicked() {
backstack.push(NavTarget.CreatePoll)
}
override fun onJoinCallClicked(roomId: RoomId) {
val inputs = CallType.RoomCall(
sessionId = matrixClient.sessionId,
roomId = roomId,
)
ElementCallActivity.start(context, inputs)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}

View File

@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.libraries.di.RoomScope
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.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -63,6 +64,7 @@ class MessagesNode @AssistedInject constructor(
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
fun onCreatePollClicked()
fun onJoinCallClicked(roomId: RoomId)
}
init {
@@ -108,6 +110,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onCreatePollClicked()
}
private fun onJoinCallClicked() {
callback?.onJoinCallClicked(room.roomId)
}
@Composable
override fun View(modifier: Modifier) {
CompositionLocalProvider(
@@ -123,6 +129,7 @@ class MessagesNode @AssistedInject constructor(
onUserDataClicked = this::onUserDataClicked,
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked,
modifier = modifier,
)
}

View File

@@ -152,8 +152,10 @@ class MessagesPresenter @AssistedInject constructor(
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) }
var enableInRoomCalls by remember { mutableStateOf(false) }
LaunchedEffect(featureFlagsService) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
}
fun handleEvents(event: MessagesEvents) {
@@ -200,6 +202,7 @@ class MessagesPresenter @AssistedInject constructor(
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls,
eventSink = { handleEvents(it) }
)
}

View File

@@ -49,5 +49,6 @@ data class MessagesState(
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean,
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -85,5 +85,6 @@ fun aMessagesState() = MessagesState(
showReinvitePrompt = false,
enableTextFormatting = true,
enableVoiceMessages = true,
enableInRoomCalls = true,
eventSink = {}
)

View File

@@ -76,9 +76,12 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
@@ -99,6 +102,7 @@ fun MessagesView(
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@@ -160,8 +164,10 @@ fun MessagesView(
MessagesViewTopBar(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
inRoomCallsEnabled = state.enableInRoomCalls,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
onJoinCallClicked = onJoinCallClicked,
)
}
},
@@ -349,8 +355,10 @@ private fun MessagesViewContent(
private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
inRoomCallsEnabled: Boolean,
modifier: Modifier = Modifier,
onRoomDetailsClicked: () -> Unit = {},
onJoinCallClicked: () -> Unit = {},
onBackPressed: () -> Unit = {},
) {
TopAppBar(
@@ -373,6 +381,13 @@ private fun MessagesViewTopBar(
)
}
},
actions = {
if (inRoomCallsEnabled) {
IconButton(onClick = onJoinCallClicked) {
Icon(CommonDrawables.ic_compound_video_call, contentDescription = null) // TODO add proper content description once we have the state
}
}
},
windowInsets = WindowInsets(0.dp)
)
}
@@ -432,5 +447,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onUserDataClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
onJoinCallClicked = {},
)
}

View File

@@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.androidutils)
implementation(projects.appconfig)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

View File

@@ -19,4 +19,5 @@ package io.element.android.features.preferences.impl.advanced
sealed interface AdvancedSettingsEvents {
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AdvancedSettingsEvents
}

View File

@@ -17,16 +17,25 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.launch
import java.net.URL
import javax.inject.Inject
class AdvancedSettingsPresenter @Inject constructor(
private val preferencesStore: PreferencesStore,
private val featureFlagService: FeatureFlagService,
) : Presenter<AdvancedSettingsState> {
@Composable
@@ -38,6 +47,14 @@ class AdvancedSettingsPresenter @Inject constructor(
val isDeveloperModeEnabled by preferencesStore
.isDeveloperModeEnabledFlow()
.collectAsState(initial = false)
val customElementCallBaseUrl by preferencesStore
.getCustomElementCallBaseUrlFlow()
.collectAsState(initial = null)
var canDisplayElementCallSettings by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canDisplayElementCallSettings = featureFlagService.isFeatureEnabled(FeatureFlags.InRoomCalls)
}
fun handleEvents(event: AdvancedSettingsEvents) {
when (event) {
@@ -47,13 +64,34 @@ class AdvancedSettingsPresenter @Inject constructor(
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
preferencesStore.setDeveloperModeEnabled(event.enabled)
}
is AdvancedSettingsEvents.SetCustomElementCallBaseUrl -> localCoroutineScope.launch {
// If the URL is either empty or the default one, we want to save 'null' to remove the custom URL
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
preferencesStore.setCustomElementCallBaseUrl(urlToSave)
}
}
}
return AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled,
customElementCallBaseUrlState = if (canDisplayElementCallSettings) {
CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl,
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
validator = ::customElementCallUrlValidator,
)
} else null,
eventSink = ::handleEvents
)
}
private fun customElementCallUrlValidator(url: String?): Boolean {
return runCatching {
if (url.isNullOrEmpty()) return@runCatching
val parsedUrl = URL(url)
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
}.isSuccess
}
}

View File

@@ -16,8 +16,15 @@
package io.element.android.features.preferences.impl.advanced
data class AdvancedSettingsState constructor(
data class AdvancedSettingsState(
val isRichTextEditorEnabled: Boolean,
val isDeveloperModeEnabled: Boolean,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState?,
val eventSink: (AdvancedSettingsEvents) -> Unit
)
data class CustomElementCallBaseUrlState(
val baseUrl: String?,
val defaultUrl: String,
val validator: (String?) -> Boolean,
)

View File

@@ -24,14 +24,17 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(),
aAdvancedSettingsState(isRichTextEditorEnabled = true),
aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(customElementCallBaseUrl = "https://call.element.io"),
)
}
fun aAdvancedSettingsState(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null,
) = AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled,
customElementCallBaseUrlState = customElementCallBaseUrl?.let { CustomElementCallBaseUrlState(it, "https://call.element.io") { true } },
eventSink = {}
)

View File

@@ -16,13 +16,16 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@@ -33,6 +36,11 @@ fun AdvancedSettingsView(
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
fun isUsingDefaultUrl(value: String?): Boolean {
val defaultUrl = state.customElementCallBaseUrlState?.defaultUrl ?: return false
return value.isNullOrEmpty() || value == defaultUrl
}
PreferencePage(
modifier = modifier,
onBackPressed = onBackPressed,
@@ -50,6 +58,23 @@ fun AdvancedSettingsView(
isChecked = state.isDeveloperModeEnabled,
onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
)
state.customElementCallBaseUrlState?.let { callUrlState ->
val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) {
stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
} else {
callUrlState.baseUrl
}
PreferenceTextField(
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
value = callUrlState.baseUrl ?: callUrlState.defaultUrl,
supportingText = supportingText,
validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !isUsingDefaultUrl(value) },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrect = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl(it)) }
)
}
}
}

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_element_call_base_url">"Custom Element Call base URL"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Set a custom base URL for Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Invalid URL, please make sure you include the protocol (http/https) and the correct address."</string>
<string name="screen_advanced_settings_developer_mode">"Developer mode"</string>
<string name="screen_advanced_settings_developer_mode_description">"Enable to have access to features and functionality for developers."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>

View File

@@ -20,6 +20,8 @@ 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.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
@@ -34,7 +36,8 @@ class AdvancedSettingsPresenterTest {
@Test
fun `present - initial state`() = runTest {
val store = InMemoryPreferencesStore()
val presenter = AdvancedSettingsPresenter(store)
val featureFlagService = FakeFeatureFlagService()
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -47,7 +50,8 @@ class AdvancedSettingsPresenterTest {
@Test
fun `present - developer mode on off`() = runTest {
val store = InMemoryPreferencesStore()
val presenter = AdvancedSettingsPresenter(store)
val featureFlagService = FakeFeatureFlagService()
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -63,7 +67,8 @@ class AdvancedSettingsPresenterTest {
@Test
fun `present - rich text editor on off`() = runTest {
val store = InMemoryPreferencesStore()
val presenter = AdvancedSettingsPresenter(store)
val featureFlagService = FakeFeatureFlagService()
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -75,4 +80,64 @@ class AdvancedSettingsPresenterTest {
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
}
}
@Test
fun `present - custom element call url state is null if the feature flag is disabled`() = runTest {
val store = InMemoryPreferencesStore()
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.InRoomCalls, false)
}
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.customElementCallBaseUrlState).isNull()
}
}
@Test
fun `present - custom element call base url`() = runTest {
val store = InMemoryPreferencesStore()
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.InRoomCalls, true)
}
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Initial state has a default `false` feature flag value, so the state will still be null
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
assertThat(initialState.customElementCallBaseUrlState?.baseUrl).isNull()
initialState.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
val updatedItem = awaitItem()
assertThat(updatedItem.customElementCallBaseUrlState?.baseUrl).isEqualTo("https://call.element.dev")
}
}
@Test
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
val store = InMemoryPreferencesStore()
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.InRoomCalls, true)
}
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Initial state has a default `false` feature flag value, so the state will still be null
skipItems(1)
val urlValidator = awaitItem().customElementCallBaseUrlState!!.validator
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
assertThat(urlValidator("test")).isFalse()
assertThat(urlValidator("http://")).isFalse()
assertThat(urlValidator("geo://test")).isFalse()
assertThat(urlValidator("https://call.element.io")).isTrue()
}
}
}

View File

@@ -21,6 +21,7 @@ import android.content.ContextWrapper
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.di.DaggerComponentOwner
inline fun <reified T : Any> Node.optionalBindings() = optionalBindings(T::class.java)
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
@@ -36,7 +37,7 @@ fun <T : Any> Context.bindings(klass: Class<T>): T {
?: error("Unable to find bindings for ${klass.name}")
}
fun <T : Any> Node.bindings(klass: Class<T>): T {
fun <T : Any> Node.optionalBindings(klass: Class<T>): T? {
// search dagger components in node hierarchy
return generateSequence(this, Node::parent)
.filterIsInstance<DaggerComponentOwner>()
@@ -44,5 +45,8 @@ fun <T : Any> Node.bindings(klass: Class<T>): T {
.flatMap { if (it is Collection<*>) it else listOf(it) }
.filterIsInstance(klass)
.firstOrNull()
?: error("Unable to find bindings for ${klass.name}")
}
fun <T : Any> Node.bindings(klass: Class<T>): T {
return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}")
}

View File

@@ -27,9 +27,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
@@ -45,6 +45,7 @@ fun ListDialog(
subtitle: String? = null,
cancelText: String = stringResource(CommonStrings.action_cancel),
submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true,
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@@ -66,6 +67,7 @@ fun ListDialog(
submitText = submitText,
onDismissRequest = onDismissRequest,
onSubmitClicked = onSubmit,
enabled = enabled,
listItems = listItems,
)
}
@@ -80,6 +82,7 @@ private fun ListDialogContent(
submitText: String,
modifier: Modifier = Modifier,
title: String? = null,
enabled: Boolean = true,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@@ -90,6 +93,7 @@ private fun ListDialogContent(
submitText = submitText,
onCancelClicked = onDismissRequest,
onSubmitClicked = onSubmitClicked,
enabled = enabled,
applyPaddingToContents = false,
) {
LazyColumn(

View File

@@ -16,10 +16,13 @@
package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@@ -29,24 +32,68 @@ import io.element.android.libraries.theme.ElementTheme
@Composable
fun TextFieldListItem(
placeholder: String,
placeholder: String?,
text: String,
onTextChanged: (String) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
OutlinedTextField(
value = text,
onValueChange = onTextChanged,
placeholder = { Text(placeholder) },
onValueChange = { onTextChanged(it) },
placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
isError = error != null,
supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
)
}
@Composable
fun TextFieldListItem(
placeholder: String?,
text: TextFieldValue,
onTextChanged: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
OutlinedTextField(
value = text,
onValueChange = { onTextChanged(it) },
placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
isError = error != null,
supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
)
}
@@ -74,3 +121,15 @@ internal fun TextFieldListItemPreview() {
)
}
}
@Preview("Text field List item - textfieldvalue", group = PreviewGroup.ListItems)
@Composable
internal fun TextFieldListItemTextFieldValuePreview() {
ElementThemedPreview {
TextFieldListItem(
placeholder = "Placeholder",
text = TextFieldValue("Text field value"),
onTextChanged = {},
)
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun PreferenceTextField(
headline: String,
onChange: (String?) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
value: String? = null,
supportingText: String? = null,
displayValue: (String?) -> Boolean = { !it.isNullOrBlank() },
trailingContent: ListItemContent? = null,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
style: ListItemStyle = ListItemStyle.Default,
) {
var displayTextFieldDialog by rememberSaveable { mutableStateOf(false) }
val valueToDisplay = if (displayValue(value)) { value } else supportingText
ListItem(
modifier = modifier,
headlineContent = { Text(headline) },
supportingContent = valueToDisplay?.let { @Composable { Text(it) } },
trailingContent = trailingContent,
style = style,
enabled = enabled,
onClick = { displayTextFieldDialog = true }
)
if (displayTextFieldDialog) {
TextFieldDialog(
title = headline,
onSubmit = {
onChange(it.takeIf { it.isNotBlank() })
displayTextFieldDialog = false
},
onDismissRequest = { displayTextFieldDialog = false },
placeholder = placeholder.orEmpty(),
value = value.orEmpty(),
validation = validation,
onValidationErrorMessage = onValidationErrorMessage,
keyboardOptions = keyboardOptions,
)
}
}
@Composable
private fun TextFieldDialog(
title: String,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
value: String?,
placeholder: String?,
modifier: Modifier = Modifier,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
}
var error by rememberSaveable { mutableStateOf<String?>(null) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
onSubmit = { onSubmit(textFieldContents.text) },
onDismissRequest = onDismissRequest,
enabled = canSubmit,
modifier = modifier,
) {
item {
TextFieldListItem(
placeholder = placeholder.orEmpty(),
text = textFieldContents,
onTextChanged = {
error = if (!validation(it.text)) onValidationErrorMessage else null
textFieldContents = it
},
error = error,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onAny = {
if (validation(textFieldContents.text)) {
onSubmit(textFieldContents.text)
}
}),
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
}
}
if (autoSelectOnDisplay) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -96,6 +96,7 @@ internal fun SimpleAlertDialogContent(
thirdButtonText: String? = null,
onThirdButtonClicked: () -> Unit = {},
applyPaddingToContents: Boolean = true,
enabled: Boolean = true,
icon: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
@@ -122,6 +123,7 @@ internal fun SimpleAlertDialogContent(
if (submitText != null) {
Button(
text = submitText,
enabled = enabled,
size = ButtonSize.Medium,
onClick = onSubmitClicked,
)

View File

@@ -55,4 +55,10 @@ enum class FeatureFlags(
description = "Allow user to lock/unlock the app with a pin code or biometrics",
defaultValue = false,
),
InRoomCalls(
key = "feature.elementcall",
title = "Element call in rooms",
description = "Allow user to start or join a call in a room",
defaultValue = false,
)
}

View File

@@ -37,6 +37,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.NotificationSettings -> true
FeatureFlags.VoiceMessages -> false
FeatureFlags.PinUnlock -> false
FeatureFlags.InRoomCalls -> false
}
} else {
false

View File

@@ -34,6 +34,7 @@ anvil {
}
dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(projects.libraries.core)

View File

@@ -17,7 +17,7 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
import io.element.android.appconfig.MatrixConfiguration
/**
* Mapping of an input URI to a matrix.to compliant URI.

View File

@@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.api.permalink
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId

View File

@@ -30,6 +30,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
import java.io.File
@@ -192,5 +194,27 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
/**
* Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters.
* @param widgetSettings The widget settings to use.
* @param clientId The client id to use. It should be unique per app install.
* @param languageTag The language tag to use. If null, the default language will be used.
* @param theme The theme to use. If null, the default theme will be used.
* @return The resulting url, or a failure.
*/
suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String? = null,
theme: String? = null,
): Result<String>
/**
* Get a [MatrixWidgetDriver] for the provided [widgetSettings].
* @param widgetSettings The widget settings to use.
* @return The resulting [MatrixWidgetDriver], or a failure.
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
override fun close() = destroy()
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import java.util.UUID
interface CallWidgetSettingsProvider {
fun provide(
baseUrl: String,
widgetId: String = UUID.randomUUID().toString()
): MatrixWidgetSettings
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import kotlinx.coroutines.flow.Flow
interface MatrixWidgetDriver : AutoCloseable {
val id: String
val incomingMessages: Flow<String>
suspend fun run()
suspend fun send(message: String)
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class MatrixWidgetSettings(
val id: String,
val initAfterContentLoad: Boolean,
val rawUrl: String,
) : Parcelable {
companion object
}

View File

@@ -40,6 +40,8 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
@@ -48,6 +50,8 @@ import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CancellationException
@@ -65,6 +69,8 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.WidgetPermissions
import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
@@ -478,6 +484,27 @@ class RustMatrixRoom(
)
}
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String?,
theme: String?,
) = runCatching {
widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme)
}
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = runCatching {
RustWidgetDriver(
widgetSettings = widgetSettings,
room = innerRoom,
widgetPermissionsProvider = object : WidgetPermissionsProvider {
override fun acquirePermissions(permissions: WidgetPermissions): WidgetPermissions {
return permissions
}
},
)
}
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching {
MediaUploadHandlerImpl(files, handle())

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions
import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettingsProvider {
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl,
widgetId = widgetId,
parentUrl = null,
hideHeader = null,
preload = null,
fontScale = null,
appPrompt = false,
skipLobby = true,
confineToRoom = true,
fonts = null,
analyticsId = null
)
val rustWidgetSettings = newVirtualElementCallWidget(options)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import org.matrix.rustcomponents.sdk.ClientProperties
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.WidgetSettings
import org.matrix.rustcomponents.sdk.generateWebviewUrl
fun MatrixWidgetSettings.toRustWidgetSettings() = WidgetSettings(
id = this.id,
initAfterContentLoad = this.initAfterContentLoad,
rawUrl = this.rawUrl,
)
fun MatrixWidgetSettings.Companion.fromRustWidgetSettings(widgetSettings: WidgetSettings) = MatrixWidgetSettings(
id = widgetSettings.id,
initAfterContentLoad = widgetSettings.initAfterContentLoad,
rawUrl = widgetSettings.rawUrl,
)
suspend fun MatrixWidgetSettings.generateWidgetWebViewUrl(
room: Room,
clientId: String,
languageTag: String? = null,
theme: String? = null
) = generateWebviewUrl(
widgetSettings = this.toRustWidgetSettings(),
room = room,
props = ClientProperties(
clientId = clientId,
languageTag = languageTag,
theme = theme,
)
)

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider
import org.matrix.rustcomponents.sdk.makeWidgetDriver
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.coroutineContext
class RustWidgetDriver(
widgetSettings: MatrixWidgetSettings,
private val room: Room,
private val widgetPermissionsProvider: WidgetPermissionsProvider,
): MatrixWidgetDriver {
override val incomingMessages = MutableSharedFlow<String>()
private val driverAndHandle = makeWidgetDriver(widgetSettings.toRustWidgetSettings())
private var receiveMessageJob: Job? = null
private var isRunning = AtomicBoolean(false)
override val id: String = widgetSettings.id
override suspend fun run() {
// Don't run the driver if it's already running
if (!isRunning.compareAndSet(false, true)) {
return
}
val coroutineScope = CoroutineScope(coroutineContext)
coroutineScope.launch {
// This call will suspend the coroutine while the driver is running, so it needs to be launched separately
driverAndHandle.driver.run(room, widgetPermissionsProvider)
}
receiveMessageJob = coroutineScope.launch(Dispatchers.IO) {
try {
while (isActive) {
driverAndHandle.handle.recv()?.let { incomingMessages.emit(it) }
}
} finally {
driverAndHandle.handle.close()
}
}
}
override suspend fun send(message: String) {
driverAndHandle.handle.send(message)
}
override fun close() {
receiveMessageJob?.cancel()
driverAndHandle.driver.close()
}
}

View File

@@ -208,8 +208,12 @@ class FakeMatrixClient(
findDmResult = result
}
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) {
getRoomResults[roomId] = result
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom?) {
if (result == null) {
getRoomResults.remove(roomId)
} else {
getRoomResults[roomId] = result
}
}
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
class FakeMatrixClientProvider(
private val getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) }
) : MatrixClientProvider {
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId)
}

View File

@@ -36,11 +36,14 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
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.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -92,6 +95,8 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
val editMessageCalls = mutableListOf<Pair<String, String?>>()
var sendMediaCount = 0
@@ -368,6 +373,15 @@ class FakeMatrixRoom(
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String?,
theme: String?,
): Result<String> = generateWidgetWebViewUrlResult
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
}
@@ -475,6 +489,14 @@ class FakeMatrixRoom(
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
progressCallbackValues = values
}
fun givenGenerateWidgetWebViewUrlResult(result: Result<String>) {
generateWidgetWebViewUrlResult = result
}
fun givenGetWidgetDriverResult(result: Result<MatrixWidgetDriver>) {
getWidgetDriverResult = result
}
}
data class SendLocationInvocation(

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.widget
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
class FakeCallWidgetSettingsProvider(
private val provideFn: (String, String) -> MatrixWidgetSettings = { _, _ -> MatrixWidgetSettings("id", true, "url") }
) : CallWidgetSettingsProvider {
val providedBaseUrls = mutableListOf<String>()
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
providedBaseUrls += baseUrl
return provideFn(baseUrl, widgetId)
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.UUID
class FakeWidgetDriver(
override val id: String = UUID.randomUUID().toString(),
) : MatrixWidgetDriver {
private val _sentMessages = mutableListOf<String>()
val sentMessages: List<String> = _sentMessages
var runCalledCount = 0
private set
var closeCalledCount = 0
private set
override val incomingMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
override suspend fun run() {
runCalledCount++
}
override suspend fun send(message: String) {
_sentMessages.add(message)
}
override fun close() {
closeCalledCount++
}
fun givenIncomingMessage(message: String) {
incomingMessages.tryEmit(message)
}
}

View File

@@ -25,5 +25,8 @@ interface PreferencesStore {
suspend fun setDeveloperModeEnabled(enabled: Boolean)
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
suspend fun setCustomElementCallBaseUrl(string: String?)
fun getCustomElementCallBaseUrlFlow(): Flow<String?>
suspend fun reset()
}

View File

@@ -21,6 +21,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.store.PreferencesStore
@@ -37,6 +38,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
@ContributesBinding(AppScope::class)
class DefaultPreferencesStore @Inject constructor(
@@ -71,6 +73,22 @@ class DefaultPreferencesStore @Inject constructor(
}
}
override suspend fun setCustomElementCallBaseUrl(string: String?) {
store.edit { prefs ->
if (string != null) {
prefs[customElementCallBaseUrlKey] = string
} else {
prefs.remove(customElementCallBaseUrlKey)
}
}
}
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
return store.data.map { prefs ->
prefs[customElementCallBaseUrlKey]
}
}
override suspend fun reset() {
store.edit { it.clear() }
}

View File

@@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryPreferencesStore(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null,
) : PreferencesStore {
private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private var _customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
_isRichTextEditorEnabled.value = enabled
@@ -43,6 +45,14 @@ class InMemoryPreferencesStore(
return _isDeveloperModeEnabled
}
override suspend fun setCustomElementCallBaseUrl(string: String?) {
_customElementCallBaseUrl.tryEmit(string)
}
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
return _customElementCallBaseUrl
}
override suspend fun reset() {
// No op
}

View File

@@ -131,7 +131,6 @@
<string name="common_room_name_placeholder">"např. název vašeho projektu"</string>
<string name="common_search_for_someone">"Hledat někoho"</string>
<string name="common_search_results">"Výsledky hledání"</string>
<string name="common_secure_backup">"Zabezpečená záloha"</string>
<string name="common_security">"Zabezpečení"</string>
<string name="common_sending">"Odesílání…"</string>
<string name="common_server_not_supported">"Server není podporován"</string>
@@ -208,9 +207,9 @@
<string name="screen_notification_settings_additional_settings_section_title">"Další nastavení"</string>
<string name="screen_notification_settings_calls_label">"Halsové a video hovory"</string>
<string name="screen_notification_settings_configuration_mismatch">"Neshoda konfigurace"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
<string name="screen_notification_settings_configuration_mismatch_description">"Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
Pokud budete pokračovat, některá nastavení se mohou změnit."</string>
<string name="screen_notification_settings_direct_chats">"Přímé zprávy"</string>

View File

@@ -126,7 +126,6 @@
<string name="common_room_name_placeholder">"например, название вашего проекта"</string>
<string name="common_search_for_someone">"Поиск человека"</string>
<string name="common_search_results">"Результаты поиска"</string>
<string name="common_secure_backup">"Безопасное резервное копирование"</string>
<string name="common_security">"Безопасность"</string>
<string name="common_sending">"Отправка…"</string>
<string name="common_server_not_supported">"Сервер не поддерживается"</string>

View File

@@ -131,7 +131,6 @@
<string name="common_room_name_placeholder">"napr. názov vášho projektu"</string>
<string name="common_search_for_someone">"Vyhľadať niekoho"</string>
<string name="common_search_results">"Výsledky hľadania"</string>
<string name="common_secure_backup">"Bezpečné zálohovanie"</string>
<string name="common_security">"Bezpečnosť"</string>
<string name="common_sending">"Odosiela sa…"</string>
<string name="common_server_not_supported">"Server nie je podporovaný"</string>

View File

@@ -69,6 +69,8 @@
<string name="action_share">"Share"</string>
<string name="action_share_link">"Share link"</string>
<string name="action_sign_in_again">"Sign in again"</string>
<string name="action_signout">"Sign out"</string>
<string name="action_signout_anyway">"Sign out anyway"</string>
<string name="action_skip">"Skip"</string>
<string name="action_start">"Start"</string>
<string name="action_start_chat">"Start chat"</string>
@@ -84,6 +86,7 @@
<string name="common_analytics">"Analytics"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Bubbles"</string>
<string name="common_chat_backup">"Chat backup"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string>
<string name="common_current_user_left_room">"Left room"</string>
@@ -122,6 +125,7 @@
<string name="common_privacy_policy">"Privacy policy"</string>
<string name="common_reaction">"Reaction"</string>
<string name="common_reactions">"Reactions"</string>
<string name="common_recovery_key">"Recovery key"</string>
<string name="common_refreshing">"Refreshing…"</string>
<string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_bug">"Report a bug"</string>
@@ -131,7 +135,6 @@
<string name="common_room_name_placeholder">"e.g. your project name"</string>
<string name="common_search_for_someone">"Search for someone"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_secure_backup">"Secure backup"</string>
<string name="common_security">"Security"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string>
@@ -200,6 +203,22 @@
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
<string name="screen_chat_backup_key_backup_description">"Backup ensures that you don\'t lose your message history."</string>
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Confirm recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$@ everywhere."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string>
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$@ everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
@@ -228,6 +247,27 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</string>
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string>
<string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string>
<string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string>
<string name="screen_recovery_key_save_title">"Save your recovery key"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
<string name="screen_recovery_key_setup_title">"Set up recovery"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>

View File

@@ -52,6 +52,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "ElementX"
include(":app")
include(":appnav")
include(":appconfig")
include(":tests:konsist")
include(":tests:uitests")
include(":tests:testutils")

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