diff --git a/changelog.d/3009.misc b/changelog.d/3009.misc new file mode 100644 index 0000000000..9a06b57eee --- /dev/null +++ b/changelog.d/3009.misc @@ -0,0 +1 @@ +Make Element Call widget URL configurable diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index 72ccdc089b..4e2aa65d44 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(projects.services.toolbox.api) implementation(libs.androidx.webkit) implementation(libs.coil.compose) + implementation(libs.network.retrofit) implementation(libs.serialization.json) api(projects.features.call.api) ksp(libs.showkase.processor) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index 88f4359203..23d0a4769e 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -38,12 +38,15 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.call.impl.R import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.ProgressDialog 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 io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings typealias RequestPermissionCallback = (Array) -> Unit @@ -77,9 +80,9 @@ internal fun CallScreenView( } CallWebView( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) - .fillMaxSize(), + .padding(padding) + .consumeWindowInsets(padding) + .fillMaxSize(), url = state.urlState, userAgent = state.userAgent, onPermissionsRequest = { request -> @@ -92,6 +95,17 @@ internal fun CallScreenView( state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) } ) + when (state.urlState) { + AsyncData.Uninitialized, + is AsyncData.Loading -> + ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) + is AsyncData.Failure -> + ErrorDialog( + content = state.urlState.error.message.orEmpty(), + onDismiss = { state.eventSink(CallScreenEvents.Hangup) }, + ) + is AsyncData.Success -> Unit + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt index f1a71f88f5..6f20129dcc 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt @@ -32,6 +32,7 @@ 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, @@ -41,7 +42,9 @@ class DefaultCallWidgetProvider @Inject constructor( theme: String?, ): Result = runCatching { val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found") - val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL + val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() + ?: elementCallBaseUrlProvider.provides(sessionId) + ?: ElementCallConfig.DEFAULT_BASE_URL val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = room.isEncrypted) val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow() CallWidgetProvider.GetWidgetResult( diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ElementCallBaseUrlProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ElementCallBaseUrlProvider.kt new file mode 100644 index 0000000000..883b5de73e --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ElementCallBaseUrlProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 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.impl.utils + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.call.impl.wellknown.CallWellknownAPI +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.network.RetrofitFactory +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import timber.log.Timber +import java.net.HttpURLConnection +import javax.inject.Inject + +interface ElementCallBaseUrlProvider { + suspend fun provides(sessionId: SessionId): String? +} + +@ContributesBinding(AppScope::class) +class DefaultElementCallBaseUrlProvider @Inject constructor( + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) : ElementCallBaseUrlProvider { + override suspend fun provides(sessionId: SessionId): String? = withContext(coroutineDispatchers.io) { + val domain = sessionId.value.substringAfter(":") + val callWellknownAPI = retrofitFactory.create("https://$domain") + .create(CallWellknownAPI::class.java) + try { + callWellknownAPI.getCallWellKnown().widgetUrl + } catch (e: HttpException) { + Timber.w(e, "Failed to fetch wellknown data") + // Ignore Http 404, but re-throws any other exceptions + if (e.code() != HttpURLConnection.HTTP_NOT_FOUND /* 404 */) { + throw e + } + null + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellKnown.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellKnown.kt new file mode 100644 index 0000000000..b2e87c907b --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellKnown.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 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.impl.wellknown + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Example: + *
+ * {
+ *     "widget_url": "https://call.server.com"
+ * }
+ * 
+ * . + */ +@Serializable +data class CallWellKnown( + @SerialName("widget_url") + val widgetUrl: String? = null, +) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellknownAPI.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellknownAPI.kt new file mode 100644 index 0000000000..e2b5d0e54f --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/wellknown/CallWellknownAPI.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 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.impl.wellknown + +import retrofit2.http.GET + +internal interface CallWellknownAPI { + @GET(".well-known/element/call.json") + suspend fun getCallWellKnown(): CallWellKnown +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index a41b5f0296..99d7defe87 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -18,8 +18,10 @@ 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.features.call.impl.utils.ElementCallBaseUrlProvider import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId 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 @@ -29,6 +31,8 @@ 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.FakeMatrixWidgetDriver 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 @@ -109,13 +113,42 @@ 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 provideLambda = lambdaRecorder { String -> aCustomUrl } + val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { sessionId -> + provideLambda(sessionId) + } + val room = FakeMatrixRoom().apply { + givenGenerateWidgetWebViewUrlResult(Result.success("url")) + givenGetWidgetDriverResult(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) + provideLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + } + private fun createProvider( matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), - callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider() + callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), + elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null }, ) = DefaultCallWidgetProvider( - matrixClientProvider, - appPreferencesStore, - callWidgetSettingsProvider, + matrixClientsProvider = matrixClientProvider, + appPreferencesStore = appPreferencesStore, + callWidgetSettingsProvider = callWidgetSettingsProvider, + elementCallBaseUrlProvider = elementCallBaseUrlProvider, ) } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt new file mode 100644 index 0000000000..619659e1df --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeElementCallBaseUrlProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 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.impl.utils.ElementCallBaseUrlProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeElementCallBaseUrlProvider( + private val providesLambda: (SessionId) -> String? = { lambdaError() } +) : ElementCallBaseUrlProvider { + override suspend fun provides(sessionId: SessionId): String? { + return providesLambda(sessionId) + } +}