Handle 'invalid server' error in server selection screen properly. (#214)
* Handle 'invalid server' error in server selection screen properly. * Use `action_learn_more` for composing the server location footer action.
This commit is contained in:
parent
93a77d94c1
commit
2906168baa
24 changed files with 132 additions and 127 deletions
1
changelog.d/210.bugfix
Normal file
1
changelog.d/210.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Handle 'invalid server' error in server selection screen properly.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.login.impl.changeserver
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
import io.element.android.libraries.ui.strings.R
|
||||
|
||||
sealed class ChangeServerError : Throwable() {
|
||||
data class InlineErrorMessage(@StringRes val messageId: Int) : ChangeServerError() {
|
||||
@Composable
|
||||
fun message(): String = stringResource(messageId)
|
||||
}
|
||||
object SlidingSyncAlert : ChangeServerError()
|
||||
|
||||
companion object {
|
||||
fun from(error: Throwable): ChangeServerError = when (error) {
|
||||
is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert
|
||||
else -> InlineErrorMessage(R.string.server_selection_invalid_homeserver_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,10 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
|
|||
|
||||
fun handleEvents(event: ChangeServerEvents) {
|
||||
when (event) {
|
||||
is ChangeServerEvents.SetServer -> homeserver.value = event.server
|
||||
is ChangeServerEvents.SetServer -> {
|
||||
homeserver.value = event.server
|
||||
handleEvents(ChangeServerEvents.ClearError)
|
||||
}
|
||||
ChangeServerEvents.Submit -> {
|
||||
localCoroutineScope.submit(homeserver, changeServerAction)
|
||||
}
|
||||
|
|
@ -68,6 +71,6 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
|
|||
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
|
||||
authenticationService.setHomeserver(domain)
|
||||
homeserverUrl.value = domain
|
||||
}.execute(changeServerAction)
|
||||
}.execute(changeServerAction, errorMapping = ChangeServerError::from)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ data class ChangeServerState(
|
|||
val changeServerAction: Async<Unit>,
|
||||
val eventSink: (ChangeServerEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction is Async.Uninitialized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.ui.strings.R
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
|
||||
|
|
@ -25,7 +26,11 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
|
|||
aChangeServerState(),
|
||||
aChangeServerState().copy(homeserver = "matrix.org"),
|
||||
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Loading()),
|
||||
aChangeServerState().copy(homeserver = "invalid.org", changeServerAction = Async.Failure(Throwable("An error"))),
|
||||
aChangeServerState().copy(
|
||||
homeserver = "invalid.org",
|
||||
changeServerAction = Async.Failure(ChangeServerError.InlineErrorMessage(R.string.server_selection_invalid_homeserver_error))
|
||||
),
|
||||
aChangeServerState().copy(homeserver = "invalid.org", changeServerAction = Async.Failure(ChangeServerError.SlidingSyncAlert)),
|
||||
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Success(Unit)),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.error.changeServerError
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
|
|
@ -80,7 +79,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
|
@ -101,6 +99,8 @@ fun ChangeServerView(
|
|||
state.changeServerAction !is Async.Loading
|
||||
}
|
||||
}
|
||||
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
|
||||
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
fun submit() {
|
||||
|
|
@ -200,53 +200,50 @@ fun ChangeServerView(
|
|||
trailingIcon = if (homeserverFieldState.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = {
|
||||
homeserverFieldState = ""
|
||||
eventSink(ChangeServerEvents.SetServer(""))
|
||||
}, enabled = interactionEnabled) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.a11y_clear))
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
if (state.changeServerAction is Async.Failure) {
|
||||
if (state.changeServerAction.error is AuthenticationException.SlidingSyncNotAvailable) {
|
||||
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
|
||||
onLearnMoreClicked()
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
}, onDismiss = {
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
})
|
||||
} else {
|
||||
ChangeServerErrorDialog(
|
||||
error = state.changeServerAction.error,
|
||||
onDismiss = {
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
isError = invalidHomeserverError != null,
|
||||
supportingText = {
|
||||
if (invalidHomeserverError != null) {
|
||||
Text(invalidHomeserverError.message(), color = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
val footerMessage = stringResource(StringR.string.server_selection_server_footer)
|
||||
val footerAction = stringResource(StringR.string.action_learn_more)
|
||||
val footerText = buildAnnotatedString {
|
||||
val defaultColor = MaterialTheme.colorScheme.tertiary
|
||||
withStyle(ParagraphStyle(textAlign = TextAlign.Start)) {
|
||||
withStyle(SpanStyle(color = defaultColor)) {
|
||||
append(footerMessage)
|
||||
append(" ")
|
||||
}
|
||||
val start = length
|
||||
withStyle(SpanStyle(color = LinkColor)) {
|
||||
append(footerAction)
|
||||
}
|
||||
addUrlAnnotation(UrlAnnotation(LoginConstants.SLIDING_SYNC_READ_MORE_URL), start, length)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val footerMessage = stringResource(StringR.string.server_selection_server_footer)
|
||||
val footerAction = stringResource(StringR.string.server_selection_server_footer_action)
|
||||
val footerText = buildAnnotatedString {
|
||||
val defaultColor = MaterialTheme.colorScheme.tertiary
|
||||
withStyle(ParagraphStyle(textAlign = TextAlign.Start)) {
|
||||
withStyle(SpanStyle(color = defaultColor)) {
|
||||
append(footerMessage)
|
||||
append(" ")
|
||||
ClickableLinkText(
|
||||
text = footerText,
|
||||
interactionSource = MutableInteractionSource(),
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
)
|
||||
}
|
||||
val start = length
|
||||
withStyle(SpanStyle(color = LinkColor)) {
|
||||
append(footerAction)
|
||||
}
|
||||
addUrlAnnotation(UrlAnnotation(LoginConstants.SLIDING_SYNC_READ_MORE_URL), start, length)
|
||||
}
|
||||
}
|
||||
ClickableLinkText(
|
||||
text = footerText,
|
||||
interactionSource = MutableInteractionSource(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
|
||||
)
|
||||
if (slidingSyncNotSupportedError != null) {
|
||||
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
|
||||
onLearnMoreClicked()
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
}, onDismiss = {
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = ::submit,
|
||||
|
|
@ -271,9 +268,10 @@ fun ChangeServerView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChangeServerErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||
internal fun ChangeServerErrorDialog(title: String, message: String, onDismiss: () -> Unit) {
|
||||
ErrorDialog(
|
||||
content = stringResource(changeServerError(error)),
|
||||
title = title,
|
||||
content = message,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,3 @@ fun loginError(
|
|||
AuthErrorCode.UNKNOWN -> StringR.unknown_error
|
||||
}
|
||||
}
|
||||
|
||||
fun changeServerError(
|
||||
throwable: Throwable
|
||||
): Int {
|
||||
val authException = throwable as? AuthenticationException ?: return StringR.unknown_error
|
||||
return when (authException) {
|
||||
is AuthenticationException.InvalidServerName -> StringR.login_error_homeserver_not_found
|
||||
else -> StringR.unknown_error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class ChangeServerPresenterTest {
|
|||
assertThat(loadingState.submitEnabled).isFalse()
|
||||
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isTrue()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ class ChangeServerPresenterTest {
|
|||
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
|
||||
awaitItem() // Skip changing the url to the parsed domain
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isTrue()
|
||||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(successState.homeserver).isEqualTo("matrix.org")
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ class ChangeServerPresenterTest {
|
|||
authServer.givenChangeServerError(Throwable())
|
||||
initialState.eventSink.invoke(ChangeServerEvents.Submit)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.submitEnabled).isTrue()
|
||||
assertThat(failureState.submitEnabled).isFalse()
|
||||
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ class ChangeServerPresenterTest {
|
|||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.changeServerAction).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||
assertThat(submittedState.changeServerAction).isInstanceOf(Async.Failure::class.java)
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(ChangeServerEvents.ClearError)
|
||||
|
|
|
|||
|
|
@ -55,44 +55,4 @@ class ErrorFormatterTests {
|
|||
}
|
||||
|
||||
// endregion loginError
|
||||
|
||||
// region changeServerError
|
||||
|
||||
@Test
|
||||
fun `changeServerError - invalid unknown error returns unknown error message`() {
|
||||
val error = Throwable("Some unknown error")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.unknown_error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changeServerError - invalid auth error returns unknown error message`() {
|
||||
val error = AuthenticationException.SlidingSyncNotAvailable("Some message. Also contains M_FORBIDDEN, but won't be parsed")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.unknown_error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changeServerError - unknown error returns unknown error message`() {
|
||||
val error = AuthenticationException.Generic("M_UNKNOWN")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.unknown_error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changeServerError - forbidden error returns unknown error message`() {
|
||||
val error = AuthenticationException.Generic("M_FORBIDDEN")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.unknown_error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changeServerError - user_deactivated error returns unknown error message`() {
|
||||
val error = AuthenticationException.Generic("M_USER_DEACTIVATED")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.unknown_error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changeServerError - invalid server name error returns invalid server name error message`() {
|
||||
val error = AuthenticationException.InvalidServerName("Server is not valid")
|
||||
assertThat(changeServerError(error)).isEqualTo(R.string.login_error_homeserver_not_found)
|
||||
}
|
||||
|
||||
// endregion changeServerError
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,13 @@ sealed interface Async<out T> {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>) {
|
||||
suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>, errorMapping: ((Throwable) -> Throwable)? = null) {
|
||||
try {
|
||||
state.value = Async.Loading()
|
||||
val result = this()
|
||||
state.value = Async.Success(result)
|
||||
} catch (error: Throwable) {
|
||||
state.value = Async.Failure(error)
|
||||
state.value = Async.Failure(errorMapping?.invoke(error) ?: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package io.element.android.libraries.designsystem.components.dialogs
|
|||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -36,8 +35,8 @@ import io.element.android.libraries.ui.strings.R as StringR
|
|||
fun ErrorDialog(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = stringResource(id = StringR.string.dialog_title_error),
|
||||
submitText: String = stringResource(id = StringR.string.ok),
|
||||
title: String = ErrorDialogDefaults.title,
|
||||
submitText: String = ErrorDialogDefaults.submitText,
|
||||
onDismiss: () -> Unit = {},
|
||||
shape: Shape = AlertDialogDefaults.shape,
|
||||
containerColor: Color = AlertDialogDefaults.containerColor,
|
||||
|
|
@ -69,6 +68,11 @@ fun ErrorDialog(
|
|||
)
|
||||
}
|
||||
|
||||
object ErrorDialogDefaults {
|
||||
val title: String @Composable get() = stringResource(id = StringR.string.dialog_title_error)
|
||||
val submitText: String @Composable get() = stringResource(id = StringR.string.ok)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ErrorDialogLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@
|
|||
<string name="login_hide_password">Hide password</string>
|
||||
<string name="ex_choose_server_subtitle">What is the address of your server?</string>
|
||||
<string name="server_selection_server_footer">You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it.</string>
|
||||
<string name="server_selection_server_footer_action">Learn more</string>
|
||||
<string name="server_selection_sliding_sync_alert_title">Server not supported</string>
|
||||
<string name="server_selection_sliding_sync_alert_message">This server currently doesn\'t support sliding sync.</string>
|
||||
<string name="server_selection_invalid_homeserver_error">We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help.</string>
|
||||
<!-- Create room -->
|
||||
<string name="search_for_someone">Search for someone</string>
|
||||
<string name="new_room">New room</string>
|
||||
|
||||
<!-- Room list -->
|
||||
<string name="notice_room_invite_accepted_by_you">You accepted the invite</string>
|
||||
<string name="notice_room_invite_accepted">%1$s accepted the invite</string>
|
||||
|
|
@ -58,5 +59,4 @@
|
|||
<string name="session_verification_start">Continue</string>
|
||||
|
||||
<string name="verification_conclusion_ok_self_notice_title">Verification complete</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5ec4955c5bd390d8f4681f709adcb7152de690bb9033827a9167bb0a4afeed3
|
||||
size 41337
|
||||
oid sha256:47cc4075122ec2d0a1420e6c3c8765f6d28b521927034067a1ff1c877bf069fb
|
||||
size 41214
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ad203c77d755606febd055a852a7288a670abab4bd6d281df1262a4cd02b8bff
|
||||
size 44823
|
||||
oid sha256:b8d776746230925c8b35bb688ae51d3b3eca0ebee6beb9e716dabcbf4ae57361
|
||||
size 44563
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:db98ec6bb8890f95367fb81bb61b592a883f113c92211b69f7d71431af00975a
|
||||
size 44135
|
||||
oid sha256:c22ea865154e6c55cdc04acd4a96c3d0c87e1918c29604d72d37d1b29466ff46
|
||||
size 43877
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:545df3870e1a6034180b6ebb23366a5855105df09ac741fe7a2e8c97e3df5086
|
||||
size 44870
|
||||
oid sha256:95a4f4ce5871c6c65dfd732bde01ea3a80c47fd97a83e306642b4b3821b23b65
|
||||
size 48597
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ad203c77d755606febd055a852a7288a670abab4bd6d281df1262a4cd02b8bff
|
||||
size 44823
|
||||
oid sha256:74483873056b7dab096936e09e07249b84ffebe42619f20a829e9994b3b01ced
|
||||
size 43840
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c2700fd98861f5bb34ee48b24c7ddceea57971fbe9e9b0aa0d5b24edc90cc6a
|
||||
size 43809
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3e0939baaec56f1be550ab960cd66c6e4541060ed36ec487b487d4ffa1c6911a
|
||||
size 38103
|
||||
oid sha256:86c3bc2523fd194a87d062bb122a8d84d412db419c0a9e2f0c6e51ccd338119a
|
||||
size 37809
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0baf660665cdce26b9e0716f1492f007cdf2d09d0989ae400b8677aafee80533
|
||||
size 41924
|
||||
oid sha256:9e0c3a87fb5fc5c3e8c31df3c071bf69b956e0dcfc35f5168452253c9e55b6fd
|
||||
size 41535
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:28c94b5d7d5caf5e5b00b1060c4538422dd50c6f1d8168d2948d9a4b8634f658
|
||||
size 40818
|
||||
oid sha256:f841b8076376b61a21c517e93627992a179ef1042566133367229c4926322279
|
||||
size 40436
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de060d43920f967c80f9795a28e7c84467f86b559ef1e434d605ec4c61f0400e
|
||||
size 41954
|
||||
oid sha256:091e106c67c1a503c98c36a0ed58a3cb08461840643cd2fe2ffc7e1f46ab3774
|
||||
size 47711
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0baf660665cdce26b9e0716f1492f007cdf2d09d0989ae400b8677aafee80533
|
||||
size 41924
|
||||
oid sha256:9496dd89a284f77cb01295a69dcf60ccdb566224560139d13f926e38a0bd226c
|
||||
size 40436
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be6c0687f8ac84831db038a9846aefba81c4a6af08237d89b2cdba5676f31142
|
||||
size 40419
|
||||
Loading…
Add table
Add a link
Reference in a new issue