Merge branch 'develop' into feature/fga/pin_auth_ui

This commit is contained in:
ganfra 2023-10-20 14:54:22 +02:00
commit 7c509ab460
135 changed files with 2870 additions and 242 deletions

View file

@ -8,8 +8,6 @@ appId: ${APP_ID}
# Back from timeline
- back
- assertVisible: "MyR"
# Close keyboard
- hideKeyboard
# Back from search
- back
- runFlow: ../assertions/assertHomeDisplayed.yaml

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

@ -14,8 +14,8 @@
* limitations under the License.
*/
package io.element.android.services.toolbox.api.appname
package io.element.android.appconfig
interface AppNameProvider {
fun getAppName(): String
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

@ -253,7 +253,6 @@ koverMerged {
// Temporary until we have actually something to test.
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter"
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
excludes += "io.element.android.features.lockscreen.impl.create.CreatePinPresenter"
}
bound {
minValue = 85

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

@ -47,6 +47,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)

View file

@ -17,5 +17,6 @@
package io.element.android.features.lockscreen.impl.create
sealed interface CreatePinEvents {
object MyEvent : CreatePinEvents
data class OnPinEntryChanged(val entryAsText: String) : CreatePinEvents
data object ClearFailure : CreatePinEvents
}

View file

@ -38,6 +38,7 @@ class CreatePinNode @AssistedInject constructor(
val state = presenter.present()
CreatePinView(
state = state,
onBackClicked = { },
modifier = modifier
)
}

View file

@ -17,21 +17,86 @@
package io.element.android.features.lockscreen.impl.create
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject
class CreatePinPresenter @Inject constructor() : Presenter<CreatePinState> {
private const val PIN_SIZE = 4
class CreatePinPresenter @Inject constructor(
private val pinValidator: PinValidator,
private val buildMeta: BuildMeta,
) : Presenter<CreatePinState> {
@Composable
override fun present(): CreatePinState {
var choosePinEntry by remember {
mutableStateOf(PinEntry.empty(PIN_SIZE))
}
var confirmPinEntry by remember {
mutableStateOf(PinEntry.empty(PIN_SIZE))
}
var isConfirmationStep by remember {
mutableStateOf(false)
}
var createPinFailure by remember {
mutableStateOf<CreatePinFailure?>(null)
}
fun handleEvents(event: CreatePinEvents) {
when (event) {
CreatePinEvents.MyEvent -> Unit
is CreatePinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
if (confirmPinEntry.isPinComplete()) {
if (confirmPinEntry == choosePinEntry) {
//TODO save in db and navigate to next screen
} else {
createPinFailure = CreatePinFailure.PinsDontMatch
}
}
} else {
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
if (choosePinEntry.isPinComplete()) {
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
is PinValidator.Result.Invalid -> {
createPinFailure = pinValidationResult.failure
}
PinValidator.Result.Valid -> isConfirmationStep = true
}
}
}
}
CreatePinEvents.ClearFailure -> {
when (createPinFailure) {
is CreatePinFailure.PinsDontMatch -> {
choosePinEntry = PinEntry.empty(PIN_SIZE)
confirmPinEntry = PinEntry.empty(PIN_SIZE)
}
is CreatePinFailure.PinBlacklisted -> {
choosePinEntry = PinEntry.empty(PIN_SIZE)
}
null -> Unit
}
isConfirmationStep = false
createPinFailure = null
}
}
}
return CreatePinState(
choosePinEntry = choosePinEntry,
confirmPinEntry = confirmPinEntry,
isConfirmationStep = isConfirmationStep,
createPinFailure = createPinFailure,
appName = buildMeta.applicationName,
eventSink = ::handleEvents
)
}

View file

@ -16,6 +16,21 @@
package io.element.android.features.lockscreen.impl.create
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
data class CreatePinState(
val choosePinEntry: PinEntry,
val confirmPinEntry: PinEntry,
val isConfirmationStep: Boolean,
val createPinFailure: CreatePinFailure?,
val appName: String,
val eventSink: (CreatePinEvents) -> Unit
)
) {
val pinSize = choosePinEntry.size
val activePinEntry = if (isConfirmationStep) {
confirmPinEntry
} else {
choosePinEntry
}
}

View file

