diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml index 6c31acd4db..b4f44c8b27 100644 --- a/.maestro/tests/roomList/searchRoomList.yaml +++ b/.maestro/tests/roomList/searchRoomList.yaml @@ -8,8 +8,6 @@ appId: ${APP_ID} # Back from timeline - back - assertVisible: "MyR" -# Close keyboard -- hideKeyboard # Back from search - back - runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts new file mode 100644 index 0000000000..3c03739553 --- /dev/null +++ b/appconfig/build.gradle.kts @@ -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 +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt new file mode 100644 index 0000000000..186b84f8f0 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt @@ -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" +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt similarity index 83% rename from services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt index 414c9b632e..bbd9f62689 100644 --- a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt @@ -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" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt similarity index 93% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt index ddce776627..e4d6ee7ca2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt @@ -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/#/" diff --git a/build.gradle.kts b/build.gradle.kts index 487776d948..e14ad71981 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts index 69046e33b4..c59f1ea855 100644 --- a/features/call/build.gradle.kts +++ b/features/call/build.gradle.kts @@ -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) } diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml index 877b7fb0a8..c7db9cc38f 100644 --- a/features/call/src/main/AndroidManifest.xml +++ b/features/call/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ { + + @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.Uninitialized) } + val callWidgetDriver = remember { mutableStateOf(null) } + val messageInterceptor = remember { mutableStateOf(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>, + callWidgetDriver: MutableState, + ) = 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() + } + +} + diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt new file mode 100644 index 0000000000..d9716251fc --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt @@ -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, + val userAgent: String, + val isInWidgetMode: Boolean, + val eventSink: (CallScreeEvents) -> Unit, +) diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt similarity index 52% rename from features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt rename to features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt index 0f5b90cbc8..9ac06a63b7 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt @@ -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) -> Unit +interface CallScreenNavigator { + fun close() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun CallScreenView( - url: String?, - userAgent: String, + state: CallScreenState, requestPermissions: (Array, 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, 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 = { }, ) } } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt similarity index 72% rename from features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt rename to features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt index 481634a4ca..651a2176f3 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt @@ -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(null) + private val webViewTarget = mutableStateOf(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> { diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt similarity index 98% rename from features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt rename to features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt index b903b437d8..0814216745 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt @@ -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 diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt new file mode 100644 index 0000000000..b65298854d --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt @@ -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> +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt new file mode 100644 index 0000000000..f3cb9cbcd5 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt @@ -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> = 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 + } +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt new file mode 100644 index 0000000000..bdb6ee48f5 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt @@ -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(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) } + } +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt new file mode 100644 index 0000000000..fa5c3bea67 --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt @@ -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 + fun sendMessage(message: String) +} diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt new file mode 100644 index 0000000000..5ed9db028c --- /dev/null +++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt @@ -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 { + return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) } + } + + fun serialize(message: WidgetMessage): String { + return coder.encodeToString(WidgetMessage.serializer(), message) + } +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt index f82e31c068..55b5f16771 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt @@ -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 { diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt new file mode 100644 index 0000000000..c318b1dfaa --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -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, + ) + } +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt new file mode 100644 index 0000000000..498503cb15 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt @@ -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 + } + } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt similarity index 99% rename from features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt rename to features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt index ae82767f45..eb8e756182 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt @@ -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 diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt new file mode 100644 index 0000000000..f7f17d794d --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -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, + ) +} diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt new file mode 100644 index 0000000000..69ae340648 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -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> { + getWidgetCalled = true + return Result.success(widgetDriver to url) + } + } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt new file mode 100644 index 0000000000..6e36dfff81 --- /dev/null +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt @@ -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() + + override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 1) + + override fun sendMessage(message: String) { + sentMessages += message + } + + fun givenInterceptedMessage(message: String) { + interceptedMessages.tryEmit(message) + } + } diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index af63538db5..028d8bee3c 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -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) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinEvents.kt index deb3095e69..78ce529325 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinEvents.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinEvents.kt @@ -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 } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinNode.kt index 3689c0cc76..331d6ada84 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinNode.kt @@ -38,6 +38,7 @@ class CreatePinNode @AssistedInject constructor( val state = presenter.present() CreatePinView( state = state, + onBackClicked = { }, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenter.kt index 08ba24e074..957594c0f7 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenter.kt @@ -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 { +private const val PIN_SIZE = 4 + +class CreatePinPresenter @Inject constructor( + private val pinValidator: PinValidator, + private val buildMeta: BuildMeta, +) : Presenter { @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(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 ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinState.kt index 67311639ad..020076a2ab 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinState.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinState.kt @@ -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 + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt index a918b5193e..c9dcce018d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt @@ -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 { override val values: Sequence 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 = {} ) + diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinView.kt index 120c0b6079..063d65f41f 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinView.kt @@ -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 = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinDigit.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinDigit.kt new file mode 100644 index 0000000000..741a61cafe --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinDigit.kt @@ -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() + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinEntry.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinEntry.kt new file mode 100644 index 0000000000..a97315f2e8 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinEntry.kt @@ -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, +) { + + 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() + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/CreatePinFailure.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/CreatePinFailure.kt new file mode 100644 index 0000000000..8c0cb78921 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/CreatePinFailure.kt @@ -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 +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/PinValidator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/PinValidator.kt new file mode 100644 index 0000000000..7353ec47d0 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/PinValidator.kt @@ -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 + } + } +} diff --git a/features/lockscreen/impl/src/main/res/values/localazy.xml b/features/lockscreen/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..6b12eac427 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values/localazy.xml @@ -0,0 +1,25 @@ + + + + "Wrong PIN. You have %1$d more chance" + "Wrong PIN. You have %1$d more chances" + + "Forgot PIN?" + "Change PIN code" + "Allow biometric unlock" + "Remove PIN" + "Are you sure you want to remove PIN?" + "Remove PIN?" + "Choose PIN" + "Confirm PIN" + "You cannot choose this as your PIN code for security reasons" + "Choose a different PIN" + "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." + "Please enter the same PIN twice" + "PINs don\'t match" + "You’ll need to re-login and create a new PIN to proceed" + "You are being signed out" + "You have 3 attempts to unlock" + diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenterTest.kt new file mode 100644 index 0000000000..78536bb693 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenterTest.kt @@ -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()) + } +} diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index ae13197f05..6f4e959499 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -38,6 +38,7 @@ anvil { dependencies { implementation(projects.anvilannotations) + implementation(projects.appconfig) anvil(projects.anvilcodegen) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt index 35fd7246f2..0d3b9c5dc3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -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 { override val values: Sequence @@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider { } 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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt index 786d8aaeae..96fc115cfa 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt index 8dce1bd78e..50b24b3964 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -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 { @@ -50,7 +50,7 @@ fun aHomeserverDataList(): List { } fun aHomeserverData( - homeserverUrl: String = LoginConstants.MATRIX_ORG_URL, + homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, isWellknownValid: Boolean = true, supportSlidingSync: Boolean = true, ): HomeserverData { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 29781acff1..47dfe248b7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt index 98fd62d7b0..91c19e4052 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -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, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt index 261b02c1b8..6726105bce 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt @@ -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) } } diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml index 9ea4bb77fd..5a5c9c64ca 100644 --- a/features/logout/api/src/main/res/values/localazy.xml +++ b/features/logout/api/src/main/res/values/localazy.xml @@ -1,8 +1,12 @@ + "Please wait for this to complete before signing out." + "Your keys are still being backed up" "Are you sure you want to sign out?" "Sign out" "Signing out…" + "You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages." + "Have you saved your recovery key?" "Sign out" "Sign out" diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index e886a3aeaa..956b80949a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 21e384906e..128c531374 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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, + @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(buildContext, listOf(callback)) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index dbf7e2fbb2..50b59afbcc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 8645553f0b..6125e920b8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 0a121b50a3..3a0585f390 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -49,5 +49,6 @@ data class MessagesState( val showReinvitePrompt: Boolean, val enableTextFormatting: Boolean, val enableVoiceMessages: Boolean, + val enableInRoomCalls: Boolean, val eventSink: (MessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 3b0b87ea39..4222a0889d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -85,5 +85,6 @@ fun aMessagesState() = MessagesState( showReinvitePrompt = false, enableTextFormatting = true, enableVoiceMessages = true, + enableInRoomCalls = true, eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b79e84a2e0..5a7168e7ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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) -> 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 = {}, ) } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index a227d24b8b..4fcc69ff6b 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -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) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 37641d684c..fea42baf5f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -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 } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 5738fe43c8..6359b34d0f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -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 { @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 + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 19625b9ebc..cd56078b27 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -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, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5ab50c8a16..d3a2dee3f4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -24,14 +24,17 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider 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)) } + ) + } } } diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index b94db7a565..1ca7071436 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -1,5 +1,8 @@ + "Custom Element Call base URL" + "Set a custom base URL for Element Call." + "Invalid URL, please make sure you include the protocol (http/https) and the correct address." "Developer mode" "Enable to have access to features and functionality for developers." "Disable the rich text editor to type Markdown manually." diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 76808ee5f9..11c79657ce 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -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() + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2e91fce99..23aaa4327b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt index e4a6d7ae7d..f8b6cee0b3 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt @@ -21,6 +21,7 @@ import android.content.ContextWrapper import com.bumble.appyx.core.node.Node import io.element.android.libraries.di.DaggerComponentOwner +inline fun Node.optionalBindings() = optionalBindings(T::class.java) inline fun Node.bindings() = bindings(T::class.java) inline fun Context.bindings() = bindings(T::class.java) @@ -36,7 +37,7 @@ fun Context.bindings(klass: Class): T { ?: error("Unable to find bindings for ${klass.name}") } -fun Node.bindings(klass: Class): T { +fun Node.optionalBindings(klass: Class): T? { // search dagger components in node hierarchy return generateSequence(this, Node::parent) .filterIsInstance() @@ -44,5 +45,8 @@ fun Node.bindings(klass: Class): T { .flatMap { if (it is Collection<*>) it else listOf(it) } .filterIsInstance(klass) .firstOrNull() - ?: error("Unable to find bindings for ${klass.name}") +} + +fun Node.bindings(klass: Class): T { + return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}") } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index e82ebfe532..42cd70cbbd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -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( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index 525d5e76b1..93268e25d7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -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 = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt new file mode 100644 index 0000000000..648ea97434 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -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(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() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt index b347402b41..e85abb396b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -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 { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt index abe744bdbc..2eb290dd4e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -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, ) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 121cf26271..e078b634d1 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -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, + ) } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt index 48f159de83..87a797d13a 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt @@ -37,6 +37,7 @@ class StaticFeatureFlagProvider @Inject constructor() : FeatureFlags.NotificationSettings -> true FeatureFlags.VoiceMessages -> false FeatureFlags.PinUnlock -> false + FeatureFlags.InRoomCalls -> false } } else { false diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 5a430f7db5..4a083eacec 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -34,6 +34,7 @@ anvil { } dependencies { + implementation(projects.appconfig) implementation(projects.libraries.di) implementation(libs.dagger) implementation(projects.libraries.core) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt index 6009aa5c03..7bb6e27a5e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt @@ -16,4 +16,7 @@ package io.element.android.libraries.matrix.api.core +/** + * The [UserId] of the currently logged in user. + */ typealias SessionId = UserId diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt index e153834501..e72af8596a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt @@ -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 { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt index e352dd5cfc..19e71db332 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -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. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index c79ab36a7b..2a388ae580 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 746f8cead8..4b7e4e4470 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -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 + /** + * 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 + + /** + * 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 + override fun close() = destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..f0a22d0128 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt @@ -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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt new file mode 100644 index 0000000000..675adc1ad4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt @@ -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 + + suspend fun run() + suspend fun send(message: String) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000000..022827898f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt @@ -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 +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 4dcdb6d88c..c778fa3ac5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -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 = runCatching { + RustWidgetDriver( + widgetSettings = widgetSettings, + room = innerRoom, + widgetPermissionsProvider = object : WidgetPermissionsProvider { + override fun acquirePermissions(permissions: WidgetPermissions): WidgetPermissions { + return permissions + } + }, + ) + } + private suspend fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { return runCatching { MediaUploadHandlerImpl(files, handle()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..a7f208e69d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt @@ -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) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000000..65e6c8bc84 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt @@ -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, + ) +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt new file mode 100644 index 0000000000..e385a34e0d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -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() + + 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() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 67a36f0db7..2332a37fdc 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -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) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt new file mode 100644 index 0000000000..80cdcff7ec --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt @@ -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 = { Result.success(FakeMatrixClient()) } +) : MatrixClientProvider { + override suspend fun getOrRestore(sessionId: SessionId): Result = getClient(sessionId) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 7549522c0c..c08a742391 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -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>() + private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") + private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) val editMessageCalls = mutableListOf>() var sendMediaCount = 0 @@ -368,6 +373,15 @@ class FakeMatrixRoom( progressCallback: ProgressCallback? ): Result = fakeSendMedia(progressCallback) + override suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String?, + ): Result = generateWidgetWebViewUrlResult + + override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result = getWidgetDriverResult + fun givenLeaveRoomError(throwable: Throwable?) { this.leaveRoomError = throwable } @@ -475,6 +489,14 @@ class FakeMatrixRoom( fun givenProgressCallbackValues(values: List>) { progressCallbackValues = values } + + fun givenGenerateWidgetWebViewUrlResult(result: Result) { + generateWidgetWebViewUrlResult = result + } + + fun givenGetWidgetDriverResult(result: Result) { + getWidgetDriverResult = result + } } data class SendLocationInvocation( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt new file mode 100644 index 0000000000..74cf94e4ad --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt @@ -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() + + override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings { + providedBaseUrls += baseUrl + return provideFn(baseUrl, widgetId) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt new file mode 100644 index 0000000000..f7fa2b494a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt @@ -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() + val sentMessages: List = _sentMessages + + var runCalledCount = 0 + private set + var closeCalledCount = 0 + private set + + override val incomingMessages = MutableSharedFlow(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) + } +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt index 8ad2c098f6..d62fb7e6cf 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/PreferencesStore.kt @@ -25,5 +25,8 @@ interface PreferencesStore { suspend fun setDeveloperModeEnabled(enabled: Boolean) fun isDeveloperModeEnabledFlow(): Flow + suspend fun setCustomElementCallBaseUrl(string: String?) + fun getCustomElementCallBaseUrlFlow(): Flow + suspend fun reset() } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt index 337301f23e..66a46d1ca3 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesStore.kt @@ -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 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 { + return store.data.map { prefs -> + prefs[customElementCallBaseUrlKey] + } + } + override suspend fun reset() { store.edit { it.clear() } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt index a2a9fdaa3f..6dea8910ed 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/featureflag/test/InMemoryPreferencesStore.kt @@ -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 { + return _customElementCallBaseUrl + } + override suspend fun reset() { // No op } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt index 7f4ee63004..b49f8f4299 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -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)) diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index f28095763c..7ada336f28 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -131,7 +131,6 @@ "např. název vašeho projektu" "Hledat někoho" "Výsledky hledání" - "Zabezpečená záloha" "Zabezpečení" "Odesílání…" "Server není podporován" @@ -208,9 +207,9 @@ "Další nastavení" "Halsové a video hovory" "Neshoda konfigurace" - "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. + "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." "Přímé zprávy" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 7401685482..ada42316b1 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -126,7 +126,6 @@ "например, название вашего проекта" "Поиск человека" "Результаты поиска" - "Безопасное резервное копирование" "Безопасность" "Отправка…" "Сервер не поддерживается" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index 536db8c501..c6cf324b75 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -131,7 +131,6 @@ "napr. názov vášho projektu" "Vyhľadať niekoho" "Výsledky hľadania" - "Bezpečné zálohovanie" "Bezpečnosť" "Odosiela sa…" "Server nie je podporovaný" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index b9a6460bec..2e2668c837 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -5,6 +5,7 @@ "Mentions only" "Muted" "Pause" + "PIN field" "Play" "Poll" "Ended poll" @@ -69,6 +70,8 @@ "Share" "Share link" "Sign in again" + "Sign out" + "Sign out anyway" "Skip" "Start" "Start chat" @@ -84,6 +87,7 @@ "Analytics" "Audio" "Bubbles" + "Chat backup" "Copyright" "Creating room…" "Left room" @@ -93,6 +97,7 @@ "Editing" "* %1$s %2$s" "Encryption enabled" + "Enter your PIN" "Error" "Everyone" "File" @@ -122,6 +127,7 @@ "Privacy policy" "Reaction" "Reactions" + "Recovery key" "Refreshing…" "Replying to %1$s" "Report a bug" @@ -129,9 +135,9 @@ "Rich text editor" "Room name" "e.g. your project name" + "Screen lock" "Search for someone" "Search results" - "Secure backup" "Security" "Sending…" "Server not supported" @@ -151,6 +157,7 @@ "Unable to decrypt" "Invites couldn\'t be sent to one or more users." "Unable to send invite(s)" + "Unlock" "Unmute" "Unsupported event" "Username" @@ -177,6 +184,7 @@ "%1$s could not access your location. Please try again later." "%1$s does not have permission to access your location. You can enable access in Settings." "%1$s does not have permission to access your location. Enable access below." + "%1$s does not have permission to access your microphone. Enable access to record a voice message." "Some messages have not been sent" "Sorry, an error occurred" "🔐️ Join me on %1$s" @@ -185,6 +193,10 @@ "Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite." "Are you sure that you want to leave the room?" "%1$s Android" + + "%1$d digit entered" + "%1$d digits entered" + "%1$d member" "%1$d members" @@ -200,6 +212,22 @@ "This is the beginning of this conversation." "New" "Share analytics data" + "Turn off backup" + "Turn on backup" + "Backup ensures that you don\'t lose your message history. %1$s." + "Backup" + "Change recovery key" + "Confirm recovery key" + "Your chat backup is currently out of sync." + "Set up recovery" + "Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere." + "Turn off" + "You will lose your encrypted messages if you are signed out of all devices." + "Are you sure you want to turn off backup?" + "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:" + "Not have encrypted message history on new devices" + "Lose access to your encrypted messages if you are signed out of %1$s everywhere" + "Are you sure you want to turn off backup?" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." @@ -228,6 +256,27 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work." + "Generate a new recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery key changed" + "Change recovery key?" + "Enter your recovery key to confirm access to your chat backup." + "Enter the 48 character code." + "Enter…" + "Recovery key confirmed" + "Confirm your recovery key" + "Save recovery key" + "Write down your recovery key somewhere safe or save it in a password manager." + "Tap to copy recovery key" + "Save your recovery key" + "You will not be able to access your new recovery key after this step." + "Have you saved your recovery key?" + "Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’." + "Generate your recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery setup successful" + "Set up recovery" "Check if you want to hide all current and future messages from this user" "Share location" "Share my location" diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt deleted file mode 100644 index 7a5cbd46f0..0000000000 --- a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.services.toolbox.impl.appname - -import android.content.Context -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidutils.system.getApplicationLabel -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.services.toolbox.api.appname.AppNameProvider -import timber.log.Timber -import javax.inject.Inject - -@ContributesBinding(AppScope::class) -class DefaultAppNameProvider @Inject constructor(@ApplicationContext private val context: Context) : - AppNameProvider { - - override fun getAppName(): String { - return try { - val appPackageName = context.packageName - var appName = context.getApplicationLabel(appPackageName) - - // Use appPackageName instead of appName if appName contains any non-ASCII character - if (!appName.matches("\\A\\p{ASCII}*\\z".toRegex())) { - appName = appPackageName - } - appName - } catch (e: Exception) { - Timber.e(e, "## AppNameProvider() : failed") - "ElementAndroid" - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 105befcd04..9ac2c96dde 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "ElementX" include(":app") include(":appnav") +include(":appconfig") include(":tests:konsist") include(":tests:uitests") include(":tests:testutils") diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt index 06b6b3d3ea..3e47dd63ce 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt @@ -32,6 +32,16 @@ suspend fun ReceiveTurbine.consumeItemsUntilTimeout(timeout: Durati return consumeItemsUntilPredicate(timeout) { false } } +/** + * Consume all items which are emitted sequentially. + * Use the smallest timeout possible internally to avoid wasting time. + * Same as calling skipItems(x) and then awaitItem() but without assumption on the number of items. + * @return the last item emitted. + */ +suspend fun ReceiveTurbine.awaitLastSequentialItem(): T { + return consumeItemsUntilTimeout(1.milliseconds).last() +} + /** * Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event. * The timeout is applied for each event. diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f44d866ff8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a28b7969455f17784f060291ce58b3720324baa67e9d93c2aa59f6d979268678 +size 14429 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..232a3e752a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.ui_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8a811677a50035a361f65ece4a1281346423226db6a1b8b3b8611f6b2f1d23d +size 13099 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 4e99b44510..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d0264d691ec2946cda4d5860f02079dd9f3e69ddd30a2e5c2f9c701253fd659c -size 10499 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 5301f939c3..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:20ec46c4c66a68d93c45a17eafd945536c9c137b18e66a82e83eced674708d98 -size 9732 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_0,NEXUS_5,1.0,en].png index e8dd1a28d7..c3d918b1c2 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9914a33ba23544bdfce1e21b52ad247024392730fb22b60bc9b6fa6440f004d4 -size 9216 +oid sha256:c9f1f9f900cea024cdf777867c33c1d1bbe4744a5b91cd994a03aa6cbf4f8815 +size 33186 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0c72d54e99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d25ab8d3f53a5139b265a1ddef43ef3755b539a695aa8f43615e9197bd93471d +size 33231 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..61c45c675c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13f2b0ac39e3e5448916beea6f7a7183c8a205811343d3ff5e8a21a4d2d3f641 +size 32447 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f96a3743f3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f031423cd98572a729f54e0bc4cdb50778aadd732aa93cf48d063075e9da00be +size 28636 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8a290a2303 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2818a043ea06af263b1442c6e90727509da2ce2da036d68dce89de31fa8b013f +size 35614 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_0,NEXUS_5,1.0,en].png index 157a7c52c3..c1fb8f128f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ad524a918e499fcea6fd5293358167ff52f8877cc31b778c8def01925fa662f -size 8582 +oid sha256:75145d74e0bebbad427626711d5bc86911bd89350e73c968c8ca9448e939cdbb +size 31954 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b22bfb46dd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d5037348c8b27714e54ebec53aac8a546f14b11e3195434f681aad95e416847 +size 32181 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4b0a1988c5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0a34e1c3c047806e4304791664c25cd8d7c9e7850c24b2bb17119e0d83fd5d4 +size 31492 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0c1ab3c32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8c09be0dbf07d8593fd2f93881febdf3494bd439cb6f62adb78beee34b6c7e7 +size 25357 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..05e4d69148 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68a54676b1f82425df6db8cb56b161950584ce9d433fc59630a16ac6288fc7d8 +size 31204 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png index 72056b5ed9..6f1f867a7c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88bdef3999877e5017bfe0e0ead1514e4e6a58abcde0b0167d4b0ad9d4abd1e0 -size 54020 +oid sha256:5f219bd9b9363f237e15bb73655dd53b2ec143e18c8544c11efbfa90390e091c +size 54312 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png index 90b4f4652f..062dfb77f8 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35f420b550029d7f8b22d73ea0349d2794cc2e5c5f3080799f496774fad7d2ff -size 55440 +oid sha256:6065f5330e1b3638719c6743db098bf6576fcffc6ccf8215f7f600a0b981144b +size 55731 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png index c99c22634e..1de7fbc725 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9518f3e4809856f3787bcd076f1a4f33067ea911e66cd6692790451309e6e192 -size 55769 +oid sha256:f4bdafdfa50665f05ba5cd8749252e7e5d65bea8a8c6bfc1c46eb1acd1570b52 +size 56086 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png index 1ae57f1414..d348a6f13f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f62a8a4eded0b742e911970837fe1003228834dd07a216a9bdc4acef37aa468 -size 55800 +oid sha256:2bfcc4dcaa8980cfc9c724e64a93116ac1fb81a1bf4421532cb196d4e07db7c5 +size 56024 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png index 31edf89ccf..224cef4b67 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1314aaf5394d03b5d08eccdb29783ce8d949f44d3eea28ec8ce434b830515304 -size 51662 +oid sha256:c86a6da3d45b767a41f65e0cf4e8cacefb33e0409386a6233140891deb2258f6 +size 51965 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png index 617d8da89b..82636b3e21 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-D-0_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecdc26ae1b8943734a8ec1020d7ca9ad4a8e570f5295eb568f80ed314585a9a9 -size 51981 +oid sha256:5a1fc20759ff45eeef547621853b9684a663fe9dcdf78b316454dcae26d078f9 +size 52286 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png index 58b944edce..094ab2536a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32ae9c61f8a01bd54b9fb51af5f0dff222f4df0be1003a9c6ad680d20877448b -size 52275 +oid sha256:7b9bc4654b6911d7f50b4b0242b650a529cb22c13a58caf5eae50366a2d27b37 +size 52532 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png index e28209550e..32de8d07f9 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1785f0fe49a5afd9b152f6ddb51acad7ba1700b2711cf302ef3a490d948fcd96 -size 53618 +oid sha256:0e051668beb9030ff184d6e75831a00202854f6448880f399a7ee2d5be187740 +size 53880 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png index 5113ea50e1..009ddeadaa 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8907587eb26b29d273fcce14fe4f17acd3ab8ae2fffa83d2c5d5c2c0d8c29bc6 -size 54270 +oid sha256:c6c6603452db218811f3f5a2baa934ca640755b969e81ca8cbf6b3dcf663aee9 +size 54551 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png index 6162f39468..d2f2f56d9b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:455b414e5da5d5174a8d87ef37219c9754e3558b39f025d7aab226b041c51096 -size 51305 +oid sha256:e953bc91e9959f1635d12416f6afde79427aeac4edef44fb38f1512672e544bd +size 51552 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png index 3b7c0855e3..9437874ffd 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0b83b1d37b34cdf0769e83f625d66479638ce402d5c8e76ef40548414d34400 -size 49862 +oid sha256:0157713ae934c0771a5d3d4d064ce6dc9be11b1d8011f2360740d7f4a20f6f04 +size 50162 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png index 500b83a53e..af4dae6214 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesView-N-0_1_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b09d4a60c3d9944cd6d50a8f0a06d5b3522eba7884f3b5e1e6889f93e8dd1794 -size 50026 +oid sha256:24f1d30bb62ed620671e87062a338fd53f409bce6e08fb7b116249decf74e409 +size 50295 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2c48d3e55d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-D-1_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4e1af15c571d1f087005849b627d79387f8f5557bbc4233768bb3c2d940d628 +size 48510 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..519d9d4d10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_null_AdvancedSettingsView-N-1_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa25ebf20fe62af56a548c3e962ae2e76e6e8e1b7e685d021306b733613e49eb +size 45462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c7b2473e20 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.list_null_Listitems_TextfieldListitem-textfieldvalue_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17fa588278f61269982bda072f4a67f9cc6f39f6f1ebcf4da59c7c3006808e14 +size 11262 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 7e07d269c0..3371b80814 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -145,6 +145,7 @@ "name": ":features:preferences:impl", "includeRegex": [ "screen_advanced_settings_.*", + "screen\\.advanced_settings\\..*", "screen_edit_profile_.*" ] }, @@ -153,6 +154,12 @@ "includeRegex": [ "call_.*" ] + }, + { + "name": ":features:lockscreen:impl", + "includeRegex": [ + "screen_app_lock_.*" + ] } ] } diff --git a/tools/sdk/build_rust_sdk.sh b/tools/sdk/build_rust_sdk.sh index 6853642218..3d7dc17059 100755 --- a/tools/sdk/build_rust_sdk.sh +++ b/tools/sdk/build_rust_sdk.sh @@ -64,7 +64,7 @@ printf "\nBuilding the SDK for aarch64-linux-android...\n\n" cd ../element-x-android mv ./libraries/rustsdk/sdk-android-debug.aar ./libraries/rustsdk/matrix-rust-sdk.aar mkdir -p ./libraries/rustsdk/sdks -cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/matrix-rust-sdk-${date}.aar +cp ./libraries/rustsdk/matrix-rust-sdk.aar ./libraries/rustsdk/sdks/matrix-rust-sdk-${date}.aar if [ ${buildApp} == "yes" ]; then