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 bac26635af
commit e2febabcf6
32 changed files with 177 additions and 288 deletions

View file

@ -46,6 +46,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}

View file

@ -54,6 +54,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs - name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}

View file

@ -30,6 +30,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

@ -36,6 +36,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

@ -32,6 +32,10 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View file

@ -13,7 +13,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider
class ContributesNodeProcessorProvider : SymbolProcessorProvider { class ContributesNodeProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
val enableLogging = environment.options["enableLogging"]?.toBoolean() ?: false val enableLogging = environment.options["enableLogging"]?.toBoolean() == true
return ContributesNodeProcessor( return ContributesNodeProcessor(
logger = environment.logger, logger = environment.logger,
codeGenerator = environment.codeGenerator, codeGenerator = environment.codeGenerator,

View file

@ -8,11 +8,6 @@
package io.element.android.appconfig package io.element.android.appconfig
object ElementCallConfig { object ElementCallConfig {
/**
* The default base URL for the Element Call service.
*/
const val DEFAULT_BASE_URL = "https://call.element.io"
/** /**
* The default duration of a ringing call in seconds before it's automatically dismissed. * The default duration of a ringing call in seconds before it's automatically dismissed.
*/ */

View file

@ -1,3 +1,4 @@
import extension.readLocalProperty
import extension.setupAnvil import extension.setupAnvil
/* /*
@ -23,6 +24,49 @@ android {
testOptions { testOptions {
unitTests.isIncludeAndroidResources = true 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() setupAnvil()
@ -47,6 +91,7 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.network.retrofit) implementation(libs.network.retrofit)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
api(projects.features.call.api) api(projects.features.call.api)
testImplementation(libs.coroutines.test) 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 package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId 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.CallWidgetSettingsProvider
@ -19,12 +17,13 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject import javax.inject.Inject
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor( class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider, private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider, private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
) : CallWidgetProvider { ) : CallWidgetProvider {
override suspend fun getWidget( override suspend fun getWidget(
sessionId: SessionId, sessionId: SessionId,
@ -35,9 +34,10 @@ class DefaultCallWidgetProvider @Inject constructor(
): Result<CallWidgetProvider.GetWidgetResult> = runCatching { ): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = matrixClient.getRoom(roomId) ?: error("Room not found") val room = matrixClient.getRoom(roomId) ?: error("Room not found")
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
?: elementCallBaseUrlProvider.provides(matrixClient) val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
?: ElementCallConfig.DEFAULT_BASE_URL val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted) val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
val callUrl = room.generateWidgetWebViewUrl( val callUrl = room.generateWidgetWebViewUrl(
@ -46,9 +46,10 @@ class DefaultCallWidgetProvider @Inject constructor(
languageTag = languageTag, languageTag = languageTag,
theme = theme, theme = theme,
).getOrThrow() ).getOrThrow()
CallWidgetProvider.GetWidgetResult( CallWidgetProvider.GetWidgetResult(
driver = room.getWidgetDriver(widgetSettings).getOrThrow(), 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.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.core.net.toUri
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig import io.element.android.features.call.impl.BuildConfig
@ -37,6 +39,10 @@ class WebViewWidgetMessageInterceptor(
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10) override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10)
init { init {
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
.build()
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@ -117,6 +123,14 @@ class WebViewWidgetMessageInterceptor(
super.onReceivedSslError(view, handler, error) 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 // 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider 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.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.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.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.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -104,42 +100,13 @@ class DefaultCallWidgetProviderTest {
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io") 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( private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
) = DefaultCallWidgetProvider( ) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider, matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider, 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.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap 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.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@ -112,8 +111,7 @@ class DeveloperSettingsPresenter @Inject constructor(
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) } triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
) )
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { 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() }
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
} }
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
@ -133,7 +131,6 @@ class DeveloperSettingsPresenter @Inject constructor(
rageshakeState = rageshakeState, rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState( customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl, baseUrl = customElementCallBaseUrl,
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
validator = ::customElementCallUrlValidator, validator = ::customElementCallUrlValidator,
), ),
hideImagesAndVideos = hideImagesAndVideos, hideImagesAndVideos = hideImagesAndVideos,

View file

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

View file

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

View file

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

View file

@ -10,7 +10,6 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import com.google.common.truth.Truth.assertThat 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.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
@ -130,7 +129,6 @@ class DeveloperSettingsPresenterTest {
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy") 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 package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity 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.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem 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) 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) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io")) eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")

View file

@ -211,6 +211,9 @@ dagger_compiler = { module = "com.google.dagger:dagger-compiler", version.ref =
anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref = "anvil" } anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref = "anvil" }
anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" } anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" }
# Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.9.0-rc.2"
# Auto services # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" }

View file

@ -1,14 +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.libraries.matrix.api.call
import io.element.android.libraries.matrix.api.MatrixClient
interface ElementCallBaseUrlProvider {
suspend fun provides(matrixClient: MatrixClient): String?
}

View file

@ -0,0 +1,16 @@
/*
* 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.libraries.matrix.api.widget
interface CallAnalyticCredentialsProvider {
val posthogUserId: String?
val posthogApiHost: String?
val posthogApiKey: String?
val rageshakeSubmitUrl: String?
val sentryDsn: String?
}

View file

@ -33,8 +33,6 @@ dependencies {
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.analyticsproviders.posthog)
implementation(projects.services.analyticsproviders.sentry)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
implementation(libs.dagger) implementation(libs.dagger)

View file

@ -189,12 +189,12 @@ class RustMatrixAuthenticationService @Inject constructor(
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first") val client = currentClient ?: error("You need to call `setHomeserver()` first")
val oAuthAuthenticationData = client.urlForOidc( val oAuthAuthorizationData = client.urlForOidc(
oidcConfiguration = oidcConfigurationProvider.get(), oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(), prompt = prompt.toRustPrompt(),
) )
val url = oAuthAuthenticationData.loginUrl() val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthenticationData pendingOAuthAuthorizationData = oAuthAuthorizationData
OidcDetails(url) OidcDetails(url)
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() failure.mapAuthenticationException()

View file

@ -1,43 +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.libraries.matrix.impl.call
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.call.ElementCallBaseUrlProvider
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultElementCallBaseUrlProvider @Inject constructor(
private val elementWellKnownParser: ElementWellKnownParser,
) : ElementCallBaseUrlProvider {
override suspend fun provides(matrixClient: MatrixClient): String? {
val url = buildString {
append("https://")
append(matrixClient.userIdServerName())
append("/.well-known/element/element.json")
}
return matrixClient.getUrl(url)
.onFailure { failure ->
Timber.w(failure, "Failed to fetch well-known element.json")
}
.getOrNull()
?.let { wellKnownStr ->
elementWellKnownParser.parse(wellKnownStr)
.onFailure { failure ->
// Can be a HTML 404.
Timber.w(failure, "Failed to parse content")
}
.getOrNull()
}
?.call
?.widgetUrl
}
}

View file

@ -8,15 +8,13 @@
package io.element.android.libraries.matrix.impl.widget package io.element.android.libraries.matrix.impl.widget
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.services.analytics.api.store.AnalyticsStore import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.posthog.PosthogEndpointConfigProvider
import io.element.android.services.analyticsproviders.sentry.SentryConfig
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.matrix.rustcomponents.sdk.EncryptionSystem import org.matrix.rustcomponents.sdk.EncryptionSystem
import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions
@ -27,12 +25,11 @@ import org.matrix.rustcomponents.sdk.Intent as CallIntent
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCallWidgetSettingsProvider @Inject constructor( class DefaultCallWidgetSettingsProvider @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider, private val callAnalyticsCredentialsProvider: CallAnalyticCredentialsProvider,
private val analyticsStore: AnalyticsStore, private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider { ) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings { override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
val analyticsEnabled = analyticsStore.userConsentFlow.first() val isAnalyticsEnabled = analyticsService.getUserConsent().first()
val posthogEndpointConfig = posthogEndpointConfigProvider.provide()
val options = VirtualElementCallWidgetOptions( val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl, elementCallUrl = baseUrl,
widgetId = widgetId, widgetId = widgetId,
@ -44,12 +41,12 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
encryption = if (encrypted) EncryptionSystem.PerParticipantKeys else EncryptionSystem.Unencrypted, encryption = if (encrypted) EncryptionSystem.PerParticipantKeys else EncryptionSystem.Unencrypted,
intent = CallIntent.START_CALL, intent = CallIntent.START_CALL,
hideScreensharing = false, hideScreensharing = false,
posthogUserId = null, posthogUserId = callAnalyticsCredentialsProvider.posthogUserId.takeIf { isAnalyticsEnabled },
posthogApiHost = posthogEndpointConfig.host.takeIf { analyticsEnabled }, posthogApiHost = callAnalyticsCredentialsProvider.posthogApiHost.takeIf { isAnalyticsEnabled },
posthogApiKey = posthogEndpointConfig.apiKey.takeIf { analyticsEnabled }, posthogApiKey = callAnalyticsCredentialsProvider.posthogApiKey.takeIf { isAnalyticsEnabled },
rageshakeSubmitUrl = RageshakeConfig.BUG_REPORT_URL, rageshakeSubmitUrl = callAnalyticsCredentialsProvider.rageshakeSubmitUrl,
sentryDsn = SentryConfig.DSN.takeIf { analyticsEnabled }, sentryDsn = callAnalyticsCredentialsProvider.sentryDsn.takeIf { isAnalyticsEnabled },
sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) SentryConfig.ENV_RELEASE else SentryConfig.ENV_DEBUG, sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG",
parentUrl = null, parentUrl = null,
hideHeader = true, hideHeader = true,
) )

View file

@ -1,99 +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.libraries.matrix.impl.call
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
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
import org.matrix.rustcomponents.sdk.ElementCallWellKnown
import org.matrix.rustcomponents.sdk.ElementWellKnown
class DefaultElementCallBaseUrlProviderTest {
@Test
fun `provides returns null when getUrl returns an error`() = runTest {
val userIdServerNameLambda = lambdaRecorder<String> { "example.com" }
val getUrlLambda = lambdaRecorder<String, Result<String>> { _ ->
Result.failure(AN_EXCEPTION)
}
val sut = DefaultElementCallBaseUrlProvider(
FakeElementWellKnownParser(
Result.success(createElementWellKnown(""))
)
)
val matrixClient = FakeMatrixClient(
userIdServerNameLambda = userIdServerNameLambda,
getUrlLambda = getUrlLambda,
)
val result = sut.provides(matrixClient)
assertThat(result).isNull()
userIdServerNameLambda.assertions().isCalledOnce()
getUrlLambda.assertions().isCalledOnce()
.with(value("https://example.com/.well-known/element/element.json"))
}
@Test
fun `provides returns null when content parsing fails`() = runTest {
val userIdServerNameLambda = lambdaRecorder<String> { "example.com" }
val getUrlLambda = lambdaRecorder<String, Result<String>> { _ ->
Result.success("""{"call":{"widget_url":"https://example.com/call"}}""")
}
val sut = DefaultElementCallBaseUrlProvider(
createFakeElementWellKnownParser(
Result.failure(AN_EXCEPTION)
)
)
val matrixClient = FakeMatrixClient(
userIdServerNameLambda = userIdServerNameLambda,
getUrlLambda = getUrlLambda,
)
val result = sut.provides(matrixClient)
assertThat(result).isNull()
userIdServerNameLambda.assertions().isCalledOnce()
getUrlLambda.assertions().isCalledOnce()
.with(value("https://example.com/.well-known/element/element.json"))
}
@Test
fun `provides returns value when getUrl returns correct content`() = runTest {
val userIdServerNameLambda = lambdaRecorder<String> { "example.com" }
val getUrlLambda = lambdaRecorder<String, Result<String>> { _ ->
Result.success("""{"call":{"widget_url":"https://example.com/call"}}""")
}
val sut = DefaultElementCallBaseUrlProvider(
createFakeElementWellKnownParser(
Result.success(createElementWellKnown("aUrl"))
)
)
val matrixClient = FakeMatrixClient(
userIdServerNameLambda = userIdServerNameLambda,
getUrlLambda = getUrlLambda,
)
val result = sut.provides(matrixClient)
assertThat(result).isEqualTo("aUrl")
userIdServerNameLambda.assertions().isCalledOnce()
getUrlLambda.assertions().isCalledOnce()
.with(value("https://example.com/.well-known/element/element.json"))
}
private fun createFakeElementWellKnownParser(result: Result<ElementWellKnown>): FakeElementWellKnownParser {
return FakeElementWellKnownParser(result)
}
private fun createElementWellKnown(widgetUrl: String): ElementWellKnown {
return ElementWellKnown(
call = ElementCallWellKnown(
widgetUrl = widgetUrl,
),
registrationHelperUrl = null,
)
}
}

View file

@ -1,26 +0,0 @@
/*
* 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.services.analytics.api.store
import kotlinx.coroutines.flow.Flow
/**
* Local storage for:
* - user consent (Boolean);
* - did ask user consent (Boolean);
* - analytics Id (String).
*/
interface AnalyticsStore {
val userConsentFlow: Flow<Boolean>
val didAskUserConsentFlow: Flow<Boolean>
val analyticsIdFlow: Flow<String>
suspend fun setUserConsent(newUserConsent: Boolean)
suspend fun setDidAskUserConsent(newValue: Boolean = true)
suspend fun setAnalyticsId(newAnalyticsId: String)
suspend fun reset()
}

View file

@ -17,8 +17,8 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.store.AnalyticsStore
import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.log.analyticsTag
import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

@ -18,7 +18,6 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.analytics.api.store.AnalyticsStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -29,6 +28,22 @@ import javax.inject.Inject
*/ */
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_analytics") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_analytics")
/**
* Local storage for:
* - user consent (Boolean);
* - did ask user consent (Boolean);
* - analytics Id (String).
*/
interface AnalyticsStore {
val userConsentFlow: Flow<Boolean>
val didAskUserConsentFlow: Flow<Boolean>
val analyticsIdFlow: Flow<String>
suspend fun setUserConsent(newUserConsent: Boolean)
suspend fun setDidAskUserConsent(newValue: Boolean = true)
suspend fun setAnalyticsId(newAnalyticsId: String)
suspend fun reset()
}
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnalyticsStore @Inject constructor( class DefaultAnalyticsStore @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context

View file

@ -18,7 +18,7 @@ import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.services.analytics.api.store.AnalyticsStore import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider

View file

@ -7,11 +7,10 @@
package io.element.android.services.analytics.impl.store package io.element.android.services.analytics.impl.store
import io.element.android.services.analytics.api.store.AnalyticsStore
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class FakeAnalyticsStore( internal class FakeAnalyticsStore(
defaultUserConsent: Boolean = false, defaultUserConsent: Boolean = false,
defaultDidAskUserConsent: Boolean = false, defaultDidAskUserConsent: Boolean = false,
defaultAnalyticsId: String = "", defaultAnalyticsId: String = "",