Use embedded version of Element Call (#4470)

* Use embedded version of Element Call: for in-app room calls, the app will use an embedded version of Element Call shipped with the app instead of using an external service.

* Remove `ElementCallBaseUrlProvider` so we don't use the Element well known file to get the base URL anymore

* Remove `ElementCallConfig.DEFAULT_BASE_URL` since it's not used anymore

* Restore the usage of the custom EC base URL in developer settings as the actual base URL, it present

* Add a way to customise the embedded EC analytic credentials

* Update CI to use the EC analytic credentials as secrets

* Improve the custom URL placeholder to include the `/room` suffix
This commit is contained in:
Jorge Martin Espinosa 2025-03-26 09:35:21 +01:00 committed by GitHub
parent 03f4122b3f
commit ba626fc173
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 177 additions and 288 deletions

View file

@ -1,3 +1,4 @@
import extension.readLocalProperty
import extension.setupAnvil
/*
@ -23,6 +24,49 @@ android {
testOptions {
unitTests.isIncludeAndroidResources = true
}
defaultConfig {
buildConfigField(
type = "String",
name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_USER_ID",
value = (System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
?: readLocalProperty("features.call.posthog.userid")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_API_HOST",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
?: readLocalProperty("features.call.posthog.api.host")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "POSTHOG_API_KEY",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
?: readLocalProperty("features.call.posthog.api.key")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
name = "RAGESHAKE_URL",
value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url")
?: ""
).let { "\"$it\"" }
)
}
}
setupAnvil()
@ -47,6 +91,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
api(projects.features.call.api)
testImplementation(libs.coroutines.test)

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.impl.BuildConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallAnalyticCredentialsProvider @Inject constructor() : CallAnalyticCredentialsProvider {
override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() }
override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() }
override val posthogApiKey: String? = BuildConfig.POSTHOG_API_KEY.takeIf { it.isNotBlank() }
override val rageshakeSubmitUrl: String? = BuildConfig.RAGESHAKE_URL.takeIf { it.isNotBlank() }
override val sentryDsn: String? = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() }
}

View file

@ -8,10 +8,8 @@
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
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
@ -19,12 +17,13 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
@ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
@ -35,9 +34,10 @@ class DefaultCallWidgetProvider @Inject constructor(
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = matrixClient.getRoom(roomId) ?: error("Room not found")
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
?: elementCallBaseUrlProvider.provides(matrixClient)
?: ElementCallConfig.DEFAULT_BASE_URL
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
val callUrl = room.generateWidgetWebViewUrl(
@ -46,9 +46,10 @@ class DefaultCallWidgetProvider @Inject constructor(
languageTag = languageTag,
theme = theme,
).getOrThrow()
CallWidgetProvider.GetWidgetResult(
driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
url = callUrl
url = callUrl,
)
}
}

View file

@ -16,6 +16,8 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.net.toUri
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig
@ -37,6 +39,10 @@ class WebViewWidgetMessageInterceptor(
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10)
init {
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
.build()
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
@ -117,6 +123,14 @@ class WebViewWidgetMessageInterceptor(
super.onReceivedSslError(view, handler, error)
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url)
}
override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(url.toUri())
}
}
// Create a WebMessageListener, which will receive messages from the WebView and reply to them

View file

@ -9,9 +9,7 @@ package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
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
@ -22,8 +20,6 @@ import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsPro
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -104,42 +100,13 @@ class DefaultCallWidgetProviderTest {
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
}
@Test
fun `getWidget - will use a wellknown base url if it exists`() = runTest {
val aCustomUrl = "https://custom.element.io"
val providesLambda = lambdaRecorder<MatrixClient, String?> { _ -> aCustomUrl }
val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { matrixClient ->
providesLambda(matrixClient)
}
val room = FakeMatrixRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider,
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
)
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
assertThat(settingsProvider.providedBaseUrls).containsExactly(aCustomUrl)
providesLambda.assertions()
.isCalledOnce()
.with(value(client))
}
private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider,
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
)
}

View file

@ -1,20 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallBaseUrlProvider(
private val providesLambda: (MatrixClient) -> String? = { lambdaError() }
) : ElementCallBaseUrlProvider {
override suspend fun provides(matrixClient: MatrixClient): String? {
return providesLambda(matrixClient)
}
}

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@ -112,8 +111,7 @@ class DeveloperSettingsPresenter @Inject constructor(
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
)
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.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 }
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
}
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
@ -133,7 +131,6 @@ class DeveloperSettingsPresenter @Inject constructor(
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl,
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
validator = ::customElementCallUrlValidator,
),
hideImagesAndVideos = hideImagesAndVideos,

View file

@ -27,6 +27,5 @@ data class DeveloperSettingsState(
data class CustomElementCallBaseUrlState(
val baseUrl: String?,
val defaultUrl: String,
val validator: (String?) -> Boolean,
)

View file

@ -47,10 +47,8 @@ fun aDeveloperSettingsState(
fun aCustomElementCallBaseUrlState(
baseUrl: String? = null,
defaultUrl: String = "https://call.element.io",
validator: (String?) -> Boolean = { true },
) = CustomElementCallBaseUrlState(
baseUrl = baseUrl,
defaultUrl = defaultUrl,
validator = validator,
)

View file

@ -136,22 +136,20 @@ private fun ElementCallCategory(
) {
PreferenceCategory(title = "Element Call", showTopDivider = true) {
val callUrlState = state.customElementCallBaseUrlState
fun isUsingDefaultUrl(value: String?): Boolean {
return value.isNullOrEmpty() || value == callUrlState.defaultUrl
}
val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) {
val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) {
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,
value = callUrlState.baseUrl,
placeholder = "https://.../room",
supportingText = supportingText,
validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !isUsingDefaultUrl(value) },
displayValue = { value -> !value.isNullOrEmpty() },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) }
)

View file

@ -10,7 +10,6 @@
package io.element.android.features.preferences.impl.developer
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
@ -130,7 +129,6 @@ class DeveloperSettingsPresenterTest {
}
awaitItem().also { state ->
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
}
}
}

View file

@ -8,10 +8,16 @@
package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isEditable
import androidx.compose.ui.test.isFocusable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
@ -56,8 +62,10 @@ class DeveloperSettingsViewTest {
),
)
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
textInputNode.performTextInput("https://call.element.dev")
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io"))
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
}
@Config(qualifiers = "h1024dp")