Merge pull request #1648 from vector-im/feature/bma/secureBackup

Secure backup
This commit is contained in:
Benoit Marty 2023-10-30 21:29:54 +01:00 committed by GitHub
commit 63cf5ef5b1
77 changed files with 284 additions and 158 deletions

View file

@ -14,9 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.securebackup.impl package io.element.android.appconfig
// TODO Move to appconfig module when it will be available
object SecureBackupConfig { object SecureBackupConfig {
const val LearnMoreUrl: String = "https://element.io/help#encryption5" const val LearnMoreUrl: String = "https://element.io/help#encryption5"
} }

View file

@ -58,6 +58,7 @@ class LogoutNode @AssistedInject constructor(
state = state, state = state,
onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked, onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked,
onSuccessLogout = { onSuccessLogout(activity, it) }, onSuccessLogout = { onSuccessLogout(activity, it) },
onBackClicked = ::navigateUp,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,13 +47,15 @@ class LogoutPresenter @Inject constructor(
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
val backupUploadState by encryptionService.backupUploadStateStateFlow.collectAsState() val backupUploadState: BackupUploadState by remember {
encryptionService.waitForBackupUploadSteadyState()
}
.collectAsState(initial = BackupUploadState.Unknown)
var showLogoutDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) }
var isLastSession by remember { mutableStateOf(false) } var isLastSession by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
isLastSession = encryptionService.isLastDevice().getOrNull() ?: false isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
encryptionService.waitForBackupUploadSteadyState()
} }
fun handleEvents(event: LogoutEvents) { fun handleEvents(event: LogoutEvents) {

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -32,6 +33,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.ProgressDialog 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.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -39,16 +41,19 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogoutView( fun LogoutView(
state: LogoutState, state: LogoutState,
onChangeRecoveryKeyClicked: () -> Unit, onChangeRecoveryKeyClicked: () -> Unit,
onBackClicked: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -56,6 +61,12 @@ fun LogoutView(
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackClicked) },
title = {},
)
},
header = { header = {
HeaderContent(state = state) HeaderContent(state = state)
}, },
@ -134,7 +145,7 @@ private fun HeaderContent(
else -> null else -> null
} }
val paddingTop = 60.dp val paddingTop = 0.dp
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = modifier.padding(top = paddingTop), modifier = modifier.padding(top = paddingTop),
iconResourceId = CommonDrawables.ic_key, iconResourceId = CommonDrawables.ic_key,
@ -219,6 +230,7 @@ internal fun LogoutViewPreview(
LogoutView( LogoutView(
state, state,
onChangeRecoveryKeyClicked = {}, onChangeRecoveryKeyClicked = {},
onSuccessLogout = {} onSuccessLogout = {},
onBackClicked = {},
) )
} }

View file

@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -73,6 +75,15 @@ class LogoutPresenterTest {
@Test @Test
fun `present - initial state - backing up`() = runTest { fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
encryptionService.givenWaitForBackupUploadSteadyStateFlow(
flow {
emit(BackupUploadState.Waiting)
delay(1)
emit(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
delay(1)
emit(BackupUploadState.Done)
}
)
val presenter = createLogoutPresenter( val presenter = createLogoutPresenter(
encryptionService = encryptionService encryptionService = encryptionService
) )
@ -84,10 +95,10 @@ class LogoutPresenterTest {
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.showConfirmationDialog).isFalse() assertThat(initialState.showConfirmationDialog).isFalse()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
encryptionService.emitBackupUploadState(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) val waitingState = awaitItem()
val state = awaitItem() assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
assertThat(state.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) val uploadingState = awaitItem()
encryptionService.emitBackupUploadState(BackupUploadState.Done) assertThat(uploadingState.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
val doneState = awaitItem() val doneState = awaitItem()
assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done) assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done)
} }

View file

