From af45233e028de9955856ef8d8c6d4c2399d0ebc3 Mon Sep 17 00:00:00 2001 From: Michael Bestas Date: Wed, 25 Nov 2020 21:12:12 +0200 Subject: [PATCH 01/52] strings: Avoid using camel case Change-Id: I0365a381f4c2f28d3d61b4f90c66af9e557a50d2 --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae0d4322..fce3fdb0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,12 +91,12 @@ - System Apps + System apps SMS text messages Device settings Call history Local contacts - Installed Apps + Installed apps Waiting to back up… Was not yet backed up From 9bba81ed5c782bc649a1ab839843f578a4e72c98 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 18 Dec 2020 08:45:18 -0300 Subject: [PATCH 02/52] Add message to AssertionError to track down why it happens --- .../com/stevesoltys/seedvault/transport/backup/KVBackup.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt index 2468f75f..153b0989 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt @@ -67,7 +67,10 @@ internal class KVBackup( } // initialize state - if (this.state != null) throw AssertionError() + val state = this.state + if (state != null) { + throw AssertionError("Have state for ${state.packageInfo.packageName}") + } this.state = KVBackupState(packageInfo) // no need for backup when no data has changed From b7f8c24538a8c628911b36955d00337ae0fcc11d Mon Sep 17 00:00:00 2001 From: William Theaker Date: Mon, 11 Jan 2021 11:38:46 -0800 Subject: [PATCH 03/52] Remove redirect from README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21e15945..d5295d0e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX. ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault. +Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault. This project aims to adhere to the [official Kotlin coding style](https://developer.android.com/kotlin/style-guide). From d64413a7c39f3e5a4b93819dc6e36607826370d3 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 19 Nov 2020 09:33:31 -0300 Subject: [PATCH 04/52] Allow user to verify existing recovery code --- .../java/com/stevesoltys/seedvault/App.kt | 3 +- .../seedvault/crypto/CipherFactory.kt | 9 +++++ .../stevesoltys/seedvault/crypto/Crypto.kt | 23 +++++++++++ .../seedvault/crypto/KeyManager.kt | 3 +- .../seedvault/settings/SettingsActivity.kt | 5 +++ .../recoverycode/RecoveryCodeInputFragment.kt | 39 ++++++++++++++++++- .../ui/recoverycode/RecoveryCodeViewModel.kt | 21 +++++++--- app/src/main/res/drawable/ic_vpn_key.xml | 10 +++++ app/src/main/res/values/strings.xml | 9 ++++- app/src/main/res/xml/settings.xml | 8 ++++ 10 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/drawable/ic_vpn_key.xml diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index ce1f0477..e91e7c8f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -44,7 +44,7 @@ class App : Application() { factory { AppListRetriever(this@App, get(), get(), get()) } viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) } - viewModel { RecoveryCodeViewModel(this@App, get()) } + viewModel { RecoveryCodeViewModel(this@App, get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) } @@ -111,6 +111,7 @@ const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" const val GLOBAL_METADATA_KEY = "@meta@" +// TODO this doesn't work for LineageOS as they do public debug builds fun isDebugBuild() = Build.TYPE == "userdebug" fun permitDiskReads(func: () -> T): T { diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt index cf19a226..52d7ef88 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/CipherFactory.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.crypto +import java.security.Key import javax.crypto.Cipher import javax.crypto.Cipher.DECRYPT_MODE import javax.crypto.Cipher.ENCRYPT_MODE @@ -11,6 +12,7 @@ internal const val GCM_AUTHENTICATION_TAG_LENGTH = 128 interface CipherFactory { fun createEncryptionCipher(): Cipher fun createDecryptionCipher(iv: ByteArray): Cipher + fun createEncryptionTestCipher(key: Key, iv: ByteArray): Cipher } internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory { @@ -28,4 +30,11 @@ internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFac } } + override fun createEncryptionTestCipher(key: Key, iv: ByteArray): Cipher { + return Cipher.getInstance(CIPHER_TRANSFORMATION).apply { + val params = GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, iv) + init(ENCRYPT_MODE, key, params) + } + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt index 8b6a6960..78c8e6e9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt @@ -12,6 +12,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec import kotlin.math.min /** @@ -95,6 +96,13 @@ interface Crypto { */ @Throws(IOException::class, SecurityException::class) fun decryptMultipleSegments(inputStream: InputStream): ByteArray + + /** + * Verify that the stored backup key was created from the given seed. + * + * @return true if the key was created from given seed, false otherwise. + */ + fun verifyBackupKey(seed: ByteArray): Boolean } internal class CryptoImpl( @@ -204,4 +212,19 @@ internal class CryptoImpl( return cipher.doFinal(buffer) } + override fun verifyBackupKey(seed: ByteArray): Boolean { + // encrypt with stored backup key + val toEncrypt = "Recovery Code Verification".toByteArray() + val cipher = cipherFactory.createEncryptionCipher() + val encrypted = cipher.doFinal(toEncrypt) as ByteArray + + // encrypt with input key cipher + val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") + val inputCipher = cipherFactory.createEncryptionTestCipher(secretKeySpec, cipher.iv) + val inputEncrypted = inputCipher.doFinal(toEncrypt) + + // keys match if encrypted result is the same + return encrypted.contentEquals(inputEncrypted) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt index e472bd39..eedbdeee 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt @@ -11,7 +11,7 @@ import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec internal const val KEY_SIZE = 256 -private const val KEY_SIZE_BYTES = KEY_SIZE / 8 +internal const val KEY_SIZE_BYTES = KEY_SIZE / 8 private const val KEY_ALIAS = "com.stevesoltys.seedvault" private const val ANDROID_KEY_STORE = "AndroidKeyStore" @@ -47,7 +47,6 @@ internal class KeyManagerImpl : KeyManager { override fun storeBackupKey(seed: ByteArray) { if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() - // TODO check if using first 256 of 512 bits produced by PBKDF2WithHmacSHA512 is safe! val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val ksEntry = SecretKeyEntry(secretKeySpec) keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt index acad9e5b..9d6a026a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt @@ -9,10 +9,12 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.ui.recoverycode.ARG_FOR_NEW_CODE import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel internal const val ACTION_APP_STATUS_LIST = "com.stevesoltys.seedvault.APP_STATUS_LIST" +private const val PREF_BACKUP_RECOVERY_CODE = "backup_recovery_code" class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmentCallback { @@ -57,6 +59,9 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen ): Boolean { val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment) + if (pref.key == PREF_BACKUP_RECOVERY_CODE) fragment.arguments = Bundle().apply { + putBoolean(ARG_FOR_NEW_CODE, false) + } supportFragmentManager.beginTransaction() .replace(R.id.fragment, fragment) .addToBackStack(null) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt index 60e4c673..abd2d8f2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt @@ -12,16 +12,20 @@ import android.widget.Button import android.widget.TextView import android.widget.Toast import android.widget.Toast.LENGTH_LONG +import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.Fragment import com.google.android.material.textfield.TextInputLayout import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.isDebugBuild +import com.stevesoltys.seedvault.ui.LiveEventHandler import io.github.novacrypto.bip39.Validation.InvalidChecksumException import io.github.novacrypto.bip39.Validation.WordNotFoundException import io.github.novacrypto.bip39.wordlists.English import org.koin.androidx.viewmodel.ext.android.sharedViewModel +internal const val ARG_FOR_NEW_CODE = "forVerifyingNewCode" + class RecoveryCodeInputFragment : Fragment() { private val viewModel: RecoveryCodeViewModel by sharedViewModel() @@ -43,6 +47,11 @@ class RecoveryCodeInputFragment : Fragment() { private lateinit var wordLayout12: TextInputLayout private lateinit var wordList: ConstraintLayout + /** + * True if this is for verifying a new recovery code, false for verifying an existing one. + */ + private var forVerifyingNewCode: Boolean = true + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -67,6 +76,10 @@ class RecoveryCodeInputFragment : Fragment() { wordLayout12 = v.findViewById(R.id.wordLayout12) wordList = v.findViewById(R.id.wordList) + arguments?.getBoolean(ARG_FOR_NEW_CODE, true)?.let { + forVerifyingNewCode = it + } + return v } @@ -91,7 +104,11 @@ class RecoveryCodeInputFragment : Fragment() { } doneButton.setOnClickListener { done() } - if (isDebugBuild() && !viewModel.isRestore) debugPreFill() + viewModel.existingCodeChecked.observeEvent(viewLifecycleOwner, + LiveEventHandler { verified -> onExistingCodeChecked(verified) } + ) + + if (forVerifyingNewCode && isDebugBuild() && !viewModel.isRestore) debugPreFill() } private fun getAdapter(): ArrayAdapter { @@ -110,7 +127,7 @@ class RecoveryCodeInputFragment : Fragment() { val input = getInput() if (!allFilledOut(input)) return try { - viewModel.validateAndContinue(input) + viewModel.validateAndContinue(input, forVerifyingNewCode) } catch (e: InvalidChecksumException) { Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show() } catch (e: WordNotFoundException) { @@ -141,6 +158,24 @@ class RecoveryCodeInputFragment : Fragment() { } } + private fun onExistingCodeChecked(verified: Boolean) { + AlertDialog.Builder(requireContext()).apply { + if (verified) { + setTitle(R.string.recovery_code_verification_ok_title) + setMessage(R.string.recovery_code_verification_ok_message) + setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + setOnDismissListener { parentFragmentManager.popBackStack() } + } else { + setIcon(R.drawable.ic_warning) + setTitle(R.string.recovery_code_verification_error_title) + setMessage(R.string.recovery_code_verification_error_message) + setPositiveButton(R.string.recovery_code_verification_try_again) { dialog, _ -> + dialog.dismiss() + } + } + }.show() + } + @Suppress("MagicNumber") private fun getWordLayout(i: Int) = when (i + 1) { 1 -> wordLayout1 diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt index 4d293641..90ff8893 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.ui.recoverycode import androidx.lifecycle.AndroidViewModel import com.stevesoltys.seedvault.App +import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent @@ -21,7 +22,11 @@ import java.util.ArrayList internal const val WORD_NUM = 12 internal const val WORD_LIST_SIZE = 2048 -class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : AndroidViewModel(app) { +class RecoveryCodeViewModel( + app: App, + private val crypto: Crypto, + private val keyManager: KeyManager +) : AndroidViewModel(app) { internal val wordList: List by lazy { val items: ArrayList = ArrayList(WORD_NUM) @@ -40,10 +45,13 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr private val mRecoveryCodeSaved = MutableLiveEvent() internal val recoveryCodeSaved: LiveEvent = mRecoveryCodeSaved + private val mExistingCodeChecked = MutableLiveEvent() + internal val existingCodeChecked: LiveEvent = mExistingCodeChecked + internal var isRestore: Boolean = false @Throws(WordNotFoundException::class, InvalidChecksumException::class) - fun validateAndContinue(input: List) { + fun validateAndContinue(input: List, forVerifyingNewCode: Boolean) { try { MnemonicValidator.ofWordList(English.INSTANCE).validate(input) } catch (e: UnexpectedWhiteSpaceException) { @@ -53,9 +61,12 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr } val mnemonic = input.joinToString(" ") val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") - keyManager.storeBackupKey(seed) - - mRecoveryCodeSaved.setEvent(true) + if (forVerifyingNewCode) { + keyManager.storeBackupKey(seed) + mRecoveryCodeSaved.setEvent(true) + } else { + mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed)) + } } } diff --git a/app/src/main/res/drawable/ic_vpn_key.xml b/app/src/main/res/drawable/ic_vpn_key.xml new file mode 100644 index 00000000..7b554c94 --- /dev/null +++ b/app/src/main/res/drawable/ic_vpn_key.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fce3fdb0..f3ac4148 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Last backup: %1$s Exclude apps Backup now + Recovery Code + Verify existing code or generate a new one Choose where to store backups @@ -53,7 +55,7 @@ Confirm code Enter your 12-word recovery code to ensure that it will work when you need it. Enter your 12-word recovery code that you wrote down when setting up backups. - Done + Verify Word 1 Word 2 Word 3 @@ -69,6 +71,11 @@ You forgot to enter this word. Wrong word. Did you mean %1$s or %2$s? Your code is invalid. Please check all words and try again! + Recovery Code Verified + Your code is correct and will work for restoring your backup. + Incorrect Recovery Code + You have entered an invalid recovery code. Please try again!\n\nIf you have lost your code, tap on Generate New Code below. + Try Again Backup notification diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index 2665461f..a9954c06 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -36,6 +36,14 @@ app:summary="@string/settings_backup_apk_summary" app:title="@string/settings_backup_apk_title" /> + + Date: Thu, 19 Nov 2020 10:23:12 -0300 Subject: [PATCH 05/52] Allow user to generate new recovery code --- .../recoverycode/RecoveryCodeInputFragment.kt | 37 +++++++++++++++++++ .../layout/fragment_recovery_code_input.xml | 16 +++++++- app/src/main/res/values/strings.xml | 4 ++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt index abd2d8f2..15c1d5ae 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt @@ -1,8 +1,11 @@ package com.stevesoltys.seedvault.ui.recoverycode +import android.app.Activity.RESULT_OK +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.View.GONE import android.view.View.OnFocusChangeListener import android.view.View.VISIBLE import android.view.ViewGroup @@ -12,9 +15,11 @@ import android.widget.Button import android.widget.TextView import android.widget.Toast import android.widget.Toast.LENGTH_LONG +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputLayout import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.isDebugBuild @@ -32,6 +37,7 @@ class RecoveryCodeInputFragment : Fragment() { private lateinit var introText: TextView private lateinit var doneButton: Button + private lateinit var newCodeButton: Button private lateinit var backView: TextView private lateinit var wordLayout1: TextInputLayout private lateinit var wordLayout2: TextInputLayout @@ -61,6 +67,7 @@ class RecoveryCodeInputFragment : Fragment() { introText = v.findViewById(R.id.introText) doneButton = v.findViewById(R.id.doneButton) + newCodeButton = v.findViewById(R.id.newCodeButton) backView = v.findViewById(R.id.backView) wordLayout1 = v.findViewById(R.id.wordLayout1) wordLayout2 = v.findViewById(R.id.wordLayout2) @@ -103,6 +110,8 @@ class RecoveryCodeInputFragment : Fragment() { editText.setAdapter(adapter) } doneButton.setOnClickListener { done() } + newCodeButton.visibility = if (forVerifyingNewCode) GONE else VISIBLE + newCodeButton.setOnClickListener { generateNewCode() } viewModel.existingCodeChecked.observeEvent(viewLifecycleOwner, LiveEventHandler { verified -> onExistingCodeChecked(verified) } @@ -172,10 +181,38 @@ class RecoveryCodeInputFragment : Fragment() { setPositiveButton(R.string.recovery_code_verification_try_again) { dialog, _ -> dialog.dismiss() } + setNegativeButton(R.string.recovery_code_verification_generate_new) { dialog, _ -> + dialog.dismiss() + } } }.show() } + private val regenRequest = registerForActivityResult(StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + parentFragmentManager.popBackStack() + Snackbar.make(requireView(), R.string.recovery_code_recreated, Snackbar.LENGTH_LONG) + .show() + } + } + + private fun generateNewCode() { + AlertDialog.Builder(requireContext()) + .setIcon(R.drawable.ic_warning) + .setTitle(R.string.recovery_code_verification_new_dialog_title) + .setMessage(R.string.recovery_code_verification_new_dialog_message) + .setPositiveButton(R.string.recovery_code_verification_generate_new) { dialog, _ -> + dialog.dismiss() + // TODO try to delete backups + val i = Intent(requireContext(), RecoveryCodeActivity::class.java) + regenRequest.launch(i) + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .show() + } + @Suppress("MagicNumber") private fun getWordLayout(i: Int) = when (i + 1) { 1 -> wordLayout1 diff --git a/app/src/main/res/layout/fragment_recovery_code_input.xml b/app/src/main/res/layout/fragment_recovery_code_input.xml index 09e421e5..911415eb 100644 --- a/app/src/main/res/layout/fragment_recovery_code_input.xml +++ b/app/src/main/res/layout/fragment_recovery_code_input.xml @@ -67,7 +67,7 @@ android:layout_marginBottom="8dp" android:text="@string/recovery_code_done_button" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@+id/newCodeButton" app:layout_constraintStart_toStartOf="parent" /> +