mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-26 03:16:02 +03:00
Merge pull request #6134 from vector-im/feature/jorgem/mandatory-backup-passphrase
Add support for mandatory backup or passphrase from .well-known home server configuration
This commit is contained in:
commit
2e1a11e10e
19 changed files with 435 additions and 142 deletions
1
changelog.d/6133.feature
Normal file
1
changelog.d/6133.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Added support for mandatory backup or passphrase from .well-known configuration.
|
|
@ -67,6 +67,7 @@ data class Params(
|
|||
val progressListener: BootstrapProgressListener? = null,
|
||||
val passphrase: String?,
|
||||
val keySpec: SsssKeySpec? = null,
|
||||
val forceResetIfSomeSecretsAreMissing: Boolean = false,
|
||||
val setupMode: SetupMode
|
||||
)
|
||||
|
||||
|
@ -83,6 +84,7 @@ class BootstrapCrossSigningTask @Inject constructor(
|
|||
// Ensure cross-signing is initialized. Due to migration it is maybe not always correctly initialized
|
||||
|
||||
val shouldSetCrossSigning = !crossSigningService.isCrossSigningInitialized() ||
|
||||
(params.forceResetIfSomeSecretsAreMissing && !crossSigningService.allPrivateKeysKnown()) ||
|
||||
(params.setupMode == SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET && !crossSigningService.allPrivateKeysKnown()) ||
|
||||
(params.setupMode == SetupMode.HARD_RESET)
|
||||
if (shouldSetCrossSigning) {
|
||||
|
|
|
@ -26,6 +26,7 @@ import com.airbnb.mvrx.withState
|
|||
import im.vector.app.R
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.databinding.FragmentBootstrapSetupRecoveryBinding
|
||||
import im.vector.app.features.raw.wellknown.SecureBackupMethod
|
||||
import javax.inject.Inject
|
||||
|
||||
class BootstrapSetupRecoveryKeyFragment @Inject constructor() :
|
||||
|
@ -55,27 +56,40 @@ class BootstrapSetupRecoveryKeyFragment @Inject constructor() :
|
|||
}
|
||||
|
||||
override fun invalidate() = withState(sharedViewModel) { state ->
|
||||
if (state.step is BootstrapStep.FirstForm) {
|
||||
if (state.step.keyBackUpExist) {
|
||||
// Display the set up action
|
||||
views.bootstrapSetupSecureSubmit.isVisible = true
|
||||
views.bootstrapSetupSecureUseSecurityKey.isVisible = false
|
||||
views.bootstrapSetupSecureUseSecurityPassphrase.isVisible = false
|
||||
views.bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = false
|
||||
val firstFormStep = state.step as? BootstrapStep.FirstForm ?: return@withState
|
||||
|
||||
if (firstFormStep.keyBackUpExist) {
|
||||
renderStateWithExistingKeyBackup()
|
||||
} else {
|
||||
if (state.step.reset) {
|
||||
views.bootstrapSetupSecureText.text = getString(R.string.reset_secure_backup_title)
|
||||
views.bootstrapSetupWarningTextView.isVisible = true
|
||||
} else {
|
||||
views.bootstrapSetupSecureText.text = getString(R.string.bottom_sheet_setup_secure_backup_subtitle)
|
||||
views.bootstrapSetupWarningTextView.isVisible = false
|
||||
}
|
||||
// Choose between create a passphrase or use a recovery key
|
||||
renderSetupHeader(needsReset = firstFormStep.reset)
|
||||
views.bootstrapSetupSecureSubmit.isVisible = false
|
||||
views.bootstrapSetupSecureUseSecurityKey.isVisible = true
|
||||
views.bootstrapSetupSecureUseSecurityPassphrase.isVisible = true
|
||||
views.bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = true
|
||||
|
||||
// Choose between create a passphrase or use a recovery key
|
||||
renderBackupMethodActions(firstFormStep.methods)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderStateWithExistingKeyBackup() = with(views) {
|
||||
// Display the set up action
|
||||
bootstrapSetupSecureSubmit.isVisible = true
|
||||
// Disable creating backup / passphrase options
|
||||
bootstrapSetupSecureUseSecurityKey.isVisible = false
|
||||
bootstrapSetupSecureUseSecurityPassphrase.isVisible = false
|
||||
bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = false
|
||||
}
|
||||
|
||||
private fun renderSetupHeader(needsReset: Boolean) = with(views) {
|
||||
bootstrapSetupSecureText.text = if (needsReset) {
|
||||
getString(R.string.reset_secure_backup_title)
|
||||
} else {
|
||||
getString(R.string.bottom_sheet_setup_secure_backup_subtitle)
|
||||
}
|
||||
bootstrapSetupWarningTextView.isVisible = needsReset
|
||||
}
|
||||
|
||||
private fun renderBackupMethodActions(method: SecureBackupMethod) = with(views) {
|
||||
bootstrapSetupSecureUseSecurityKey.isVisible = method.isKeyAvailable
|
||||
bootstrapSetupSecureUseSecurityPassphrase.isVisible = method.isPassphraseAvailable
|
||||
bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = method.isPassphraseAvailable
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,10 @@ import im.vector.app.core.platform.VectorViewModel
|
|||
import im.vector.app.core.platform.WaitingViewData
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.features.auth.ReAuthActivity
|
||||
import im.vector.app.features.raw.wellknown.SecureBackupMethod
|
||||
import im.vector.app.features.raw.wellknown.getElementWellknown
|
||||
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
|
||||
import im.vector.app.features.raw.wellknown.secureBackupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.android.sdk.api.auth.UIABaseAuth
|
||||
|
@ -41,7 +45,9 @@ import org.matrix.android.sdk.api.auth.UserPasswordAuth
|
|||
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
|
||||
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
|
||||
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.failure.Failure
|
||||
import org.matrix.android.sdk.api.raw.RawService
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupLastVersionResult
|
||||
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysVersionResult
|
||||
|
@ -61,6 +67,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
private val stringProvider: StringProvider,
|
||||
private val errorFormatter: ErrorFormatter,
|
||||
private val session: Session,
|
||||
private val rawService: RawService,
|
||||
private val bootstrapTask: BootstrapCrossSigningTask,
|
||||
private val migrationTask: BackupToQuadSMigrationTask,
|
||||
) : VectorViewModel<BootstrapViewState, BootstrapActions, BootstrapViewEvents>(initialState) {
|
||||
|
@ -83,12 +90,33 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
|
||||
init {
|
||||
|
||||
setState {
|
||||
copy(step = BootstrapStep.CheckingMigration, isRecoverySetup = session.sharedSecretStorageService().isRecoverySetup())
|
||||
}
|
||||
|
||||
// Refresh the well-known configuration
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val wellKnown = rawService.getElementWellknown(session.sessionParams)
|
||||
setState {
|
||||
copy(
|
||||
isSecureBackupRequired = wellKnown?.isSecureBackupRequired().orFalse(),
|
||||
secureBackupMethod = wellKnown?.secureBackupMethod() ?: SecureBackupMethod.KEY_OR_PASSPHRASE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (initialState.setupMode) {
|
||||
SetupMode.PASSPHRASE_RESET,
|
||||
SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET,
|
||||
SetupMode.HARD_RESET -> {
|
||||
setState {
|
||||
copy(step = BootstrapStep.FirstForm(keyBackUpExist = false, reset = true))
|
||||
copy(
|
||||
step = BootstrapStep.FirstForm(
|
||||
keyBackUpExist = false,
|
||||
reset = session.sharedSecretStorageService().isRecoverySetup(),
|
||||
methods = this.secureBackupMethod
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
SetupMode.CROSS_SIGNING_ONLY -> {
|
||||
|
@ -112,7 +140,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
// we just resume plain bootstrap
|
||||
doesKeyBackupExist = false
|
||||
setState {
|
||||
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist))
|
||||
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod))
|
||||
}
|
||||
} else {
|
||||
// we need to get existing backup passphrase/key and convert to SSSS
|
||||
|
@ -126,7 +154,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
doesKeyBackupExist = true
|
||||
isBackupCreatedFromPassphrase = keyVersion.getAuthDataAsMegolmBackupAuthData()?.privateKeySalt != null
|
||||
setState {
|
||||
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist))
|
||||
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -411,6 +439,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
progressListener = progressListener,
|
||||
passphrase = state.passphrase,
|
||||
keySpec = state.migrationRecoveryKey?.let { extractCurveKeyFromRecoveryKey(it)?.let { RawBytesKeySpec(it) } },
|
||||
forceResetIfSomeSecretsAreMissing = state.isSecureBackupRequired,
|
||||
setupMode = state.setupMode
|
||||
)
|
||||
) { bootstrapResult ->
|
||||
|
@ -419,6 +448,13 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
_viewEvents.post(BootstrapViewEvents.Dismiss(true))
|
||||
}
|
||||
is BootstrapResult.Success -> {
|
||||
val isSecureBackupRequired = state.isSecureBackupRequired
|
||||
val secureBackupMethod = state.secureBackupMethod
|
||||
|
||||
if (state.passphrase != null && isSecureBackupRequired && secureBackupMethod == SecureBackupMethod.PASSPHRASE) {
|
||||
// Go straight to conclusion, skip the save key step
|
||||
_viewEvents.post(BootstrapViewEvents.Dismiss(success = true))
|
||||
} else {
|
||||
setState {
|
||||
copy(
|
||||
recoveryKeyCreationInfo = bootstrapResult.keyInfo,
|
||||
|
@ -429,6 +465,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is BootstrapResult.InvalidPasswordError -> {
|
||||
// it's a bad password / auth
|
||||
setState {
|
||||
|
@ -476,7 +513,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
} else {
|
||||
setState {
|
||||
copy(
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
|
||||
// Also reset the passphrase
|
||||
passphrase = null,
|
||||
passphraseRepeat = null,
|
||||
|
@ -489,7 +526,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
is BootstrapStep.SetupPassphrase -> {
|
||||
setState {
|
||||
copy(
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
|
||||
// Also reset the passphrase
|
||||
passphrase = null,
|
||||
passphraseRepeat = null
|
||||
|
@ -504,18 +541,33 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
is BootstrapStep.AccountReAuth -> {
|
||||
if (state.canLeave) {
|
||||
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
|
||||
} else {
|
||||
// Go back to the first step
|
||||
setState {
|
||||
copy(
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
|
||||
// Also reset the passphrase
|
||||
passphrase = null,
|
||||
passphraseRepeat = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
BootstrapStep.Initializing -> {
|
||||
// do we let you cancel from here?
|
||||
if (state.canLeave) {
|
||||
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
|
||||
}
|
||||
}
|
||||
is BootstrapStep.SaveRecoveryKey,
|
||||
BootstrapStep.DoneSuccess -> {
|
||||
// nop
|
||||
}
|
||||
BootstrapStep.CheckingMigration -> Unit
|
||||
is BootstrapStep.FirstForm -> {
|
||||
if (state.canLeave) {
|
||||
_viewEvents.post(
|
||||
when (state.setupMode) {
|
||||
SetupMode.CROSS_SIGNING_ONLY,
|
||||
|
@ -524,10 +576,11 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is BootstrapStep.GetBackupSecretForMigration -> {
|
||||
setState {
|
||||
copy(
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
|
||||
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
|
||||
// Also reset the passphrase
|
||||
passphrase = null,
|
||||
passphraseRepeat = null,
|
||||
|
@ -549,3 +602,5 @@ class BootstrapSharedViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val BootstrapViewState.canLeave: Boolean get() = !isSecureBackupRequired || isRecoverySetup
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
package im.vector.app.features.crypto.recover
|
||||
|
||||
import im.vector.app.features.raw.wellknown.SecureBackupMethod
|
||||
|
||||
/**
|
||||
* TODO The schema is not up to date
|
||||
*
|
||||
|
@ -89,7 +91,7 @@ sealed class BootstrapStep {
|
|||
object CheckingMigration : BootstrapStep()
|
||||
|
||||
// Use will be asked to choose between passphrase or recovery key, or to start process if a key backup exists
|
||||
data class FirstForm(val keyBackUpExist: Boolean, val reset: Boolean = false) : BootstrapStep()
|
||||
data class FirstForm(val keyBackUpExist: Boolean, val reset: Boolean = false, val methods: SecureBackupMethod) : BootstrapStep()
|
||||
|
||||
object SetupPassphrase : BootstrapStep()
|
||||
object ConfirmPassphrase : BootstrapStep()
|
||||
|
|
|
@ -21,6 +21,7 @@ import com.airbnb.mvrx.MavericksState
|
|||
import com.airbnb.mvrx.Uninitialized
|
||||
import com.nulabinc.zxcvbn.Strength
|
||||
import im.vector.app.core.platform.WaitingViewData
|
||||
import im.vector.app.features.raw.wellknown.SecureBackupMethod
|
||||
import org.matrix.android.sdk.api.session.securestorage.SsssKeyCreationInfo
|
||||
|
||||
data class BootstrapViewState(
|
||||
|
@ -34,7 +35,10 @@ data class BootstrapViewState(
|
|||
val passphraseConfirmMatch: Async<Unit> = Uninitialized,
|
||||
val recoveryKeyCreationInfo: SsssKeyCreationInfo? = null,
|
||||
val initializationWaitingViewData: WaitingViewData? = null,
|
||||
val recoverySaveFileProcess: Async<Unit> = Uninitialized
|
||||
val recoverySaveFileProcess: Async<Unit> = Uninitialized,
|
||||
val isSecureBackupRequired: Boolean = false,
|
||||
val secureBackupMethod: SecureBackupMethod = SecureBackupMethod.KEY_OR_PASSPHRASE,
|
||||
val isRecoverySetup: Boolean = true
|
||||
) : MavericksState {
|
||||
|
||||
constructor(args: BootstrapBottomSheet.Args) : this(setupMode = args.setUpMode)
|
||||
|
|
|
@ -27,7 +27,7 @@ sealed class VerificationAction : VectorViewModelAction {
|
|||
object OtherUserDidNotScanned : VerificationAction()
|
||||
data class SASMatchAction(val otherUserId: String, val sasTransactionId: String) : VerificationAction()
|
||||
data class SASDoNotMatchAction(val otherUserId: String, val sasTransactionId: String) : VerificationAction()
|
||||
object GotItConclusion : VerificationAction()
|
||||
data class GotItConclusion(val verified: Boolean) : VerificationAction()
|
||||
object SkipVerification : VerificationAction()
|
||||
object VerifyFromPassphrase : VerificationAction()
|
||||
data class GotResultFromSsss(val cypherData: String, val alias: String) : VerificationAction()
|
||||
|
|
|
@ -30,9 +30,13 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
|
|||
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
||||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.features.raw.wellknown.getElementWellknown
|
||||
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.android.sdk.api.MatrixCallback
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.raw.RawService
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME
|
||||
import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
|
||||
|
@ -78,6 +82,7 @@ data class VerificationBottomSheetViewState(
|
|||
val userWantsToCancel: Boolean = false,
|
||||
val userThinkItsNotHim: Boolean = false,
|
||||
val quadSContainsSecrets: Boolean = true,
|
||||
val isVerificationRequired: Boolean = false,
|
||||
val quadSHasBeenReset: Boolean = false,
|
||||
val hasAnyOtherSession: Boolean = false
|
||||
) : MavericksState {
|
||||
|
@ -92,6 +97,7 @@ data class VerificationBottomSheetViewState(
|
|||
|
||||
class VerificationBottomSheetViewModel @AssistedInject constructor(
|
||||
@Assisted initialState: VerificationBottomSheetViewState,
|
||||
private val rawService: RawService,
|
||||
private val session: Session,
|
||||
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
|
||||
private val stringProvider: StringProvider) :
|
||||
|
@ -108,6 +114,15 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
|
|||
init {
|
||||
session.cryptoService().verificationService().addListener(this)
|
||||
|
||||
// This is async, but at this point should be in cache
|
||||
// so it's ok to not wait until result
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val wellKnown = rawService.getElementWellknown(session.sessionParams)
|
||||
setState {
|
||||
copy(isVerificationRequired = wellKnown?.isSecureBackupRequired().orFalse())
|
||||
}
|
||||
}
|
||||
|
||||
val userItem = session.getUser(initialState.otherUserId)
|
||||
|
||||
var autoReady = false
|
||||
|
@ -182,12 +197,14 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
|
|||
state.verifyingFrom4S) {
|
||||
// you cannot cancel anymore
|
||||
} else {
|
||||
if (!state.isVerificationRequired) {
|
||||
setState {
|
||||
copy(userWantsToCancel = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmCancel() = withState { state ->
|
||||
cancelAllPendingVerifications(state)
|
||||
|
@ -341,8 +358,19 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
|
|||
?.shortCodeDoesNotMatch()
|
||||
}
|
||||
is VerificationAction.GotItConclusion -> {
|
||||
if (state.isVerificationRequired && !action.verified) {
|
||||
// we should go back to first screen
|
||||
setState {
|
||||
copy(
|
||||
pendingRequest = Uninitialized,
|
||||
sasTransactionState = null,
|
||||
qrTransactionState = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
|
||||
}
|
||||
}
|
||||
is VerificationAction.SkipVerification -> {
|
||||
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ class VerificationConclusionController @Inject constructor(
|
|||
notice(host.eventHtmlRenderer.render(host.stringProvider.getString(R.string.verification_conclusion_compromised)).toEpoxyCharSequence())
|
||||
}
|
||||
|
||||
bottomDone()
|
||||
bottomGotIt()
|
||||
}
|
||||
ConclusionState.CANCELLED -> {
|
||||
bottomSheetVerificationNoticeItem {
|
||||
|
@ -92,18 +92,7 @@ class VerificationConclusionController @Inject constructor(
|
|||
notice(host.stringProvider.getString(R.string.verify_cancelled_notice).toEpoxyCharSequence())
|
||||
}
|
||||
|
||||
bottomSheetDividerItem {
|
||||
id("sep0")
|
||||
}
|
||||
|
||||
bottomSheetVerificationActionItem {
|
||||
id("got_it")
|
||||
title(host.stringProvider.getString(R.string.sas_got_it))
|
||||
titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary))
|
||||
iconRes(R.drawable.ic_arrow_right)
|
||||
iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary))
|
||||
listener { host.listener?.onButtonTapped() }
|
||||
}
|
||||
bottomGotIt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,11 +109,27 @@ class VerificationConclusionController @Inject constructor(
|
|||
titleColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
|
||||
iconRes(R.drawable.ic_arrow_right)
|
||||
iconColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
|
||||
listener { host.listener?.onButtonTapped() }
|
||||
listener { host.listener?.onButtonTapped(true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomGotIt() {
|
||||
val host = this
|
||||
bottomSheetDividerItem {
|
||||
id("sep0")
|
||||
}
|
||||
|
||||
bottomSheetVerificationActionItem {
|
||||
id("got_it")
|
||||
title(host.stringProvider.getString(R.string.sas_got_it))
|
||||
titleColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
|
||||
iconRes(R.drawable.ic_arrow_right)
|
||||
iconColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
|
||||
listener { host.listener?.onButtonTapped(false) }
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onButtonTapped()
|
||||
fun onButtonTapped(success: Boolean)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ class VerificationConclusionFragment @Inject constructor(
|
|||
controller.update(state)
|
||||
}
|
||||
|
||||
override fun onButtonTapped() {
|
||||
sharedViewModel.handle(VerificationAction.GotItConclusion)
|
||||
override fun onButtonTapped(success: Boolean) {
|
||||
sharedViewModel.handle(VerificationAction.GotItConclusion(success))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,6 +88,7 @@ class VerificationRequestController @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
if (!state.isVerificationRequired) {
|
||||
bottomSheetDividerItem {
|
||||
id("sep1")
|
||||
}
|
||||
|
@ -100,6 +101,7 @@ class VerificationRequestController @Inject constructor(
|
|||
iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorError))
|
||||
listener { host.listener?.onClickSkip() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val styledText =
|
||||
if (state.isMe) {
|
||||
|
|
|
@ -50,6 +50,7 @@ import im.vector.app.features.MainActivityArgs
|
|||
import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import im.vector.app.features.analytics.plan.ViewRoom
|
||||
import im.vector.app.features.crypto.recover.SetupMode
|
||||
import im.vector.app.features.disclaimer.showDisclaimerDialog
|
||||
import im.vector.app.features.matrixto.MatrixToBottomSheet
|
||||
import im.vector.app.features.matrixto.OriginOfMatrixTo
|
||||
|
@ -226,6 +227,14 @@ class HomeActivity :
|
|||
is HomeActivityViewEvents.AskPasswordToInitCrossSigning -> handleAskPasswordToInitCrossSigning(it)
|
||||
is HomeActivityViewEvents.OnNewSession -> handleOnNewSession(it)
|
||||
HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush()
|
||||
HomeActivityViewEvents.StartRecoverySetupFlow -> handleStartRecoverySetup()
|
||||
is HomeActivityViewEvents.ForceVerification -> {
|
||||
if (it.sendRequest) {
|
||||
navigator.requestSelfSessionVerification(this)
|
||||
} else {
|
||||
navigator.waitSessionVerification(this)
|
||||
}
|
||||
}
|
||||
is HomeActivityViewEvents.OnCrossSignedInvalidated -> handleCrossSigningInvalidated(it)
|
||||
HomeActivityViewEvents.ShowAnalyticsOptIn -> handleShowAnalyticsOptIn()
|
||||
HomeActivityViewEvents.NotifyUserForThreadsMigration -> handleNotifyUserForThreadsMigration()
|
||||
|
@ -355,6 +364,13 @@ class HomeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleStartRecoverySetup() {
|
||||
// To avoid IllegalStateException in case the transaction was executed after onSaveInstanceState
|
||||
lifecycleScope.launchWhenResumed {
|
||||
navigator.open4SSetup(this@HomeActivity, SetupMode.NORMAL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderState(state: HomeActivityViewState) {
|
||||
when (val status = state.syncStatusServiceStatus) {
|
||||
is SyncStatusService.Status.InitialSyncProgressing -> {
|
||||
|
|
|
@ -27,4 +27,6 @@ sealed interface HomeActivityViewEvents : VectorViewEvents {
|
|||
object ShowAnalyticsOptIn : HomeActivityViewEvents
|
||||
object NotifyUserForThreadsMigration : HomeActivityViewEvents
|
||||
data class MigrateThreads(val checkSession: Boolean) : HomeActivityViewEvents
|
||||
object StartRecoverySetupFlow : HomeActivityViewEvents
|
||||
data class ForceVerification(val sendRequest: Boolean) : HomeActivityViewEvents
|
||||
}
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
package im.vector.app.features.home
|
||||
|
||||
import androidx.lifecycle.asFlow
|
||||
import com.airbnb.mvrx.Mavericks
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import com.airbnb.mvrx.ViewModelContext
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
@ -28,6 +30,9 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
|
|||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.features.analytics.store.AnalyticsStore
|
||||
import im.vector.app.features.login.ReAuthHelper
|
||||
import im.vector.app.features.raw.wellknown.ElementWellKnown
|
||||
import im.vector.app.features.raw.wellknown.getElementWellknown
|
||||
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
|
||||
import im.vector.app.features.session.coroutineScope
|
||||
import im.vector.app.features.settings.VectorPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -42,6 +47,8 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
|
|||
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
|
||||
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
|
||||
import org.matrix.android.sdk.api.extensions.tryOrNull
|
||||
import org.matrix.android.sdk.api.raw.RawService
|
||||
import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService
|
||||
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
|
||||
import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
|
||||
import org.matrix.android.sdk.api.session.getUser
|
||||
|
@ -59,8 +66,9 @@ import kotlin.coroutines.resume
|
|||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class HomeActivityViewModel @AssistedInject constructor(
|
||||
@Assisted initialState: HomeActivityViewState,
|
||||
@Assisted private val initialState: HomeActivityViewState,
|
||||
private val activeSessionHolder: ActiveSessionHolder,
|
||||
private val rawService: RawService,
|
||||
private val reAuthHelper: ReAuthHelper,
|
||||
private val analyticsStore: AnalyticsStore,
|
||||
private val lightweightSettingsStorage: LightweightSettingsStorage,
|
||||
|
@ -72,10 +80,17 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
override fun create(initialState: HomeActivityViewState): HomeActivityViewModel
|
||||
}
|
||||
|
||||
companion object : MavericksViewModelFactory<HomeActivityViewModel, HomeActivityViewState> by hiltMavericksViewModelFactory()
|
||||
companion object : MavericksViewModelFactory<HomeActivityViewModel, HomeActivityViewState> by hiltMavericksViewModelFactory() {
|
||||
override fun initialState(viewModelContext: ViewModelContext): HomeActivityViewState? {
|
||||
val activity: HomeActivity = viewModelContext.activity()
|
||||
val args: HomeActivityArgs? = activity.intent.getParcelableExtra(Mavericks.KEY_ARG)
|
||||
return args?.let { HomeActivityViewState(accountCreation = it.accountCreation) }
|
||||
?: super.initialState(viewModelContext)
|
||||
}
|
||||
}
|
||||
|
||||
private var isInitialized = false
|
||||
private var checkBootstrap = false
|
||||
private var hasCheckedBootstrap = false
|
||||
private var onceTrusted = false
|
||||
|
||||
private fun initialize() {
|
||||
|
@ -116,16 +131,12 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
safeActiveSession
|
||||
.flow()
|
||||
.liveCrossSigningInfo(safeActiveSession.myUserId)
|
||||
.onEach {
|
||||
val isVerified = it.getOrNull()?.isTrusted() ?: false
|
||||
.onEach { info ->
|
||||
val isVerified = info.getOrNull()?.isTrusted() ?: false
|
||||
if (!isVerified && onceTrusted) {
|
||||
// cross signing keys have been reset
|
||||
// Trigger a popup to re-verify
|
||||
// Note: user can be null in case of logout
|
||||
safeActiveSession.getUser(safeActiveSession.myUserId)
|
||||
?.toMatrixItem()
|
||||
?.let { user ->
|
||||
_viewEvents.post(HomeActivityViewEvents.OnCrossSignedInvalidated(user))
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val elementWellKnown = rawService.getElementWellknown(safeActiveSession.sessionParams)
|
||||
sessionHasBeenUnverified(elementWellKnown)
|
||||
}
|
||||
}
|
||||
onceTrusted = isVerified
|
||||
|
@ -180,15 +191,8 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
.asFlow()
|
||||
.onEach { status ->
|
||||
when (status) {
|
||||
is SyncStatusService.Status.InitialSyncProgressing -> {
|
||||
// Schedule a check of the bootstrap when the init sync will be finished
|
||||
checkBootstrap = true
|
||||
}
|
||||
is SyncStatusService.Status.Idle -> {
|
||||
if (checkBootstrap) {
|
||||
checkBootstrap = false
|
||||
maybeBootstrapCrossSigningAfterInitialSync()
|
||||
}
|
||||
maybeVerifyOrBootstrapCrossSigning()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
@ -200,6 +204,10 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
if (session.hasAlreadySynced()) {
|
||||
maybeVerifyOrBootstrapCrossSigning()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,12 +248,72 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun maybeBootstrapCrossSigningAfterInitialSync() {
|
||||
private fun sessionHasBeenUnverified(elementWellKnown: ElementWellKnown?) {
|
||||
val session = activeSessionHolder.getSafeActiveSession() ?: return
|
||||
val isSecureBackupRequired = elementWellKnown?.isSecureBackupRequired() ?: false
|
||||
if (isSecureBackupRequired) {
|
||||
// If 4S is forced, force verification
|
||||
// for stability cancel all pending verifications?
|
||||
session.cryptoService().verificationService().getExistingVerificationRequests(session.myUserId).forEach {
|
||||
session.cryptoService().verificationService().cancelVerificationRequest(it)
|
||||
}
|
||||
_viewEvents.post(HomeActivityViewEvents.ForceVerification(false))
|
||||
} else {
|
||||
// cross signing keys have been reset
|
||||
// Trigger a popup to re-verify
|
||||
// Note: user can be null in case of logout
|
||||
session.getUser(session.myUserId)
|
||||
?.toMatrixItem()
|
||||
?.let { user ->
|
||||
_viewEvents.post(HomeActivityViewEvents.OnCrossSignedInvalidated(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeVerifyOrBootstrapCrossSigning() {
|
||||
// The contents of this method should only run once
|
||||
if (hasCheckedBootstrap) return
|
||||
hasCheckedBootstrap = true
|
||||
|
||||
// We do not use the viewModel context because we do not want to tie this action to activity view model
|
||||
activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch(Dispatchers.IO) {
|
||||
val session = activeSessionHolder.getSafeActiveSession() ?: return@launch
|
||||
val session = activeSessionHolder.getSafeActiveSession() ?: return@launch Unit.also {
|
||||
Timber.w("## No session to init cross signing or bootstrap")
|
||||
}
|
||||
|
||||
tryOrNull("## MaybeBootstrapCrossSigning: Failed to download keys") {
|
||||
val elementWellKnown = rawService.getElementWellknown(session.sessionParams)
|
||||
val isSecureBackupRequired = elementWellKnown?.isSecureBackupRequired() ?: false
|
||||
|
||||
// In case of account creation, it is already done before
|
||||
if (initialState.accountCreation) {
|
||||
if (isSecureBackupRequired) {
|
||||
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
|
||||
} else {
|
||||
val password = reAuthHelper.data ?: return@launch Unit.also {
|
||||
Timber.w("No password to init cross signing")
|
||||
}
|
||||
|
||||
// Silently initialize cross signing without 4S
|
||||
// We do not use the viewModel context because we do not want to cancel this action
|
||||
Timber.d("Initialize cross signing")
|
||||
try {
|
||||
session.cryptoService().crossSigningService().awaitCrossSigninInitialization { response, _ ->
|
||||
resume(
|
||||
UserPasswordAuth(
|
||||
session = response.session,
|
||||
user = session.myUserId,
|
||||
password = password
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
Timber.e(failure, "Failed to initialize cross signing")
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
tryOrNull("## MaybeVerifyOrBootstrapCrossSigning: Failed to download keys") {
|
||||
awaitCallback<MXUsersDevicesMap<CryptoDeviceInfo>> {
|
||||
session.cryptoService().downloadKeys(listOf(session.myUserId), true, it)
|
||||
}
|
||||
|
@ -255,8 +323,16 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
// Is there already cross signing keys here?
|
||||
val mxCrossSigningInfo = session.cryptoService().crossSigningService().getMyCrossSigningKeys()
|
||||
if (mxCrossSigningInfo != null) {
|
||||
if (isSecureBackupRequired && !session.sharedSecretStorageService().isRecoverySetup()) {
|
||||
// If 4S is forced, start the full interactive setup flow
|
||||
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
|
||||
} else {
|
||||
// Cross-signing is already set up for this user, is it trusted?
|
||||
if (!mxCrossSigningInfo.isTrusted()) {
|
||||
if (isSecureBackupRequired) {
|
||||
// If 4S is forced, force verification
|
||||
_viewEvents.post(HomeActivityViewEvents.ForceVerification(true))
|
||||
} else {
|
||||
// New session
|
||||
_viewEvents.post(
|
||||
HomeActivityViewEvents.OnNewSession(
|
||||
|
@ -266,33 +342,44 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cross signing is not initialized
|
||||
if (isSecureBackupRequired) {
|
||||
// If 4S is forced, start the full interactive setup flow
|
||||
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
|
||||
} else {
|
||||
// Initialize cross-signing silently
|
||||
val password = reAuthHelper.data
|
||||
|
||||
if (password == null) {
|
||||
// Check this is not an SSO account
|
||||
if (session.homeServerCapabilitiesService().getHomeServerCapabilities().canChangePassword) {
|
||||
// Ask password to the user: Upgrade security
|
||||
_viewEvents.post(HomeActivityViewEvents.AskPasswordToInitCrossSigning(session.getUser(session.myUserId)?.toMatrixItem()))
|
||||
}
|
||||
// Else (SSO) just ignore for the moment
|
||||
} else {
|
||||
// Try to initialize cross signing in background if possible
|
||||
Timber.d("Initialize cross signing...")
|
||||
try {
|
||||
awaitCallback<Unit> {
|
||||
session.cryptoService().crossSigningService().initializeCrossSigning(
|
||||
object : UserInteractiveAuthInterceptor {
|
||||
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
|
||||
session.cryptoService().crossSigningService().awaitCrossSigninInitialization { response, errCode ->
|
||||
// We missed server grace period or it's not setup, see if we remember locally password
|
||||
if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD &&
|
||||
if (response.nextUncompletedStage() == LoginFlowTypes.PASSWORD &&
|
||||
errCode == null &&
|
||||
reAuthHelper.data != null) {
|
||||
promise.resume(
|
||||
resume(
|
||||
UserPasswordAuth(
|
||||
session = flowResponse.session,
|
||||
session = response.session,
|
||||
user = session.myUserId,
|
||||
password = reAuthHelper.data
|
||||
)
|
||||
)
|
||||
} else {
|
||||
promise.resumeWithException(Exception("Cannot silently initialize cross signing, UIA missing"))
|
||||
}
|
||||
}
|
||||
},
|
||||
callback = it
|
||||
)
|
||||
Timber.d("Initialize cross signing SUCCESS")
|
||||
} else {
|
||||
resumeWithException(Exception("Cannot silently initialize cross signing, UIA missing"))
|
||||
}
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
Timber.e(failure, "Failed to initialize cross signing")
|
||||
|
@ -300,6 +387,8 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handle(action: HomeActivityViewActions) {
|
||||
when (action) {
|
||||
|
@ -312,3 +401,18 @@ class HomeActivityViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun CrossSigningService.awaitCrossSigninInitialization(
|
||||
block: Continuation<UIABaseAuth>.(response: RegistrationFlowResponse, errCode: String?) -> Unit
|
||||
) {
|
||||
awaitCallback<Unit> {
|
||||
initializeCrossSigning(
|
||||
object : UserInteractiveAuthInterceptor {
|
||||
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
|
||||
promise.block(flowResponse, errCode)
|
||||
}
|
||||
},
|
||||
callback = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,5 +20,6 @@ import com.airbnb.mvrx.MavericksState
|
|||
import org.matrix.android.sdk.api.session.initsync.SyncStatusService
|
||||
|
||||
data class HomeActivityViewState(
|
||||
val syncStatusServiceStatus: SyncStatusService.Status = SyncStatusService.Status.Idle
|
||||
val syncStatusServiceStatus: SyncStatusService.Status = SyncStatusService.Status.Idle,
|
||||
val accountCreation: Boolean = false
|
||||
) : MavericksState
|
||||
|
|
|
@ -53,7 +53,19 @@ data class E2EWellKnownConfig(
|
|||
* (as it was before) for various environments where this is desired.
|
||||
*/
|
||||
@Json(name = "default")
|
||||
val e2eDefault: Boolean? = null
|
||||
val e2eDefault: Boolean? = null,
|
||||
|
||||
@Json(name = "secure_backup_required")
|
||||
val secureBackupRequired: Boolean? = null,
|
||||
|
||||
/**
|
||||
* The new field secure_backup_setup_methods is an array listing the methods the client should display.
|
||||
* Supported values currently include key and passphrase.
|
||||
* If the secure_backup_setup_methods field is not present or exists but does not contain any supported methods,
|
||||
* clients should fallback to the default value of: ["key", "passphrase"].
|
||||
*/
|
||||
@Json(name = "secure_backup_setup_methods")
|
||||
val secureBackupSetupMethods: List<String>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
|
|
@ -29,3 +29,22 @@ suspend fun RawService.getElementWellknown(sessionParams: SessionParams): Elemen
|
|||
}
|
||||
|
||||
fun ElementWellKnown.isE2EByDefault() = elementE2E?.e2eDefault ?: riotE2E?.e2eDefault ?: true
|
||||
|
||||
fun ElementWellKnown.isSecureBackupRequired() = elementE2E?.secureBackupRequired
|
||||
?: riotE2E?.secureBackupRequired
|
||||
?: false
|
||||
|
||||
fun ElementWellKnown?.secureBackupMethod(): SecureBackupMethod {
|
||||
val methodList = this?.elementE2E?.secureBackupSetupMethods
|
||||
?: this?.riotE2E?.secureBackupSetupMethods
|
||||
?: listOf("key", "passphrase")
|
||||
return if (methodList.contains("key") && methodList.contains("passphrase")) {
|
||||
SecureBackupMethod.KEY_OR_PASSPHRASE
|
||||
} else if (methodList.contains("key")) {
|
||||
SecureBackupMethod.KEY
|
||||
} else if (methodList.contains("passphrase")) {
|
||||
SecureBackupMethod.PASSPHRASE
|
||||
} else {
|
||||
SecureBackupMethod.KEY_OR_PASSPHRASE
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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 im.vector.app.features.raw.wellknown
|
||||
|
||||
enum class SecureBackupMethod {
|
||||
KEY,
|
||||
PASSPHRASE,
|
||||
KEY_OR_PASSPHRASE;
|
||||
|
||||
val isKeyAvailable: Boolean get() = this == KEY || this == KEY_OR_PASSPHRASE
|
||||
val isPassphraseAvailable: Boolean get() = this == PASSPHRASE || this == KEY_OR_PASSPHRASE
|
||||
}
|
|
@ -25,7 +25,8 @@
|
|||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_security_key_24dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/bootstrapTitleText"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bootstrapTitleText"
|
||||
app:tint="?vctr_content_primary"
|
||||
tools:ignore="MissingPrefix" />
|
||||
|
||||
|
@ -39,10 +40,9 @@
|
|||
android:ellipsize="end"
|
||||
android:textColor="?vctr_content_primary"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bootstrapIcon"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/bootstrapIcon"
|
||||
app:layout_constraintTop_toTopOf="@id/bootstrapIcon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@string/bottom_sheet_setup_secure_backup_title" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
|
|
Loading…
Reference in a new issue