@ -52,8 +52,8 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -190,19 +190,22 @@ private fun RoomListContent(
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
state = lazyListState, state = lazyListState,
) { ) {
if (state.displayVerificationPrompt) { when {
item { state.displayVerificationPrompt -> {
RequestVerificationHeader( item {
onVerifyClicked = onVerifyClicked, RequestVerificationHeader(
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } onVerifyClicked = onVerifyClicked,
) onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
} }
} else if (state.displayRecoveryKeyPrompt) { state.displayRecoveryKeyPrompt -> {
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClicked = onOpenSettings, onContinueClicked = onOpenSettings,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
) )
}
} }
} }

View file

@ -33,6 +33,7 @@ dependencies {
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.appconfig)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)

View file

@ -40,6 +40,7 @@ class SecureBackupDisableNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onDone = ::navigateUp, onDone = ::navigateUp,
onBackClicked = ::navigateUp,
) )
} }
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -33,6 +34,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -40,13 +42,16 @@ 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.Button
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SecureBackupDisableView( fun SecureBackupDisableView(
state: SecureBackupDisableState, state: SecureBackupDisableState,
onDone: () -> Unit, onDone: () -> Unit,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LaunchedEffect(state.disableAction) { LaunchedEffect(state.disableAction) {
@ -56,6 +61,12 @@ fun SecureBackupDisableView(
} }
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackClicked) },
title = {},
)
},
header = { header = {
HeaderContent() HeaderContent()
}, },
@ -95,7 +106,7 @@ private fun HeaderContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp), modifier = modifier.padding(top = 0.dp),
iconResourceId = CommonDrawables.ic_key_off, iconResourceId = CommonDrawables.ic_key_off,
title = stringResource(id = R.string.screen_key_backup_disable_title), title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description), subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
@ -158,5 +169,6 @@ internal fun SecureBackupDisableViewPreview(
SecureBackupDisableView( SecureBackupDisableView(
state = state, state = state,
onDone = {}, onDone = {},
onBackClicked = {},
) )
} }

View file

@ -40,6 +40,7 @@ class SecureBackupEnableNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onDone = ::navigateUp, onDone = ::navigateUp,
onBackClicked = ::navigateUp,
) )
} }
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.securebackup.impl.enable
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -29,16 +30,20 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SecureBackupEnableView( fun SecureBackupEnableView(
state: SecureBackupEnableState, state: SecureBackupEnableState,
onDone: () -> Unit, onDone: () -> Unit,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LaunchedEffect(state.enableAction) { LaunchedEffect(state.enableAction) {
@ -48,6 +53,12 @@ fun SecureBackupEnableView(
} }
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackClicked) },
title = {},
)
},
header = { header = {
HeaderContent() HeaderContent()
}, },
@ -68,7 +79,7 @@ private fun HeaderContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp), modifier = modifier.padding(top = 0.dp),
iconResourceId = CommonDrawables.ic_key, iconResourceId = CommonDrawables.ic_key,
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
subTitle = null, subTitle = null,
@ -99,5 +110,6 @@ internal fun SecureBackupEnableViewPreview(
SecureBackupEnableView( SecureBackupEnableView(
state = state, state = state,
onDone = {}, onDone = {},
onBackClicked = {},
) )
} }

View file