@ -17,15 +17,45 @@
package io.element.android.features.lockscreen.impl.create
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> {
override val values: Sequence<CreatePinState>
get() = sequenceOf(
aCreatePinState(),
// Add other states here
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("12")
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
isConfirmationStep = true,
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
confirmPinEntry = PinEntry.empty(4).fillWith("1788"),
isConfirmationStep = true,
creationFailure = CreatePinFailure.PinsDontMatch
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1111"),
creationFailure = CreatePinFailure.PinBlacklisted
),
)
}
fun aCreatePinState() = CreatePinState(
fun aCreatePinState(
choosePinEntry: PinEntry = PinEntry.empty(4),
confirmPinEntry: PinEntry = PinEntry.empty(4),
isConfirmationStep: Boolean = false,
creationFailure: CreatePinFailure? = null,
) = CreatePinState(
choosePinEntry = choosePinEntry,
confirmPinEntry = confirmPinEntry,
isConfirmationStep = isConfirmationStep,
createPinFailure = creationFailure,
appName = "Element",
eventSink = {}
)

View file

@ -14,39 +14,222 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.lockscreen.impl.create
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.create.model.PinDigit
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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 timber.log.Timber
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.theme.ElementTheme
@Composable
fun CreatePinView(
state: CreatePinState,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Timber.d("CreatePinView: $state")
Box(modifier, contentAlignment = Alignment.Center) {
Text(
"CreatePin feature view",
color = MaterialTheme.colorScheme.primary,
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClicked)
},
title = {}
)
},
content = { padding ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(state = scrollState)
.padding(vertical = 16.dp, horizontal = 20.dp),
) {
CreatePinHeader(state.isConfirmationStep, state.appName)
CreatePinContent(state)
}
}
)
}
@Composable
private fun CreatePinHeader(
isValidationStep: Boolean,
appName: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconTitleSubtitleMolecule(
title = if (isValidationStep) {
stringResource(id = R.string.screen_app_lock_setup_confirm_pin)
} else {
stringResource(id = R.string.screen_app_lock_setup_choose_pin)
},
subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName),
iconImageVector = Icons.Filled.Lock,
)
}
}
@Composable
private fun CreatePinContent(
state: CreatePinState,
modifier: Modifier = Modifier,
) {
PinEntryTextField(
state.activePinEntry,
onValueChange = {
state.eventSink(CreatePinEvents.OnPinEntryChanged(it))
},
modifier = modifier
.padding(top = 36.dp)
.fillMaxWidth()
)
if (state.createPinFailure != null) {
ErrorDialog(
modifier = modifier,
title = state.createPinFailure.title(),
content = state.createPinFailure.content(),
onDismiss = {
state.eventSink(CreatePinEvents.ClearFailure)
}
)
}
}
@Composable
private fun CreatePinFailure.content(): String {
return when (this) {
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
}
}
@Composable
private fun CreatePinFailure.title(): String {
return when (this) {
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
}
}
@Composable
private fun PinEntryTextField(
pinEntry: PinEntry,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
modifier = modifier,
value = TextFieldValue(pinEntry.toText()),
onValueChange = {
onValueChange(it.text)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
decorationBox = {
PinEntryRow(pinEntry = pinEntry)
}
)
}
@Composable
private fun PinEntryRow(
pinEntry: PinEntry,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit)
}
}
}
@Composable
private fun PinDigitView(
digit: PinDigit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
}
}
Box(
modifier = modifier
.size(48.dp)
.then(appearanceModifier),
contentAlignment = Alignment.Center,
) {
if (digit is PinDigit.Filled) {
Text(
text = digit.toText(),
style = ElementTheme.typography.fontHeadingMdBold
)
}
}
}
@Composable
@PreviewsDayNight
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
ElementPreview {
CreatePinView(
state = state,
onBackClicked = {},
)
}
}

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.features.lockscreen.impl.create.model
sealed interface PinDigit {
data object Empty : PinDigit
data class Filled(val value: Char) : PinDigit
fun toText(): String {
return when (this) {
is Empty -> ""
is Filled -> value.toString()
}
}
}

View file

@ -0,0 +1,66 @@
/*
* 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.lockscreen.impl.create.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class PinEntry(
val digits: ImmutableList<PinDigit>,
) {
companion object {
fun empty(size: Int): PinEntry {
val digits = List(size) { PinDigit.Empty }
return PinEntry(
digits = digits.toPersistentList()
)
}
}
val size = digits.size
/**
* Fill the first digits with the given text.
* Can't be more than the size of the PinEntry
* Keep the Empty digits at the end
* @return the new PinEntry
*/
fun fillWith(text: String): PinEntry {
val newDigits = digits.toMutableList()
text.forEachIndexed { index, char ->
if (index < size) {
newDigits[index] = PinDigit.Filled(char)
}
}
return copy(digits = newDigits.toPersistentList())
}
fun clear(): PinEntry {
return fillWith("")
}
fun isPinComplete(): Boolean {
return digits.all { it is PinDigit.Filled }
}
fun toText(): String {
return digits.joinToString("") {
it.toText()
}
}
}

View file

