ganfra
1 week ago
committed by
GitHub
29 changed files with 43 additions and 599 deletions
@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.error |
||||
|
||||
import io.element.android.libraries.core.bool.orFalse |
||||
|
||||
fun Throwable.isWaitListError(): Boolean { |
||||
return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse() |
||||
} |
@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
sealed interface WaitListEvents { |
||||
data object AttemptLogin : WaitListEvents |
||||
data object ClearError : WaitListEvents |
||||
data object Continue : WaitListEvents |
||||
} |
@ -1,52 +0,0 @@
@@ -1,52 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import com.bumble.appyx.core.plugin.plugins |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.anvilannotations.ContributesNode |
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState |
||||
import io.element.android.libraries.architecture.NodeInputs |
||||
import io.element.android.libraries.architecture.inputs |
||||
import io.element.android.libraries.di.AppScope |
||||
|
||||
@ContributesNode(AppScope::class) |
||||
class WaitListNode @AssistedInject constructor( |
||||
@Assisted buildContext: BuildContext, |
||||
@Assisted plugins: List<Plugin>, |
||||
presenterFactory: WaitListPresenter.Factory, |
||||
) : Node(buildContext, plugins = plugins) { |
||||
data class Inputs(val loginFormState: LoginFormState) : NodeInputs |
||||
|
||||
private val inputs: Inputs = inputs() |
||||
private val presenter = presenterFactory.create(inputs.loginFormState) |
||||
|
||||
interface Callback : Plugin { |
||||
fun onCancelClick() |
||||
} |
||||
|
||||
private fun onCancelClick() { |
||||
plugins<Callback>().forEach { it.onCancelClick() } |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
val state = presenter.present() |
||||
WaitListView( |
||||
state = state, |
||||
onCancelClick = ::onCancelClick, |
||||
modifier = modifier |
||||
) |
||||
} |
||||
} |
@ -1,87 +0,0 @@
@@ -1,87 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableIntStateOf |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.rememberCoroutineScope |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.features.login.impl.DefaultLoginUserStory |
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState |
||||
import io.element.android.libraries.architecture.AsyncData |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import io.element.android.libraries.core.meta.BuildMeta |
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService |
||||
import io.element.android.libraries.matrix.api.core.SessionId |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.launch |
||||
import timber.log.Timber |
||||
|
||||
class WaitListPresenter @AssistedInject constructor( |
||||
@Assisted private val formState: LoginFormState, |
||||
private val buildMeta: BuildMeta, |
||||
private val authenticationService: MatrixAuthenticationService, |
||||
private val defaultLoginUserStory: DefaultLoginUserStory, |
||||
) : Presenter<WaitListState> { |
||||
@AssistedFactory |
||||
interface Factory { |
||||
fun create(loginFormState: LoginFormState): WaitListPresenter |
||||
} |
||||
|
||||
@Composable |
||||
override fun present(): WaitListState { |
||||
val coroutineScope = rememberCoroutineScope() |
||||
val homeserverUrl = remember { |
||||
authenticationService.getHomeserverDetails().value?.url ?: "server" |
||||
} |
||||
|
||||
val loginAction: MutableState<AsyncData<SessionId>> = remember { |
||||
mutableStateOf(AsyncData.Uninitialized) |
||||
} |
||||
|
||||
val attemptNumber = remember { mutableIntStateOf(0) } |
||||
|
||||
fun handleEvents(event: WaitListEvents) { |
||||
when (event) { |
||||
WaitListEvents.AttemptLogin -> { |
||||
// Do not attempt to login on first resume of the View. |
||||
attemptNumber.intValue++ |
||||
if (attemptNumber.intValue > 1) { |
||||
coroutineScope.loginAttempt(formState, loginAction) |
||||
} |
||||
} |
||||
WaitListEvents.ClearError -> loginAction.value = AsyncData.Uninitialized |
||||
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true) |
||||
} |
||||
} |
||||
|
||||
return WaitListState( |
||||
appName = buildMeta.applicationName, |
||||
serverName = homeserverUrl, |
||||
loginAction = loginAction.value, |
||||
eventSink = ::handleEvents |
||||
) |
||||
} |
||||
|
||||
private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<AsyncData<SessionId>>) = launch { |
||||
Timber.w("Attempt to login...") |
||||
loggedInState.value = AsyncData.Loading() |
||||
authenticationService.login(formState.login.trim(), formState.password) |
||||
.onSuccess { sessionId -> |
||||
loggedInState.value = AsyncData.Success(sessionId) |
||||
} |
||||
.onFailure { failure -> |
||||
loggedInState.value = AsyncData.Failure(failure) |
||||
} |
||||
} |
||||
} |
@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import io.element.android.libraries.architecture.AsyncData |
||||
import io.element.android.libraries.matrix.api.core.SessionId |
||||
|
||||
// Do not use default value, so no member get forgotten in the presenters. |
||||
data class WaitListState( |
||||
val appName: String, |
||||
val serverName: String, |
||||
val loginAction: AsyncData<SessionId>, |
||||
val eventSink: (WaitListEvents) -> Unit |
||||
) |
@ -1,35 +0,0 @@
@@ -1,35 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.libraries.architecture.AsyncData |
||||
import io.element.android.libraries.matrix.api.core.SessionId |
||||
|
||||
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> { |
||||
override val values: Sequence<WaitListState> |
||||
get() = sequenceOf( |
||||
aWaitListState(loginAction = AsyncData.Uninitialized), |
||||
aWaitListState(loginAction = AsyncData.Loading()), |
||||
aWaitListState(loginAction = AsyncData.Failure(Throwable("error"))), |
||||
aWaitListState(loginAction = AsyncData.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))), |
||||
aWaitListState(loginAction = AsyncData.Success(SessionId("@alice:element.io"))), |
||||
// Add other state here |
||||
) |
||||
} |
||||
|
||||
fun aWaitListState( |
||||
appName: String = "Element X", |
||||
serverName: String = "server.org", |
||||
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized, |
||||
) = WaitListState( |
||||
appName = appName, |
||||
serverName = serverName, |
||||
loginAction = loginAction, |
||||
eventSink = {} |
||||
) |
@ -1,143 +0,0 @@
@@ -1,143 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.material3.LocalContentColor |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.lifecycle.Lifecycle |
||||
import io.element.android.compound.theme.ElementTheme |
||||
import io.element.android.features.login.impl.R |
||||
import io.element.android.features.login.impl.error.isWaitListError |
||||
import io.element.android.features.login.impl.error.loginError |
||||
import io.element.android.libraries.architecture.AsyncData |
||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage |
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.designsystem.theme.components.Button |
||||
import io.element.android.libraries.designsystem.theme.components.TextButton |
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
|
||||
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425 |
||||
// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode. |
||||
@Composable |
||||
fun WaitListView( |
||||
state: WaitListState, |
||||
onCancelClick: () -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
OnLifecycleEvent { _, event -> |
||||
when (event) { |
||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
else -> Unit |
||||
} |
||||
} |
||||
WaitListContent(state, onCancelClick, modifier) |
||||
} |
||||
|
||||
@Composable |
||||
private fun WaitListError(state: WaitListState) { |
||||
// Display a dialog for error other than the waitlist error |
||||
state.loginAction.errorOrNull()?.let { error -> |
||||
if (error.isWaitListError().not()) { |
||||
RetryDialog( |
||||
content = stringResource(id = loginError(error)), |
||||
onRetry = { |
||||
state.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
}, |
||||
onDismiss = { |
||||
state.eventSink.invoke(WaitListEvents.ClearError) |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun WaitListContent( |
||||
state: WaitListState, |
||||
onCancelClick: () -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
Box( |
||||
modifier = modifier.fillMaxSize(), |
||||
) { |
||||
val title = stringResource( |
||||
when (state.loginAction) { |
||||
is AsyncData.Success -> R.string.screen_waitlist_title_success |
||||
else -> R.string.screen_waitlist_title |
||||
} |
||||
) |
||||
val subtitle = when (state.loginAction) { |
||||
is AsyncData.Success -> stringResource( |
||||
id = R.string.screen_waitlist_message_success, |
||||
state.appName, |
||||
) |
||||
else -> stringResource( |
||||
id = R.string.screen_waitlist_message, |
||||
state.appName, |
||||
state.serverName, |
||||
) |
||||
} |
||||
SunsetPage( |
||||
isLoading = state.loginAction.isLoading(), |
||||
title = title, |
||||
subtitle = subtitle, |
||||
) { |
||||
OverallContent(state, onCancelClick) |
||||
} |
||||
WaitListError(state) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun OverallContent( |
||||
state: WaitListState, |
||||
onCancelClick: () -> Unit, |
||||
) { |
||||
Box(modifier = Modifier.fillMaxSize()) { |
||||
if (state.loginAction !is AsyncData.Success) { |
||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) { |
||||
TextButton( |
||||
text = stringResource(CommonStrings.action_cancel), |
||||
onClick = onCancelClick, |
||||
) |
||||
} |
||||
} |
||||
if (state.loginAction is AsyncData.Success) { |
||||
Button( |
||||
text = stringResource(id = CommonStrings.action_continue), |
||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) }, |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.align(Alignment.BottomCenter) |
||||
.padding(bottom = 8.dp), |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview { |
||||
WaitListView( |
||||
state = state, |
||||
onCancelClick = {}, |
||||
) |
||||
} |
@ -1,114 +0,0 @@
@@ -1,114 +0,0 @@
|
||||
/* |
||||
* Copyright 2023, 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen |
||||
|
||||
import app.cash.molecule.RecompositionMode |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.features.login.impl.DefaultLoginUserStory |
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState |
||||
import io.element.android.libraries.architecture.AsyncData |
||||
import io.element.android.libraries.matrix.api.core.SessionId |
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER |
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL |
||||
import io.element.android.libraries.matrix.test.A_THROWABLE |
||||
import io.element.android.libraries.matrix.test.A_USER_ID |
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService |
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta |
||||
import io.element.android.tests.testutils.WarmUpRule |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
class WaitListPresenterTest { |
||||
@get:Rule |
||||
val warmUpRule = WarmUpRule() |
||||
|
||||
@Test |
||||
fun `present - initial state`() = runTest { |
||||
val authenticationService = FakeMatrixAuthenticationService().apply { |
||||
givenHomeserver(A_HOMESERVER) |
||||
} |
||||
val loginUserStory = DefaultLoginUserStory() |
||||
val presenter = WaitListPresenter( |
||||
LoginFormState.Default, |
||||
aBuildMeta(applicationName = "Application Name"), |
||||
authenticationService, |
||||
loginUserStory, |
||||
) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.appName).isEqualTo("Application Name") |
||||
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL) |
||||
assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - attempt login with error`() = runTest { |
||||
val authenticationService = FakeMatrixAuthenticationService().apply { |
||||
givenLoginError(A_THROWABLE) |
||||
} |
||||
val loginUserStory = DefaultLoginUserStory() |
||||
val presenter = WaitListPresenter( |
||||
LoginFormState.Default, |
||||
aBuildMeta(), |
||||
authenticationService, |
||||
loginUserStory, |
||||
) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
// First usage of AttemptLogin, nothing should happen |
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
expectNoEvents() |
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
val submitState = awaitItem() |
||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) |
||||
val errorState = awaitItem() |
||||
assertThat(errorState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE)) |
||||
// Assert the error can be cleared |
||||
errorState.eventSink(WaitListEvents.ClearError) |
||||
val clearedState = awaitItem() |
||||
assertThat(clearedState.loginAction).isEqualTo(AsyncData.Uninitialized) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - attempt login with success`() = runTest { |
||||
val authenticationService = FakeMatrixAuthenticationService() |
||||
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } |
||||
val presenter = WaitListPresenter( |
||||
LoginFormState.Default, |
||||
aBuildMeta(), |
||||
authenticationService, |
||||
loginUserStory, |
||||
) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse() |
||||
val initialState = awaitItem() |
||||
// First usage of AttemptLogin, nothing should happen |
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
expectNoEvents() |
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin) |
||||
val submitState = awaitItem() |
||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) |
||||
val successState = awaitItem() |
||||
assertThat(successState.loginAction).isEqualTo(AsyncData.Success(A_USER_ID)) |
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse() |
||||
successState.eventSink.invoke(WaitListEvents.Continue) |
||||
assertThat(loginUserStory.loginFlowIsDone.value).isTrue() |
||||
} |
||||
} |
||||
} |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c |
||||
size 140926 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:8524bdd37ac6eb784d82041b572d9cf3bb69cf3de318ba1d8abc45e9a239dad1 |
||||
size 141726 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:a4fd2455de9a763e582e4977e36686c714dfb380737fc6193b4eab9ef8b64de9 |
||||
size 58641 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c |
||||
size 140926 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:f7880a67dd818c44b519be634a4a33a4a0efc842f9ad5e4879f16acb7c8b1f5f |
||||
size 122622 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf |
||||
size 138058 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:ef852707489db29300b4bf8259a5d71d3c82ce26bcea3ddb00d037b1ba379423 |
||||
size 138901 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:5946208785bcc7eff5e76048cd467e9bde6f4ff5874ef2dd2562d5bbb0844f93 |
||||
size 59177 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf |
||||
size 138058 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:3b4fe8418f426d9953189908053407550ad6d83d45a548253d75507321f686c9 |
||||
size 120721 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:3d81ad9f5a5983653d9266a2500b2c71dc7ca231d52a62d1c1f1b091849ede32 |
||||
size 165501 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:1cb4f2cbdc2b7fca643a9debff50206d4aa9321894a86149d6c815fd4db7b593 |
||||
size 166084 |
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:3b942ce79745a26aa2d4227c35aaad442b35c2f768915e3d9fabf45a04cd1d82 |
||||
size 61267 |
Loading…
Reference in new issue