@ -50,7 +50,8 @@ class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
onDone = { onDone = {
coroutineScope.postSuccessSnackbar() coroutineScope.postSuccessSnackbar()
navigateUp() navigateUp()
} },
onBackClicked = ::navigateUp,
) )
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.securebackup.impl.enter
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -30,17 +31,21 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SecureBackupEnterRecoveryKeyView( fun SecureBackupEnterRecoveryKeyView(
state: SecureBackupEnterRecoveryKeyState, state: SecureBackupEnterRecoveryKeyState,
onDone: () -> Unit, onDone: () -> Unit,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (state.submitAction) { when (state.submitAction) {
@ -59,6 +64,12 @@ fun SecureBackupEnterRecoveryKeyView(
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackClicked) },
title = {},
)
},
header = { header = {
HeaderContent() HeaderContent()
}, },
@ -75,6 +86,9 @@ fun SecureBackupEnterRecoveryKeyView(
state = state, state = state,
onChange = { onChange = {
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange(it)) state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange(it))
},
onSubmit = {
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit)
}) })
} }
} }
@ -84,7 +98,7 @@ private fun HeaderContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp), modifier = modifier.padding(top = 0.dp),
iconResourceId = CommonDrawables.ic_key, iconResourceId = CommonDrawables.ic_key,
title = stringResource(id = R.string.screen_recovery_key_confirm_title), title = stringResource(id = R.string.screen_recovery_key_confirm_title),
subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description), subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description),
@ -112,13 +126,15 @@ private fun BottomMenu(
@Composable @Composable
private fun Content( private fun Content(
state: SecureBackupEnterRecoveryKeyState, state: SecureBackupEnterRecoveryKeyState,
onChange: ((String) -> Unit)?, onChange: (String) -> Unit,
onSubmit: () -> Unit,
) { ) {
RecoveryKeyView( RecoveryKeyView(
modifier = Modifier.padding(top = 52.dp), modifier = Modifier.padding(top = 52.dp),
state = state.recoveryKeyViewState, state = state.recoveryKeyViewState,
onClick = null, onClick = null,
onChange = onChange, onChange = onChange,
onSubmit = onSubmit,
) )
} }
@ -130,5 +146,6 @@ internal fun SecureBackupEnterRecoveryKeyViewPreview(
SecureBackupEnterRecoveryKeyView( SecureBackupEnterRecoveryKeyView(
state = state, state = state,
onDone = {}, onDone = {},
onBackClicked = {},
) )
} }

View file

@ -27,7 +27,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.impl.SecureBackupConfig import io.element.android.appconfig.SecureBackupConfig
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)

View file

@ -60,6 +60,7 @@ class SecureBackupSetupNode @AssistedInject constructor(
coroutineScope.postSuccessSnackbar() coroutineScope.postSuccessSnackbar()
navigateUp() navigateUp()
}, },
onBackClicked = ::navigateUp,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -16,8 +16,10 @@
package io.element.android.features.securebackup.impl.setup package io.element.android.features.securebackup.impl.setup
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -32,24 +34,42 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SecureBackupSetupView( fun SecureBackupSetupView(
state: SecureBackupSetupState, state: SecureBackupSetupState,
onDone: () -> Unit, onDone: () -> Unit,
onBackClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val canGoBack = state.canGoBack()
BackHandler(enabled = canGoBack) {
onBackClicked()
}
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
if (canGoBack) {
BackButton(onClick = onBackClicked)
}
},
title = {},
)
},
header = { header = {
HeaderContent(state = state) HeaderContent(state = state)
}, },
@ -109,6 +129,10 @@ fun SecureBackupSetupView(
} }
} }
private fun SecureBackupSetupState.canGoBack(): Boolean {
return recoveryKeyViewState.formattedRecoveryKey == null
}
@Composable @Composable
private fun HeaderContent( private fun HeaderContent(
state: SecureBackupSetupState, state: SecureBackupSetupState,
@ -136,7 +160,7 @@ private fun HeaderContent(
stringResource(id = R.string.screen_recovery_key_save_description) stringResource(id = R.string.screen_recovery_key_save_description)
} }
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp), modifier = modifier.padding(top = 0.dp),
iconResourceId = CommonDrawables.ic_key, iconResourceId = CommonDrawables.ic_key,
title = title, title = title,
subTitle = subTitle, subTitle = subTitle,
@ -192,6 +216,7 @@ private fun Content(
state = state, state = state,
onClick = onClick, onClick = onClick,
onChange = null, onChange = null,
onSubmit = null,
) )
} }
@ -203,5 +228,6 @@ internal fun SecureBackupSetupViewPreview(
SecureBackupSetupView( SecureBackupSetupView(
state = state, state = state,
onDone = {}, onDone = {},
onBackClicked = {},
) )
} }

View file

@ -33,5 +33,6 @@ internal fun SecureBackupSetupViewChangePreview(
recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change), recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change),
), ),
onDone = {}, onDone = {},
onBackClicked = {},
) )
} }

View file