@ -0,0 +1,22 @@
/*
* 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.lockscreen.impl.create.validation
sealed interface CreatePinFailure {
data object PinBlacklisted : CreatePinFailure
data object PinsDontMatch : CreatePinFailure
}

View file

@ -0,0 +1,44 @@
/*
* 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.lockscreen.impl.create.validation
import androidx.annotation.VisibleForTesting
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import javax.inject.Inject
class PinValidator @Inject constructor() {
companion object {
@VisibleForTesting
val BLACKLIST = listOf("0000", "1234")
}
sealed interface Result {
data object Valid : Result
data class Invalid(val failure: CreatePinFailure) : Result
}
fun isPinValid(pinEntry: PinEntry): Result {
val pinAsText = pinEntry.toText()
val isBlacklisted = BLACKLIST.any { it == pinAsText }
return if (isBlacklisted) {
Result.Invalid(CreatePinFailure.PinBlacklisted)
} else {
Result.Valid
}
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Wrong PIN. You have %1$d more chance"</item>
<item quantity="other">"Wrong PIN. You have %1$d more chances"</item>
</plurals>
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>
<string name="screen_app_lock_settings_remove_pin">"Remove PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Are you sure you want to remove PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Remove PIN?"</string>
<string name="screen_app_lock_setup_choose_pin">"Choose PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Confirm PIN"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"You cannot choose this as your PIN code for security reasons"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Choose a different PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Lock %1$s to add extra security to your chats.
Choose something memorable. If you forget this PIN, you will be logged out of the app."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Please enter the same PIN twice"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string>
<string name="screen_app_lock_signout_alert_message">"Youll need to re-login and create a new PIN to proceed"</string>
<string name="screen_app_lock_signout_alert_title">"You are being signed out"</string>
<string name="screen_app_lock_subtitle">"You have 3 attempts to unlock"</string>
</resources>

View file

@ -0,0 +1,114 @@
/*
* 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.lockscreen.impl.create
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.lockscreen.impl.create.model.PinDigit
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CreatePinPresenterTest {
private val blacklistedPin = PinValidator.BLACKLIST.first()
private val halfCompletePin = "12"
private val completePin = "1235"
private val mismatchedPin = "1236"
@Test
fun `present - complete flow`() = runTest {
val presenter = createCreatePinPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
state.choosePinEntry.assertEmpty()
state.confirmPinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(CreatePinEvents.OnPinEntryChanged(halfCompletePin))
}
awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(CreatePinEvents.OnPinEntryChanged(blacklistedPin))
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin)
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinBlacklisted)
state.eventSink(CreatePinEvents.ClearFailure)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(CreatePinEvents.OnPinEntryChanged(mismatchedPin))
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(mismatchedPin)
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinsDontMatch)
state.eventSink(CreatePinEvents.ClearFailure)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse()
assertThat(state.createPinFailure).isNull()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
}
awaitItem().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(completePin)
}
}
}
private fun PinEntry.assertText(text: String) {
assertThat(toText()).isEqualTo(text)
}
private fun PinEntry.assertEmpty() {
val isEmpty = digits.all { it is PinDigit.Empty }
assertThat(isEmpty).isTrue()
}
private fun createCreatePinPresenter(): CreatePinPresenter {
return CreatePinPresenter(PinValidator(), aBuildMeta())
}
}

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

@ -38,7 +38,7 @@ coil = "2.4.0"
datetime = "0.4.1"
serialization_json = "1.6.0"
showkase = "1.0.0-beta18"
jsoup = "1.16.1"
jsoup = "1.16.2"
appyx = "1.4.0"
dependencycheck = "8.4.0"
dependencyanalysis = "1.25.0"
@ -70,7 +70,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
# https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:32.3.1"
google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0"
# AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" }

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

@ -98,6 +98,16 @@ val SemanticColors.bgSubtleTertiary
val SemanticColors.temporaryColorBgSpecial
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
// This color is not present in Semantic color, so put hard-coded value for now
val SemanticColors.pinDigitBg
get() = if (isLight) {
// We want LightDesignTokens.colorGray300
Color(0xFFF0F2F5)
} else {
// We want DarkDesignTokens.colorGray400
Color(0xFF26282D)
}
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {

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

@ -16,4 +16,7 @@
package io.element.android.libraries.matrix.api.core
/**
* The [UserId] of the currently logged in user.
*/
typealias SessionId = UserId

View file

@ -19,6 +19,11 @@ package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable
/**
* A [String] holding a valid Matrix user ID.
*
* https://spec.matrix.org/v1.8/appendices/#user-identifiers
*/
@JvmInline
value class UserId(val value: String) : Serializable {

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

@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@ -28,7 +29,6 @@ import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyReque
import io.element.android.libraries.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.services.toolbox.api.appname.AppNameProvider
import timber.log.Timber
import javax.inject.Inject
@ -39,7 +39,7 @@ private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag)
@ContributesBinding(AppScope::class)
class PushersManager @Inject constructor(
// private val localeProvider: LocaleProvider,
private val appNameProvider: AppNameProvider,
private val buildMeta: BuildMeta,
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
private val pushClientSecret: PushClientSecret,
@ -88,7 +88,7 @@ class PushersManager @Inject constructor(
appId = PushConfig.pusher_app_id,
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/,
lang = "en", // TODO localeProvider.current().language,
appDisplayName = appNameProvider.getAppName(),
appDisplayName = buildMeta.applicationName,
deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(),
url = gateway,
defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId))

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>

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