From 0cea89edcca41dd50da26ced550d817949dc102c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 20 Sep 2024 17:26:27 +0200 Subject: [PATCH 1/3] Handle no network error when starting Element Call. --- .../features/call/impl/ui/CallScreenEvents.kt | 4 +- .../call/impl/ui/CallScreenPresenter.kt | 20 ++++++++++ .../features/call/impl/ui/CallScreenState.kt | 1 + .../call/impl/ui/CallScreenStateProvider.kt | 2 + .../features/call/impl/ui/CallScreenView.kt | 37 +++++++++++-------- .../utils/WebViewWidgetMessageInterceptor.kt | 30 ++++++++++++++- 6 files changed, 74 insertions(+), 20 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt index 675e1e27e0..4785efe650 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt @@ -11,6 +11,6 @@ import io.element.android.features.call.impl.utils.WidgetMessageInterceptor sealed interface CallScreenEvents { data object Hangup : CallScreenEvents - data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : - CallScreenEvents + data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents + data class OnWebViewError(val description: String?) : CallScreenEvents } 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 4a6ec60e90..12a803bbcc 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 @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -38,6 +39,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope @@ -78,8 +80,10 @@ class CallScreenPresenter @AssistedInject constructor( val callWidgetDriver = remember { mutableStateOf(null) } val messageInterceptor = remember { mutableStateOf(null) } var isJoinedCall by rememberSaveable { mutableStateOf(false) } + var canRenderWebViewInCaseOfError by rememberSaveable { mutableStateOf(false) } val languageTag = languageTagProvider.provideLanguageTag() val theme = if (ElementTheme.isLightTheme) "light" else "dark" + val errorMessage = stringResource(id = CommonStrings.error_unknown) DisposableEffect(Unit) { coroutineScope.launch { // Sets the call as joined @@ -125,6 +129,8 @@ class CallScreenPresenter @AssistedInject constructor( LaunchedEffect(Unit) { interceptor.interceptedMessages .onEach { + // We are receiving messages from the WebView, consider that the application is loaded + canRenderWebViewInCaseOfError = true // Relay message to Widget Driver callWidgetDriver.value?.send(it) @@ -163,11 +169,25 @@ class CallScreenPresenter @AssistedInject constructor( is CallScreenEvents.SetupMessageChannels -> { messageInterceptor.value = event.widgetMessageInterceptor } + is CallScreenEvents.OnWebViewError -> { + if (!canRenderWebViewInCaseOfError) { + urlState.value = AsyncData.Failure( + Exception( + buildString { + append(errorMessage) + event.description?.let { append("\n\n").append(it) } + } + ) + ) + } + // Else ignore the error, give a chance the Element Call to recover by itself. + } } } return CallScreenState( urlState = urlState.value, + canRenderWebViewInCaseOfError = canRenderWebViewInCaseOfError, userAgent = userAgent, isInWidgetMode = isInWidgetMode, eventSink = { handleEvents(it) }, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt index 09fabfd0b0..9ceb89d45b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData data class CallScreenState( val urlState: AsyncData, + val canRenderWebViewInCaseOfError: Boolean, val userAgent: String, val isInWidgetMode: Boolean, val eventSink: (CallScreenEvents) -> Unit, 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 index fc39c71c23..3486a1fb98 100644 --- 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 @@ -21,12 +21,14 @@ open class CallScreenStateProvider : PreviewParameterProvider { internal fun aCallScreenState( urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), + canRenderWebViewInCaseOfError: Boolean = true, userAgent: String = "", isInWidgetMode: Boolean = false, eventSink: (CallScreenEvents) -> Unit = {}, ): CallScreenState { return CallScreenState( urlState = urlState, + canRenderWebViewInCaseOfError = canRenderWebViewInCaseOfError, 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 4641e4e7bc..64f06d28f9 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 @@ -85,25 +85,30 @@ internal fun CallScreenView( BackHandler { handleBack() } - CallWebView( - modifier = Modifier + if (state.urlState !is AsyncData.Failure || state.canRenderWebViewInCaseOfError) { + CallWebView( + modifier = Modifier .padding(padding) .consumeWindowInsets(padding) .fillMaxSize(), - url = state.urlState, - userAgent = state.userAgent, - onPermissionsRequest = { request -> - val androidPermissions = mapWebkitPermissions(request.resources) - val callback: RequestPermissionCallback = { request.grant(it) } - requestPermissions(androidPermissions.toTypedArray(), callback) - }, - onWebViewCreate = { webView -> - val interceptor = WebViewWidgetMessageInterceptor(webView) - state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) - pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) - } - ) + url = state.urlState, + userAgent = state.userAgent, + onPermissionsRequest = { request -> + val androidPermissions = mapWebkitPermissions(request.resources) + val callback: RequestPermissionCallback = { request.grant(it) } + requestPermissions(androidPermissions.toTypedArray(), callback) + }, + onWebViewCreate = { webView -> + val interceptor = WebViewWidgetMessageInterceptor( + webView = webView, + onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, + ) + state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) + val pipController = WebViewPipController(webView) + pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) + } + ) + } when (state.urlState) { AsyncData.Uninitialized, is AsyncData.Loading -> diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt index 6f200e550a..609c04a0e6 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt @@ -8,16 +8,23 @@ package io.element.android.features.call.impl.utils import android.graphics.Bitmap +import android.net.http.SslError import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import io.element.android.features.call.impl.BuildConfig import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber class WebViewWidgetMessageInterceptor( private val webView: WebView, + private val onError: (String?) -> Unit, ) : WidgetMessageInterceptor { companion object { // We call both the WebMessageListener and the JavascriptInterface objects in JS with this @@ -45,16 +52,35 @@ class WebViewWidgetMessageInterceptor( if (message.data.response && message.data.api == "toWidget" || !message.data.response && message.data.api == "fromWidget") { let json = JSON.stringify(event.data) - ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } } + ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG }} $LISTENER_NAME.postMessage(json); } else { - ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } } + ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG }} } }); """.trimIndent(), null ) } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + // No network for instance, transmit the error + Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}") + onError(error?.description?.toString()) + super.onReceivedError(view, request, error) + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}") + onError(errorResponse?.statusCode.toString()) + super.onReceivedHttpError(view, request, errorResponse) + } + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + Timber.e("onReceivedSslError error: ${error?.primaryError}") + onError(error?.primaryError?.toString()) + super.onReceivedSslError(view, handler, error) + } } // Create a WebMessageListener, which will receive messages from the WebView and reply to them From 2ec6250e6f54425de3d4c68ba42f3536e396f059 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 24 Sep 2024 10:01:34 +0200 Subject: [PATCH 2/3] Fix tests --- .../call/impl/ui/CallScreenPresenter.kt | 21 +++------ .../features/call/impl/ui/CallScreenState.kt | 2 +- .../call/impl/ui/CallScreenStateProvider.kt | 5 ++- .../features/call/impl/ui/CallScreenView.kt | 32 ++++++++------ .../call/ui/CallScreenPresenterTest.kt | 43 +++++++++++++++++++ 5 files changed, 73 insertions(+), 30 deletions(-) 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 12a803bbcc..3a4a31f190 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 @@ -17,7 +17,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -39,7 +38,6 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider -import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope @@ -80,10 +78,10 @@ class CallScreenPresenter @AssistedInject constructor( val callWidgetDriver = remember { mutableStateOf(null) } val messageInterceptor = remember { mutableStateOf(null) } var isJoinedCall by rememberSaveable { mutableStateOf(false) } - var canRenderWebViewInCaseOfError by rememberSaveable { mutableStateOf(false) } + var ignoreWebViewError by rememberSaveable { mutableStateOf(false) } + var webViewError by remember { mutableStateOf(null) } val languageTag = languageTagProvider.provideLanguageTag() val theme = if (ElementTheme.isLightTheme) "light" else "dark" - val errorMessage = stringResource(id = CommonStrings.error_unknown) DisposableEffect(Unit) { coroutineScope.launch { // Sets the call as joined @@ -130,7 +128,7 @@ class CallScreenPresenter @AssistedInject constructor( interceptor.interceptedMessages .onEach { // We are receiving messages from the WebView, consider that the application is loaded - canRenderWebViewInCaseOfError = true + ignoreWebViewError = true // Relay message to Widget Driver callWidgetDriver.value?.send(it) @@ -170,15 +168,8 @@ class CallScreenPresenter @AssistedInject constructor( messageInterceptor.value = event.widgetMessageInterceptor } is CallScreenEvents.OnWebViewError -> { - if (!canRenderWebViewInCaseOfError) { - urlState.value = AsyncData.Failure( - Exception( - buildString { - append(errorMessage) - event.description?.let { append("\n\n").append(it) } - } - ) - ) + if (!ignoreWebViewError) { + webViewError = event.description.orEmpty() } // Else ignore the error, give a chance the Element Call to recover by itself. } @@ -187,7 +178,7 @@ class CallScreenPresenter @AssistedInject constructor( return CallScreenState( urlState = urlState.value, - canRenderWebViewInCaseOfError = canRenderWebViewInCaseOfError, + webViewError = webViewError, userAgent = userAgent, isInWidgetMode = isInWidgetMode, eventSink = { handleEvents(it) }, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt index 9ceb89d45b..48a4672e1a 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt @@ -11,7 +11,7 @@ import io.element.android.libraries.architecture.AsyncData data class CallScreenState( val urlState: AsyncData, - val canRenderWebViewInCaseOfError: Boolean, + val webViewError: String?, val userAgent: String, val isInWidgetMode: Boolean, val eventSink: (CallScreenEvents) -> Unit, 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 index 3486a1fb98..bb4794749d 100644 --- 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 @@ -16,19 +16,20 @@ open class CallScreenStateProvider : PreviewParameterProvider { aCallScreenState(), aCallScreenState(urlState = AsyncData.Loading()), aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), + aCallScreenState(webViewError = "Error details from WebView"), ) } internal fun aCallScreenState( urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), - canRenderWebViewInCaseOfError: Boolean = true, + webViewError: String? = null, userAgent: String = "", isInWidgetMode: Boolean = false, eventSink: (CallScreenEvents) -> Unit = {}, ): CallScreenState { return CallScreenState( urlState = urlState, - canRenderWebViewInCaseOfError = canRenderWebViewInCaseOfError, + webViewError = webViewError, 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 64f06d28f9..5fa4856476 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 @@ -85,7 +85,15 @@ internal fun CallScreenView( BackHandler { handleBack() } - if (state.urlState !is AsyncData.Failure || state.canRenderWebViewInCaseOfError) { + if (state.webViewError != null) { + ErrorDialog( + content = buildString { + append(stringResource(CommonStrings.error_unknown)) + state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } + }, + onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, + ) + } else { CallWebView( modifier = Modifier .padding(padding) @@ -108,17 +116,17 @@ internal fun CallScreenView( pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) } ) - } - 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(), - onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, - ) - is AsyncData.Success -> Unit + 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(), + onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, + ) + is AsyncData.Success -> Unit + } } } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 77326b0d7d..7390b5fde9 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -71,6 +71,7 @@ class CallScreenPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) + assertThat(initialState.webViewError).isNull() assertThat(initialState.isInWidgetMode).isFalse() analyticsLambda.assertions().isNeverCalled() joinedCallLambda.assertions().isCalledOnce() @@ -270,6 +271,48 @@ class CallScreenPresenterTest { assert(stopSyncLambda).isCalledOnce() } + @Test + fun `present - error from WebView are updating the state`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isEqualTo("A Webview error") + } + } + + @Test + fun `present - error from WebView are ignored if Element Call is loaded`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + val initialState = awaitItem() + + val messageInterceptor = FakeWidgetMessageInterceptor() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + // Emit a message + messageInterceptor.givenInterceptedMessage("A message") + // WebView emits an error, but it will be ignored + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isNull() + } + } + private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), From eb111ed289675a9cba2ab0af0c69811a98d8dacb Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 24 Sep 2024 08:23:43 +0000 Subject: [PATCH 3/3] Update screenshots --- .../images/features.call.impl.ui_CallScreenView_Day_3_en.png | 3 +++ .../images/features.call.impl.ui_CallScreenView_Night_3_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png new file mode 100644 index 0000000000..6ed4f78a66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1067d901572528e98cb735ffd5a4c8d340ab62718fb41b1c14b9d8c8019d34c4 +size 19192 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png new file mode 100644 index 0000000000..d8ca8ea365 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4e900d1e69e56e6b0d1e2a18e23a087051b3452a173678494217326399e0948 +size 17340