@ -25,12 +25,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -52,6 +55,7 @@ internal fun RecoveryKeyView(
state: RecoveryKeyViewState, state: RecoveryKeyViewState,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?, onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -63,7 +67,7 @@ internal fun RecoveryKeyView(
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
) )
RecoveryKeyContent(state, onClick, onChange) RecoveryKeyContent(state, onClick, onChange, onSubmit)
RecoveryKeyFooter(state) RecoveryKeyFooter(state)
} }
} }
@ -73,11 +77,12 @@ private fun RecoveryKeyContent(
state: RecoveryKeyViewState, state: RecoveryKeyViewState,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?, onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
) { ) {
when (state.recoveryKeyUserStory) { when (state.recoveryKeyUserStory) {
RecoveryKeyUserStory.Setup, RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> RecoveryKeyStaticContent(state, onClick) RecoveryKeyUserStory.Change -> RecoveryKeyStaticContent(state, onClick)
RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent(state, onChange) RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent(state, onChange, onSubmit)
} }
} }
@ -143,8 +148,13 @@ private fun RecoveryKeyStaticContent(
} }
@Composable @Composable
private fun RecoveryKeyFormContent(state: RecoveryKeyViewState, onChange: ((String) -> Unit)?) { private fun RecoveryKeyFormContent(
state: RecoveryKeyViewState,
onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
) {
onChange ?: error("onChange should not be null") onChange ?: error("onChange should not be null")
onSubmit ?: error("onSubmit should not be null")
val recoveryKeyVisualTransformation = remember { val recoveryKeyVisualTransformation = remember {
RecoveryKeyVisualTransformation() RecoveryKeyVisualTransformation()
} }
@ -155,6 +165,12 @@ private fun RecoveryKeyFormContent(state: RecoveryKeyViewState, onChange: ((Stri
onValueChange = onChange, onValueChange = onChange,
enabled = state.inProgress.not(), enabled = state.inProgress.not(),
visualTransformation = recoveryKeyVisualTransformation, visualTransformation = recoveryKeyVisualTransformation,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmit() }
),
label = { Text(text = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder)) } label = { Text(text = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder)) }
) )
} }
@ -217,5 +233,6 @@ internal fun RecoveryKeyViewPreview(
state = state, state = state,
onClick = {}, onClick = {},
onChange = {}, onChange = {},
onSubmit = {},
) )
} }

View file

