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/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index a2e8359284..691963f90d 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -188,13 +188,13 @@ class CallScreenPresenter @AssistedInject constructor( inputs.url } is CallType.RoomCall -> { - val (driver, url) = callWidgetProvider.getWidget( + val result = callWidgetProvider.getWidget( sessionId = inputs.sessionId, roomId = inputs.roomId, clientId = UUID.randomUUID().toString(), ).getOrThrow() - callWidgetDriver.value = driver - url + callWidgetDriver.value = result.driver + result.url } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt new file mode 100644 index 0000000000..be6622d8ee --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData + +open class CallScreenStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aCallScreenState(), + aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), + ) +} + +private fun aCallScreenState( + urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), + userAgent: String = "", + isInWidgetMode: Boolean = false, + eventSink: (CallScreenEvents) -> Unit = {}, +): CallScreenState { + return CallScreenState( + urlState = urlState, + userAgent = userAgent, + isInWidgetMode = isInWidgetMode, + eventSink = eventSink, + ) +} 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 ebf006d102..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 @@ -32,17 +32,21 @@ 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.tooling.preview.PreviewParameter import androidx.compose.ui.viewinterop.AndroidView 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 @@ -91,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 + } } } @@ -157,16 +172,11 @@ private fun WebView.setup( @PreviewsDayNight @Composable -internal fun CallScreenViewPreview() { - ElementPreview { - CallScreenView( - state = CallScreenState( - urlState = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), - isInWidgetMode = false, - userAgent = "", - eventSink = {}, - ), - requestPermissions = { _, _ -> }, - ) - } +internal fun CallScreenViewPreview( + @PreviewParameter(CallScreenStateProvider::class) state: CallScreenState, +) = ElementPreview { + CallScreenView( + state = state, + requestPermissions = { _, _ -> }, + ) } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt index 670571476c..61843c471a 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt @@ -27,5 +27,10 @@ interface CallWidgetProvider { clientId: String, languageTag: String? = null, theme: String? = null, - ): Result> + ): Result + + data class GetWidgetResult( + val driver: MatrixWidgetDriver, + val url: String, + ) } 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 1daa0a8f3d..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 @@ -24,7 +24,6 @@ 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 @@ -33,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, @@ -40,11 +40,16 @@ class DefaultCallWidgetProvider @Inject constructor( clientId: String, languageTag: String?, theme: String?, - ): Result> = runCatching { + ): 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() - room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl + CallWidgetProvider.GetWidgetResult( + driver = room.getWidgetDriver(widgetSettings).getOrThrow(), + url = callUrl + ) } } 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..63eb5208dd --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ElementCallBaseUrlProvider.kt @@ -0,0 +1,61 @@ +/* + * 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.di.SingleIn +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? +} + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultElementCallBaseUrlProvider @Inject constructor( + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) : ElementCallBaseUrlProvider { + private val apiCache = mutableMapOf() + + override suspend fun provides(sessionId: SessionId): String? = withContext(coroutineDispatchers.io) { + val domain = sessionId.value.substringAfter(":") + val callWellknownAPI = apiCache.getOrPut(sessionId) { + retrofitFactory.create("https://$domain") + .create(CallWellknownAPI::class.java) + } + try { + callWellknownAPI.getCallWellKnown().widgetUrl + } catch (e: HttpException) { + // Ignore Http 404, but re-throws any other exceptions + if (e.code() != HttpURLConnection.HTTP_NOT_FOUND) { + throw e + } + Timber.w(e, "Failed to fetch wellknown data") + 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..b0947bb409 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 providesLambda = lambdaRecorder { _ -> aCustomUrl } + val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { sessionId -> + providesLambda(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) + providesLambda.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/FakeCallWidgetProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt index b957122a3f..c085d70cb1 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -19,7 +19,6 @@ package io.element.android.features.call.utils import io.element.android.features.call.impl.utils.CallWidgetProvider 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.FakeMatrixWidgetDriver class FakeCallWidgetProvider( @@ -35,8 +34,13 @@ class FakeCallWidgetProvider( clientId: String, languageTag: String?, theme: String? - ): Result> { + ): Result { getWidgetCalled = true - return Result.success(widgetDriver to url) + return Result.success( + CallWidgetProvider.GetWidgetResult( + driver = widgetDriver, + url = url, + ) + ) } } 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) + } +} diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..abd445c244 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Day-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68d9ca60586aac84157c60f126b17b70ca9d52087da80f253b60f47de87d7ff6 +size 13750 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c4cfe0583 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call.impl.ui_CallScreenView_null_CallScreenView-Night-0_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b676c158a1f820d50c9ffd50d048d5c236ee8356279663f257a4882f06c5a1a9 +size 12214