Integrate Element Call with widget API (#1581)

* Integrate Element Call with widget API.

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

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-10-19 17:38:43 +02:00 committed by GitHub
parent a814c4a95a
commit 46f78ef700
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 2202 additions and 166 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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