Allow user to verify existing recovery code
This commit is contained in:
parent
b7f8c24538
commit
d64413a7c3
10 changed files with 119 additions and 11 deletions
|
@ -44,7 +44,7 @@ class App : Application() {
|
||||||
factory { AppListRetriever(this@App, get(), get(), get()) }
|
factory { AppListRetriever(this@App, get(), get(), get()) }
|
||||||
|
|
||||||
viewModel { SettingsViewModel(this@App, get(), get(), 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 { BackupStorageViewModel(this@App, get(), get(), get()) }
|
||||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
||||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), 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 ANCESTRAL_RECORD_KEY = "@ancestral_record@"
|
||||||
const val GLOBAL_METADATA_KEY = "@meta@"
|
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 isDebugBuild() = Build.TYPE == "userdebug"
|
||||||
|
|
||||||
fun <T> permitDiskReads(func: () -> T): T {
|
fun <T> permitDiskReads(func: () -> T): T {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.crypto
|
package com.stevesoltys.seedvault.crypto
|
||||||
|
|
||||||
|
import java.security.Key
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.Cipher.DECRYPT_MODE
|
import javax.crypto.Cipher.DECRYPT_MODE
|
||||||
import javax.crypto.Cipher.ENCRYPT_MODE
|
import javax.crypto.Cipher.ENCRYPT_MODE
|
||||||
|
@ -11,6 +12,7 @@ internal const val GCM_AUTHENTICATION_TAG_LENGTH = 128
|
||||||
interface CipherFactory {
|
interface CipherFactory {
|
||||||
fun createEncryptionCipher(): Cipher
|
fun createEncryptionCipher(): Cipher
|
||||||
fun createDecryptionCipher(iv: ByteArray): Cipher
|
fun createDecryptionCipher(iv: ByteArray): Cipher
|
||||||
|
fun createEncryptionTestCipher(key: Key, iv: ByteArray): Cipher
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class CipherFactoryImpl(private val keyManager: KeyManager) : CipherFactory {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -95,6 +96,13 @@ interface Crypto {
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
fun decryptMultipleSegments(inputStream: InputStream): ByteArray
|
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(
|
internal class CryptoImpl(
|
||||||
|
@ -204,4 +212,19 @@ internal class CryptoImpl(
|
||||||
return cipher.doFinal(buffer)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
internal const val KEY_SIZE = 256
|
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 KEY_ALIAS = "com.stevesoltys.seedvault"
|
||||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
@ -47,7 +47,6 @@ internal class KeyManagerImpl : KeyManager {
|
||||||
|
|
||||||
override fun storeBackupKey(seed: ByteArray) {
|
override fun storeBackupKey(seed: ByteArray) {
|
||||||
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
|
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 secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
|
||||||
val ksEntry = SecretKeyEntry(secretKeySpec)
|
val ksEntry = SecretKeyEntry(secretKeySpec)
|
||||||
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
|
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
|
||||||
|
|
|
@ -9,10 +9,12 @@ import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
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.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
internal const val ACTION_APP_STATUS_LIST = "com.stevesoltys.seedvault.APP_STATUS_LIST"
|
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 {
|
class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmentCallback {
|
||||||
|
|
||||||
|
@ -57,6 +59,9 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val fragment =
|
val fragment =
|
||||||
supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.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()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.fragment, fragment)
|
.replace(R.id.fragment, fragment)
|
||||||
.addToBackStack(null)
|
.addToBackStack(null)
|
||||||
|
|
|
@ -12,16 +12,20 @@ import android.widget.Button
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.widget.Toast.LENGTH_LONG
|
import android.widget.Toast.LENGTH_LONG
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.isDebugBuild
|
import com.stevesoltys.seedvault.isDebugBuild
|
||||||
|
import com.stevesoltys.seedvault.ui.LiveEventHandler
|
||||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||||
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
||||||
import io.github.novacrypto.bip39.wordlists.English
|
import io.github.novacrypto.bip39.wordlists.English
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
|
internal const val ARG_FOR_NEW_CODE = "forVerifyingNewCode"
|
||||||
|
|
||||||
class RecoveryCodeInputFragment : Fragment() {
|
class RecoveryCodeInputFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
||||||
|
@ -43,6 +47,11 @@ class RecoveryCodeInputFragment : Fragment() {
|
||||||
private lateinit var wordLayout12: TextInputLayout
|
private lateinit var wordLayout12: TextInputLayout
|
||||||
private lateinit var wordList: ConstraintLayout
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -67,6 +76,10 @@ class RecoveryCodeInputFragment : Fragment() {
|
||||||
wordLayout12 = v.findViewById(R.id.wordLayout12)
|
wordLayout12 = v.findViewById(R.id.wordLayout12)
|
||||||
wordList = v.findViewById(R.id.wordList)
|
wordList = v.findViewById(R.id.wordList)
|
||||||
|
|
||||||
|
arguments?.getBoolean(ARG_FOR_NEW_CODE, true)?.let {
|
||||||
|
forVerifyingNewCode = it
|
||||||
|
}
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +104,11 @@ class RecoveryCodeInputFragment : Fragment() {
|
||||||
}
|
}
|
||||||
doneButton.setOnClickListener { done() }
|
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<String> {
|
private fun getAdapter(): ArrayAdapter<String> {
|
||||||
|
@ -110,7 +127,7 @@ class RecoveryCodeInputFragment : Fragment() {
|
||||||
val input = getInput()
|
val input = getInput()
|
||||||
if (!allFilledOut(input)) return
|
if (!allFilledOut(input)) return
|
||||||
try {
|
try {
|
||||||
viewModel.validateAndContinue(input)
|
viewModel.validateAndContinue(input, forVerifyingNewCode)
|
||||||
} catch (e: InvalidChecksumException) {
|
} catch (e: InvalidChecksumException) {
|
||||||
Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show()
|
Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show()
|
||||||
} catch (e: WordNotFoundException) {
|
} 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")
|
@Suppress("MagicNumber")
|
||||||
private fun getWordLayout(i: Int) = when (i + 1) {
|
private fun getWordLayout(i: Int) = when (i + 1) {
|
||||||
1 -> wordLayout1
|
1 -> wordLayout1
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.ui.recoverycode
|
||||||
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.stevesoltys.seedvault.App
|
import com.stevesoltys.seedvault.App
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
|
@ -21,7 +22,11 @@ import java.util.ArrayList
|
||||||
internal const val WORD_NUM = 12
|
internal const val WORD_NUM = 12
|
||||||
internal const val WORD_LIST_SIZE = 2048
|
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<CharSequence> by lazy {
|
internal val wordList: List<CharSequence> by lazy {
|
||||||
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
|
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
|
||||||
|
@ -40,10 +45,13 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr
|
||||||
private val mRecoveryCodeSaved = MutableLiveEvent<Boolean>()
|
private val mRecoveryCodeSaved = MutableLiveEvent<Boolean>()
|
||||||
internal val recoveryCodeSaved: LiveEvent<Boolean> = mRecoveryCodeSaved
|
internal val recoveryCodeSaved: LiveEvent<Boolean> = mRecoveryCodeSaved
|
||||||
|
|
||||||
|
private val mExistingCodeChecked = MutableLiveEvent<Boolean>()
|
||||||
|
internal val existingCodeChecked: LiveEvent<Boolean> = mExistingCodeChecked
|
||||||
|
|
||||||
internal var isRestore: Boolean = false
|
internal var isRestore: Boolean = false
|
||||||
|
|
||||||
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
|
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
|
||||||
fun validateAndContinue(input: List<CharSequence>) {
|
fun validateAndContinue(input: List<CharSequence>, forVerifyingNewCode: Boolean) {
|
||||||
try {
|
try {
|
||||||
MnemonicValidator.ofWordList(English.INSTANCE).validate(input)
|
MnemonicValidator.ofWordList(English.INSTANCE).validate(input)
|
||||||
} catch (e: UnexpectedWhiteSpaceException) {
|
} catch (e: UnexpectedWhiteSpaceException) {
|
||||||
|
@ -53,9 +61,12 @@ class RecoveryCodeViewModel(app: App, private val keyManager: KeyManager) : Andr
|
||||||
}
|
}
|
||||||
val mnemonic = input.joinToString(" ")
|
val mnemonic = input.joinToString(" ")
|
||||||
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
||||||
keyManager.storeBackupKey(seed)
|
if (forVerifyingNewCode) {
|
||||||
|
keyManager.storeBackupKey(seed)
|
||||||
mRecoveryCodeSaved.setEvent(true)
|
mRecoveryCodeSaved.setEvent(true)
|
||||||
|
} else {
|
||||||
|
mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
10
app/src/main/res/drawable/ic_vpn_key.xml
Normal file
10
app/src/main/res/drawable/ic_vpn_key.xml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
|
||||||
|
</vector>
|
|
@ -28,6 +28,8 @@
|
||||||
<string name="settings_backup_status_summary">Last backup: %1$s</string>
|
<string name="settings_backup_status_summary">Last backup: %1$s</string>
|
||||||
<string name="settings_backup_exclude_apps">Exclude apps</string>
|
<string name="settings_backup_exclude_apps">Exclude apps</string>
|
||||||
<string name="settings_backup_now">Backup now</string>
|
<string name="settings_backup_now">Backup now</string>
|
||||||
|
<string name="settings_backup_recovery_code">Recovery Code</string>
|
||||||
|
<string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</string>
|
||||||
|
|
||||||
<!-- Storage -->
|
<!-- Storage -->
|
||||||
<string name="storage_fragment_backup_title">Choose where to store backups</string>
|
<string name="storage_fragment_backup_title">Choose where to store backups</string>
|
||||||
|
@ -53,7 +55,7 @@
|
||||||
<string name="recovery_code_confirm_button">Confirm code</string>
|
<string name="recovery_code_confirm_button">Confirm code</string>
|
||||||
<string name="recovery_code_confirm_intro">Enter your 12-word recovery code to ensure that it will work when you need it.</string>
|
<string name="recovery_code_confirm_intro">Enter your 12-word recovery code to ensure that it will work when you need it.</string>
|
||||||
<string name="recovery_code_input_intro">Enter your 12-word recovery code that you wrote down when setting up backups.</string>
|
<string name="recovery_code_input_intro">Enter your 12-word recovery code that you wrote down when setting up backups.</string>
|
||||||
<string name="recovery_code_done_button">Done</string>
|
<string name="recovery_code_done_button">Verify</string>
|
||||||
<string name="recovery_code_input_hint_1">Word 1</string>
|
<string name="recovery_code_input_hint_1">Word 1</string>
|
||||||
<string name="recovery_code_input_hint_2">Word 2</string>
|
<string name="recovery_code_input_hint_2">Word 2</string>
|
||||||
<string name="recovery_code_input_hint_3">Word 3</string>
|
<string name="recovery_code_input_hint_3">Word 3</string>
|
||||||
|
@ -69,6 +71,11 @@
|
||||||
<string name="recovery_code_error_empty_word">You forgot to enter this word.</string>
|
<string name="recovery_code_error_empty_word">You forgot to enter this word.</string>
|
||||||
<string name="recovery_code_error_invalid_word">Wrong word. Did you mean %1$s or %2$s?</string>
|
<string name="recovery_code_error_invalid_word">Wrong word. Did you mean %1$s or %2$s?</string>
|
||||||
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
|
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
|
||||||
|
<string name="recovery_code_verification_ok_title">Recovery Code Verified</string>
|
||||||
|
<string name="recovery_code_verification_ok_message">Your code is correct and will work for restoring your backup.</string>
|
||||||
|
<string name="recovery_code_verification_error_title">Incorrect Recovery Code</string>
|
||||||
|
<string name="recovery_code_verification_error_message">You have entered an invalid recovery code. Please try again!\n\nIf you have lost your code, tap on Generate New Code below.</string>
|
||||||
|
<string name="recovery_code_verification_try_again">Try Again</string>
|
||||||
|
|
||||||
<!-- Notification -->
|
<!-- Notification -->
|
||||||
<string name="notification_channel_title">Backup notification</string>
|
<string name="notification_channel_title">Backup notification</string>
|
||||||
|
|
|
@ -36,6 +36,14 @@
|
||||||
app:summary="@string/settings_backup_apk_summary"
|
app:summary="@string/settings_backup_apk_summary"
|
||||||
app:title="@string/settings_backup_apk_title" />
|
app:title="@string/settings_backup_apk_title" />
|
||||||
|
|
||||||
|
<androidx.preference.Preference
|
||||||
|
app:dependency="backup"
|
||||||
|
app:fragment="com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeInputFragment"
|
||||||
|
app:icon="@drawable/ic_vpn_key"
|
||||||
|
app:key="backup_recovery_code"
|
||||||
|
app:summary="@string/settings_backup_recovery_code_summary"
|
||||||
|
app:title="@string/settings_backup_recovery_code" />
|
||||||
|
|
||||||
<androidx.preference.Preference
|
<androidx.preference.Preference
|
||||||
app:allowDividerAbove="true"
|
app:allowDividerAbove="true"
|
||||||
app:allowDividerBelow="false"
|
app:allowDividerBelow="false"
|
||||||
|
|
Loading…
Reference in a new issue