@ -16,12 +16,12 @@
package io.element.android.libraries.matrix.api.encryption package io.element.android.libraries.matrix.api.encryption
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface EncryptionService { interface EncryptionService {
val backupStateStateFlow: StateFlow<BackupState> val backupStateStateFlow: StateFlow<BackupState>
val recoveryStateStateFlow: StateFlow<RecoveryState> val recoveryStateStateFlow: StateFlow<RecoveryState>
val backupUploadStateStateFlow: StateFlow<BackupUploadState>
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress> val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
suspend fun enableBackups(): Result<Unit> suspend fun enableBackups(): Result<Unit>
@ -46,7 +46,7 @@ interface EncryptionService {
suspend fun fixRecoveryIssues(recoveryKey: String): Result<Unit> suspend fun fixRecoveryIssues(recoveryKey: String): Result<Unit>
/** /**
* Observe [backupUploadStateStateFlow] to get progress. * Wait for backup upload steady state.
*/ */
suspend fun waitForBackupUploadSteadyState(): Result<Unit> fun waitForBackupUploadSteadyState(): Flow<BackupUploadState>
} }

View file

@ -22,7 +22,10 @@ import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackupStateListener import org.matrix.rustcomponents.sdk.BackupStateListener
import org.matrix.rustcomponents.sdk.BackupSteadyStateListener import org.matrix.rustcomponents.sdk.BackupSteadyStateListener
@ -50,7 +53,6 @@ internal class RustEncryptionService(
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(service.backupState().let(backupStateMapper::map)) override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(service.backupState().let(backupStateMapper::map))
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(service.recoveryState().let(recoveryStateMapper::map)) override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(service.recoveryState().let(recoveryStateMapper::map))
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown) override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown)
override val backupUploadStateStateFlow: MutableStateFlow<BackupUploadState> = MutableStateFlow(BackupUploadState.Unknown)
fun start() { fun start() {
service.backupStateListener(object : BackupStateListener { service.backupStateListener(object : BackupStateListener {
@ -94,16 +96,19 @@ internal class RustEncryptionService(
} }
} }
override suspend fun waitForBackupUploadSteadyState( override fun waitForBackupUploadSteadyState(): Flow<BackupUploadState> {
): Result<Unit> = withContext(dispatchers.io) { return callbackFlow {
runCatching {
service.waitForBackupUploadSteadyState( service.waitForBackupUploadSteadyState(
progressListener = object : BackupSteadyStateListener { progressListener = object : BackupSteadyStateListener {
override fun onUpdate(status: RustBackupUploadState) { override fun onUpdate(status: RustBackupUploadState) {
backupUploadStateStateFlow.value = backupUploadStateMapper.map(status) trySend(backupUploadStateMapper.map(status))
if (status == RustBackupUploadState.Done) {
close()
}
} }
} }
) )
awaitClose {}
} }
} }

View file

@ -22,14 +22,16 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
class FakeEncryptionService : EncryptionService { class FakeEncryptionService : EncryptionService {
private var disableRecoveryFailure: Exception? = null private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN) override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN) override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown) override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Unknown)
override val backupUploadStateStateFlow: MutableStateFlow<BackupUploadState> = MutableStateFlow(BackupUploadState.Unknown) private var waitForBackupUploadSteadyStateFlow: Flow<BackupUploadState> = flowOf()
private var fixRecoveryIssuesFailure: Exception? = null private var fixRecoveryIssuesFailure: Exception? = null
@ -73,12 +75,12 @@ class FakeEncryptionService : EncryptionService {
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun waitForBackupUploadSteadyState(): Result<Unit> { fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) {
return Result.success(Unit) waitForBackupUploadSteadyStateFlow = flow
} }
suspend fun emitBackupUploadState(state: BackupUploadState) { override fun waitForBackupUploadSteadyState(): Flow<BackupUploadState> {
backupUploadStateStateFlow.emit(state) return waitForBackupUploadSteadyStateFlow
} }
suspend fun emitBackupState(state: BackupState) { suspend fun emitBackupState(state: BackupState) {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c8c36602ad1aa6a62cae8f69b4e0a185715a4105a958a17451e3f5573618a499 oid sha256:75e44003e66f73983d63b0735217a3f99d1ef2af5778d53e256ab3c644c59cb8
size 28590 size 29114

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:30777b75b1464bbaf0ab63c73189ae8d6b380d677ae87c22ad59a9052dbda564 oid sha256:1a9b3f53a32a0924ae1db60f01df80e1e098d332ae29fdbfda527fb69b6a8860
size 32530 size 33097

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:23d11018a66dcfca97713d7dfd5916a1c00f01881c21aad6014101a17b5b8912 oid sha256:1807fd9e347c29c853d925c28f1e00259865eb9e7990fca1ad63e90a9eea8a1d
size 30167 size 30676

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8b3b64d82224c1e28cfad0d5ac81796a2461311d63d23e84f9bf19b290b08bcd oid sha256:0c140c29ad4a849893e08ed5b1943346ebc7ae5b888fb98b97088d2a5f025087
size 37255 size 37967

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:aeee9a02b5bfa15ff7dc2e45f374237b2fa198df9e3a0a92598a950af7431a5b oid sha256:d329bff55f22efa99e8afa20775a25f4c7e717cf5797ecc7a2c0f47e4cc1f69a
size 34809 size 34937

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5437f1dd640de8500ce78fe810a92c5174879918b592e746f09a7244b4b98d74 oid sha256:b72c4ecdb641d2adf53c092ac1ccc85018a747ffb0f4b911d3b10db6524ea897
size 31993 size 31588

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b3c05dd55dfd945a7c7528492d47698586089b618ae6ab811ae188a808a0a4b8 oid sha256:704aac22e5063a3cc9b831c9079f1348385a1251c91630575200c33528fe3d76
size 33355 size 33480

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39091e9262d0d57d51468ae809598d44829a2a33759e62f18154444c0c614f50 oid sha256:ac0f94f62f6d89475fde477b095dc1242a606d9cd4bfa45a8cfaa8e368e22707
size 26893 size 27385

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:424a67c305485dc3d151d7771df9b9e724aa076e3758dc9cb341b86184163a39 oid sha256:9f843cb5d7bceebbc6508257f0410af14242b23407ecea4643ab8a75329acca2
size 30551 size 30994

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2a1e679206fef656da4fbcba44220cc169d45e01faf74c6bdb174a807ccf89a2 oid sha256:d9690fdef2aaabadb890c8649195de83cd60306f109346d292951ff6694037bd
size 28529 size 28955

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0f1a452799ce1f0055c17ec86a5dabe7d3f5f5688b15bc9e9f5cdde7c39774b7 oid sha256:c75561844b2e5d163c7cdb49a36eb4f3adb4119fda2b9e4b6d9552daf447b907
size 35541 size 35817

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:edf4ff593991f2cc88999034ff5ba80599d48ced6d23825a385ecf82d61d4686 oid sha256:996388f47d4cd4c7c6318d335204a1b3610272ea1b340e4e36f10da222f3bcb6
size 30383 size 30476

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:63b528173b275d15fb8c0ec5d173b63636f24a3897227f4fb37e06a817a7521e oid sha256:aa9f65a1f2e08e65f21aae2b4b6fb17b980262196443aec6b9cbc6c912077c78
size 28881 size 28497

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5adffcc0b87707daba799293ec36b9e8202c72de2248835ed593b87fc82ea9b4 oid sha256:9f63c93ba36566926aa471375f0bb341e1c989e5b1be9090f771597f91a7a226
size 29168 size 29258

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b042f7e1248b739be1b0b96782d3970c0bfafa06c48ca507acbe882df471de3 oid sha256:83e046cae9a59996697172a964c55c53a35ab22404b46cb2fdbf45956254d36d
size 58735 size 59003

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6df1c5767b684843e39d31e6056133e72eb76d5d229db0d7176ff1be02852880 oid sha256:576dc46e025bfa281a8f160315c7df052f8e9de85d1894a4a6b630eb10b4fad6
size 48100 size 47781

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d2c79522043f06f1a2d5810b1e2206d8b6d2ccea18389fb9a11a18887dc2d895 oid sha256:4335f4f058d36b6cf9364ee2135fe692783c3a73302ad7267599ca0d62f20fd5
size 59411 size 59675

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9f5e1a5524453cc1814322315bde1180aa1e26f3f2bf22ec5e797c8767bd8675 oid sha256:4ec952a7c1f8e032689bd045e7bf094987ef75e7ae63357ba247fbce087ff843
size 30634 size 30449

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:49f4d57ff2771fb32f198b506f0a0b7b7fd4f8f0af7f5b78c2369dfaec2e6543 oid sha256:17747da89c21acc64c3b869d1aaea3ce6308139e8e505a973e6190cd2b48e6ed
size 57060 size 57249

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:97c8f3ca9f5f9f4c89930b1279261125a548d5770a361f0f9749904212bb3fb3 oid sha256:b5c652cb90b4e8d5849270ba732417c0dc9cbe5e1f84dcf6916dfad43f712dff
size 42305 size 41926

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d34567f158c2480ba0c6884663b8c996d4754e069f558103b8de90a25a22a8e0 oid sha256:5e68b8e510524f04f4f764e0fd33b2b0b3e395bd87eeae0e467007bf60ab2708
size 57550 size 57739

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d50b5caf3f62d349985102f951a6bf679923b3726f420c6d55e128d6763d5b40 oid sha256:4e3b46d43c8a9ce48c7226fc3159d1ec72fd55cbf7dd5b4de866fb4081e52116
size 27043 size 26804

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8ffce711923870d9b43a9d4c2871ac1794d3d7e2b34a2063ab6f9e56c4e22dc0 oid sha256:8a86b726999a4d994fa4f7a778e4f80b122e912c5c15285ea91fd8814d8eb743
size 15203 size 15694

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5ef1cae71114c56ad6173cdeef356da56ce2769d5bd4a1332133f048d241a8d1 oid sha256:5778ff8908d601c853a0eef84ce72026baa7ca461b8dc5b2c30e48c95153bc91
size 15777 size 16278

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:82d71934698b4e25afaaf9024c5dbb0571c3f8fd3270b5b07ff8d8dd9045bdd9 oid sha256:e4b919c589d65d61c7ee11588ff628e02603cd3e4c5fc79a672d3991d64fcebd
size 21841 size 22276

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4036acde06bd1fb9e256a97b53a6711ec45db4b67141b01231b491512608e457 oid sha256:01653b0165eaf33b77948e41899cb21177fa70d9101eb04ad43ee7c7f4ab3607
size 14271 size 14718

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1d4d58607c126ae4a01a0ba62e09666fac11d3a2b05596742bc4e25cf75696fb oid sha256:39d22d5c0e0b88869900ddf28de055188c17c3e59dfa528a2d81b552ebb140a3
size 14830 size 15282

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e10389377cd21f189794e8a91cecee43b3c44a0030d5f312558dfef7add80dd1 oid sha256:2d4856648cb21a768d174fe1ab6675be9c1c565e1d535cd3fe7d92d3f6c05d1c
size 18548 size 18970

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:90d7dab73ed32794eceac418541c2cf851d1e8bae0fdd5c34f8d9f0425890dc1 oid sha256:05af89ec739d652391d64c56db2d5704075c34b8392386bfa6ca819b863b2fa0
size 33998 size 34283

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e9949ae01bf9aa195e88cb4d06ec0abd61e4bc3dee402f0cdf6617b7559ec318 oid sha256:ad0763e5588212f988a56f1f2b3558c29b332ce832cb9ebaaffcbcebab4d457c
size 46006 size 46477

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:386e41bb476411e1836be260ae309361b1493a914508484fda618783dffae00d oid sha256:383a54396c54861554b53c95700ac49e84c0b9e0ded95aeb4246ab8aa7e9a703
size 44521 size 44834

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e9893ff64a23c98a13892235f36c52e2ab5ce5a57b3ee7953b884d81ebcfcd89 oid sha256:f9892182ed80b39ebc3151335979967a516793b0ca761df35a7649091372d390
size 33201 size 32737

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:691eb429d1e3a799d99213225a2bd92fa0b7b3ed8b384c8910ba114b5e05b514 oid sha256:cad8f3834b84f1374fce154a90acc0b6d2279ecca29630575764962ef8a107e9
size 32221 size 32510

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:27e4d8474ca09ee1a76947464c1b8bd3c6aeb2f0b73b67cca5736d62d92e286d oid sha256:9e421009e738803d834e4fe603900b828f5e4a2d85ffa5784e8d4f7a40810b80
size 42834 size 43156

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:119fa492046cbfa51e9eb406fea4c4a9a6fb7dd152e3779153a2df2947800f76 oid sha256:e254d17ebeb9e991eaeac000b9148a0c0279d31eac2996e8bf78491747089b30
size 41383 size 41767

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dab45727bd828a76376b796e0d76794d7fb42ac80bc8c721b661eaf0926bb382 oid sha256:b8ae3debf28076d7d0d71a2f3784c7d5cfd9dfc50ea534a4b44a952da1a4d947
size 29544 size 29166

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d2f9a83985ed5f607f6f6618794857468c4e71451bb58ba160478f8fb39a78a8 oid sha256:09e3eb3b83d8f0b74e0d237b3424c68a9b90e077c37d2e472a194583086f7115
size 51611 size 51884

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cad87dcb5fb62c8d577c6e0ac4a13681a827c9d9bd1e2880d3cc3ad802802376 oid sha256:a8cd8131baca93c8fb4c40cde91671155052cc749b0cefe4ba5a276f89f212ab
size 49737 size 50017

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ac22cb35d4842fdab146bcaa8b89b6b09e53354c79fb6c865ab3b76b08c35a7 oid sha256:744f0f6ec4910b54fd13ff423b1e0555ac0cff1c59123e6f5d096496f14603af
size 53186 size 53238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ac22cb35d4842fdab146bcaa8b89b6b09e53354c79fb6c865ab3b76b08c35a7 oid sha256:744f0f6ec4910b54fd13ff423b1e0555ac0cff1c59123e6f5d096496f14603af
size 53186 size 53238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:14fd53641ce5e6d7246bbd575ba4d91d73a1ddda332fa134619afd011e9086bb oid sha256:5d958c172b24915c6b03d8e75ca2611f95cf523eae30cdfb0af4bc40e27cb6c5
size 51267 size 48553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:385b8e5012fad2197297fa1782226a0938a63e53bc4ed1dfa13d82b885a2ca74 oid sha256:9973919b98ce55f98cb9bc00b98af8b90b7b7355235cad6bc1b4b8af799f6bc3
size 49691 size 49981

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e87bac6fa87595efb59245e2289ec7badeab60b959f7d99898cb404cf3c484f4 oid sha256:ebc7011596661eafd90f646f59e986e7f46d942188fb0224fec03b8362284ab1
size 47704 size 47988

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8f02343264623c0ee8d1eeb3ea48702f45d672bb4f319fd78e6eb488aab10a0 oid sha256:5b4290983b3a2b2f13df38f6d95c2ae777230aae7018209f807793186eb2565b
size 50268 size 50426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8f02343264623c0ee8d1eeb3ea48702f45d672bb4f319fd78e6eb488aab10a0 oid sha256:5b4290983b3a2b2f13df38f6d95c2ae777230aae7018209f807793186eb2565b
size 50268 size 50426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8be58c4edb9be3cadbd15ac98f8eabbf4ed72af5cac31f0d3d0a1f18c86c5046 oid sha256:20530d92efe5c6c52daaa99342923ad7310f801515677899f77d1f5bf1de41f2
size 45221 size 42597

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dba09f9888d8efd1036c66baf702d05bdd3f4a26c39ca14ae5c59ae2571d00b7 oid sha256:dbe8d4ff5c2fca528c12e2475ec2179489c6cbaf3e98d0fa45f0980dcd4f9aab
size 50245 size 50590

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ad43dd16b6b0232978b83795b739dcb71778a3a0faf7b8a4da599f23ec653237 oid sha256:f1950689df7774679f0a10f679fc5be61917d3f89fe127dff037c119d675a0f7
size 48125 size 48482

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ac22cb35d4842fdab146bcaa8b89b6b09e53354c79fb6c865ab3b76b08c35a7 oid sha256:744f0f6ec4910b54fd13ff423b1e0555ac0cff1c59123e6f5d096496f14603af
size 53186 size 53238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ac22cb35d4842fdab146bcaa8b89b6b09e53354c79fb6c865ab3b76b08c35a7 oid sha256:744f0f6ec4910b54fd13ff423b1e0555ac0cff1c59123e6f5d096496f14603af
size 53186 size 53238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:14fd53641ce5e6d7246bbd575ba4d91d73a1ddda332fa134619afd011e9086bb oid sha256:5d958c172b24915c6b03d8e75ca2611f95cf523eae30cdfb0af4bc40e27cb6c5
size 51267 size 48553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:67c874c41e3ce38f3cf2ccc6cf3759d6fec0a675fffa9cea94df341a8792c61e oid sha256:1c426686054a0b72361c9ec2b93318c91f01f0d365d03593b03596dcfeef162d
size 48296 size 48559

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9b32141d05f2e1b89eab149b91e02354b7db464a7c0b42516da74e18af777aa6 oid sha256:1fca0245cec36a31642087fcafd4d65b1d670dfe315b6cadd0203be3e029984c
size 46048 size 46307

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8f02343264623c0ee8d1eeb3ea48702f45d672bb4f319fd78e6eb488aab10a0 oid sha256:5b4290983b3a2b2f13df38f6d95c2ae777230aae7018209f807793186eb2565b
size 50268 size 50426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8f02343264623c0ee8d1eeb3ea48702f45d672bb4f319fd78e6eb488aab10a0 oid sha256:5b4290983b3a2b2f13df38f6d95c2ae777230aae7018209f807793186eb2565b
size 50268 size 50426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8be58c4edb9be3cadbd15ac98f8eabbf4ed72af5cac31f0d3d0a1f18c86c5046 oid sha256:20530d92efe5c6c52daaa99342923ad7310f801515677899f77d1f5bf1de41f2
size 45221 size 42597