Let user write down recovery code on first start
This commit is contained in:
parent
ee6cf38312
commit
66c0919eb5
20 changed files with 873 additions and 17 deletions
|
@ -83,8 +83,11 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
implementation 'io.github.novacrypto:BIP39:2019.01.27'
|
||||
|
||||
implementation "androidx.core:core-ktx:1.0.2"
|
||||
implementation 'androidx.core:core-ktx:1.0.2'
|
||||
implementation 'androidx.preference:preference-ktx:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
}
|
||||
|
|
|
@ -18,17 +18,21 @@
|
|||
|
||||
<application
|
||||
android:name=".Backup"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
|
||||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:exported="true" />
|
||||
|
||||
<activity
|
||||
android:name=".settings.RecoveryCodeActivity"
|
||||
android:label="@string/recovery_code_title" />
|
||||
|
||||
<activity
|
||||
android:name="com.stevesoltys.backup.activity.MainActivity"
|
||||
android:label="@string/app_name">
|
||||
|
|
35
app/src/main/java/com/stevesoltys/backup/LiveEvent.kt
Normal file
35
app/src/main/java/com/stevesoltys/backup/LiveEvent.kt
Normal file
|
@ -0,0 +1,35 @@
|
|||
package com.stevesoltys.backup
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import com.stevesoltys.backup.LiveEvent.ConsumableEvent
|
||||
|
||||
open class LiveEvent<T> : LiveData<ConsumableEvent<T>>() {
|
||||
|
||||
fun observeEvent(owner: LifecycleOwner, handler: LiveEventHandler<in T>) {
|
||||
val observer = LiveEventObserver(handler)
|
||||
super.observe(owner, observer)
|
||||
}
|
||||
|
||||
class ConsumableEvent<T>(private val content: T) {
|
||||
private var consumed = false
|
||||
|
||||
val contentIfNotConsumed: T?
|
||||
get() {
|
||||
if (consumed) return null
|
||||
consumed = true
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
internal class LiveEventObserver<T>(private val handler: LiveEventHandler<in T>) : Observer<ConsumableEvent<T>> {
|
||||
override fun onChanged(consumableEvent: ConsumableEvent<T>?) {
|
||||
if (consumableEvent != null) {
|
||||
val content = consumableEvent.contentIfNotConsumed
|
||||
if (content != null) handler.onEvent(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.stevesoltys.backup;
|
||||
|
||||
public interface LiveEventHandler<T> {
|
||||
void onEvent(T t);
|
||||
}
|
13
app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt
Normal file
13
app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt
Normal file
|
@ -0,0 +1,13 @@
|
|||
package com.stevesoltys.backup
|
||||
|
||||
class MutableLiveEvent<T> : LiveEvent<T>() {
|
||||
|
||||
fun postEvent(value: T) {
|
||||
super.postValue(ConsumableEvent(value))
|
||||
}
|
||||
|
||||
fun setEvent(value: T) {
|
||||
super.setValue(ConsumableEvent(value))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.LiveEventHandler
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
class RecoveryCodeActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var viewModel: RecoveryCodeViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_recovery_code)
|
||||
|
||||
viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java)
|
||||
viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked ->
|
||||
if (clicked) {
|
||||
val tag = "Confirm"
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, RecoveryCodeInputFragment(), tag)
|
||||
.addToBackStack(tag)
|
||||
.commit()
|
||||
}
|
||||
})
|
||||
viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved ->
|
||||
if (saved) {
|
||||
setResult(RESULT_OK)
|
||||
finishAfterTransition()
|
||||
}
|
||||
})
|
||||
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.fragment, RecoveryCodeOutputFragment(), "Code")
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when {
|
||||
item.itemId == android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
class RecoveryCodeAdapter(private val items: List<CharSequence>) : Adapter<RecoveryCodeViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecoveryCodeViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.list_item_recovery_code_output, parent, false) as View
|
||||
return RecoveryCodeViewHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecoveryCodeViewHolder, position: Int) {
|
||||
holder.bind(position + 1, items[position])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class RecoveryCodeViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
|
||||
private val num = v.findViewById<TextView>(R.id.num)
|
||||
private val word = v.findViewById<TextView>(R.id.word)
|
||||
|
||||
internal fun bind(number: Int, item: CharSequence) {
|
||||
num.text = number.toString()
|
||||
word.text = item
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.OnFocusChangeListener
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_LONG
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.R
|
||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
||||
import kotlinx.android.synthetic.main.fragment_recovery_code_input.*
|
||||
import kotlinx.android.synthetic.main.recovery_code_input.*
|
||||
|
||||
class RecoveryCodeInputFragment : Fragment() {
|
||||
|
||||
private lateinit var viewModel: RecoveryCodeViewModel
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java)
|
||||
|
||||
for (i in 0 until WORD_NUM) {
|
||||
val wordLayout = getWordLayout(i)
|
||||
wordLayout.editText!!.onFocusChangeListener = OnFocusChangeListener { _, focus ->
|
||||
if (!focus) wordLayout.isErrorEnabled = false
|
||||
}
|
||||
}
|
||||
doneButton.setOnClickListener { done() }
|
||||
|
||||
if (Build.TYPE == "userdebug") debugPreFill()
|
||||
}
|
||||
|
||||
private fun getInput(): List<CharSequence> = ArrayList<String>(WORD_NUM).apply {
|
||||
for (i in 0 until WORD_NUM) add(getWordLayout(i).editText!!.text.toString())
|
||||
}
|
||||
|
||||
private fun done() {
|
||||
val input = getInput()
|
||||
if (!allFilledOut(input)) return
|
||||
try {
|
||||
viewModel.validateAndContinue(input)
|
||||
} catch (e: InvalidChecksumException) {
|
||||
Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show()
|
||||
} catch (e: WordNotFoundException) {
|
||||
showWrongWordError(input, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun allFilledOut(input: List<CharSequence>): Boolean {
|
||||
for (i in 0 until input.size) {
|
||||
if (input[i].isNotEmpty()) continue
|
||||
showError(i, getString(R.string.recovery_code_error_empty_word))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showWrongWordError(input: List<CharSequence>, e: WordNotFoundException) {
|
||||
val i = input.indexOf(e.word)
|
||||
if (i == -1) throw AssertionError()
|
||||
showError(i, getString(R.string.recovery_code_error_invalid_word, e.suggestion1, e.suggestion2))
|
||||
}
|
||||
|
||||
private fun showError(i: Int, errorMsg: CharSequence) {
|
||||
getWordLayout(i).apply {
|
||||
error = errorMsg
|
||||
requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWordLayout(i: Int) = when (i + 1) {
|
||||
1 -> wordLayout1
|
||||
2 -> wordLayout2
|
||||
3 -> wordLayout3
|
||||
4 -> wordLayout4
|
||||
5 -> wordLayout5
|
||||
6 -> wordLayout6
|
||||
7 -> wordLayout7
|
||||
8 -> wordLayout8
|
||||
9 -> wordLayout9
|
||||
10 -> wordLayout10
|
||||
11 -> wordLayout11
|
||||
12 -> wordLayout12
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
private fun debugPreFill() {
|
||||
val words = viewModel.wordList
|
||||
for (i in 0 until words.size) {
|
||||
getWordLayout(i).editText!!.setText(words[i])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.backup.R
|
||||
import kotlinx.android.synthetic.main.fragment_recovery_code_output.*
|
||||
|
||||
class RecoveryCodeOutputFragment : Fragment() {
|
||||
|
||||
private lateinit var viewModel: RecoveryCodeViewModel
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java)
|
||||
|
||||
setGridParameters(wordList)
|
||||
wordList.adapter = RecoveryCodeAdapter(viewModel.wordList)
|
||||
|
||||
confirmCodeButton.setOnClickListener { viewModel.onConfirmButtonClicked() }
|
||||
}
|
||||
|
||||
private fun setGridParameters(list: RecyclerView) {
|
||||
val layoutManager = list.layoutManager as GridLayoutManager
|
||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
layoutManager.orientation = RecyclerView.VERTICAL
|
||||
layoutManager.spanCount = 4
|
||||
} else {
|
||||
layoutManager.orientation = RecyclerView.HORIZONTAL
|
||||
layoutManager.spanCount = 6
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.util.ByteStringUtils
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.backup.LiveEvent
|
||||
import com.stevesoltys.backup.MutableLiveEvent
|
||||
import io.github.novacrypto.bip39.*
|
||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
|
||||
import io.github.novacrypto.bip39.Validation.UnexpectedWhiteSpaceException
|
||||
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
||||
import io.github.novacrypto.bip39.wordlists.English
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
internal const val WORD_NUM = 12
|
||||
|
||||
class RecoveryCodeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
internal val wordList: List<CharSequence> by lazy {
|
||||
val items: ArrayList<CharSequence> = ArrayList(WORD_NUM)
|
||||
// TODO factor out entropy generation
|
||||
val entropy = ByteArray(Words.TWELVE.byteLength())
|
||||
SecureRandom().nextBytes(entropy)
|
||||
MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) {
|
||||
if (it != " ") items.add(it)
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
private val mConfirmButtonClicked = MutableLiveEvent<Boolean>()
|
||||
internal val confirmButtonClicked: LiveEvent<Boolean> = mConfirmButtonClicked
|
||||
internal fun onConfirmButtonClicked() = mConfirmButtonClicked.setEvent(true)
|
||||
|
||||
internal val recoveryCodeSaved = MutableLiveEvent<Boolean>()
|
||||
|
||||
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
|
||||
fun validateAndContinue(input: List<CharSequence>) {
|
||||
try {
|
||||
MnemonicValidator.ofWordList(English.INSTANCE).validate(input)
|
||||
} catch (e: UnexpectedWhiteSpaceException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: InvalidWordCountException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
val mnemonic = input.joinToString(" ")
|
||||
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
||||
// TODO use KeyManager to store secret
|
||||
setBackupPassword(getApplication(), ByteStringUtils.toHexString(seed))
|
||||
recoveryCodeSaved.setEvent(true)
|
||||
}
|
||||
|
||||
}
|
|
@ -13,10 +13,13 @@ import android.widget.Toast.LENGTH_SHORT
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE
|
||||
|
||||
private val TAG = SettingsActivity::class.java.name
|
||||
|
||||
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
|
||||
const val REQUEST_CODE_RECOVERY_CODE = 2
|
||||
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var viewModel: SettingsViewModel
|
||||
|
@ -31,9 +34,25 @@ class SettingsActivity : AppCompatActivity() {
|
|||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
|
||||
if (resultCode != RESULT_OK) {
|
||||
Log.w(TAG, "Error in activity result: $requestCode")
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
if (requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
|
||||
viewModel.handleChooseFolderResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (!viewModel.locationIsSet()) {
|
||||
if (isFinishing) return
|
||||
|
||||
// check that backup is provisioned
|
||||
if (!viewModel.recoveryCodeIsSet()) {
|
||||
showRecoveryCodeActivity()
|
||||
} else if (!viewModel.locationIsSet()) {
|
||||
showChooseFolderActivity()
|
||||
}
|
||||
}
|
||||
|
@ -64,15 +83,9 @@ class SettingsActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
|
||||
if (resultCode != RESULT_OK) {
|
||||
Log.w(TAG, "Error in activity result: $requestCode")
|
||||
return
|
||||
}
|
||||
|
||||
if (requestCode == OPEN_DOCUMENT_TREE_REQUEST_CODE) {
|
||||
viewModel.handleChooseFolderResult(result)
|
||||
}
|
||||
private fun showRecoveryCodeActivity() {
|
||||
val intent = Intent(this, RecoveryCodeActivity::class.java)
|
||||
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
|
||||
}
|
||||
|
||||
private fun showChooseFolderActivity() {
|
||||
|
@ -82,7 +95,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||
// TODO StringRes
|
||||
try {
|
||||
val documentChooser = createChooser(openTreeIntent, "Select the backup location")
|
||||
startActivityForResult(documentChooser, OPEN_DOCUMENT_TREE_REQUEST_CODE)
|
||||
startActivityForResult(documentChooser, REQUEST_CODE_OPEN_DOCUMENT_TREE)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Toast.makeText(this, "Please install a file manager.", LENGTH_LONG).show()
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
private val app = application
|
||||
|
||||
fun recoveryCodeIsSet() = getBackupPassword(getApplication()) != null
|
||||
fun locationIsSet() = getBackupFolderUri(getApplication()) != null
|
||||
|
||||
fun handleChooseFolderResult(result: Intent?) {
|
||||
|
|
5
app/src/main/res/layout/activity_recovery_code.xml
Normal file
5
app/src/main/res/layout/activity_recovery_code.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
75
app/src/main/res/layout/fragment_recovery_code_input.xml
Normal file
75
app/src/main/res/layout/fragment_recovery_code_input.xml
Normal file
|
@ -0,0 +1,75 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:context=".settings.RecoveryCodeInputFragment">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/introIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:src="@drawable/ic_info_outline"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/introText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/recovery_code_confirm_intro"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/introIcon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/divider"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/introText" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/wordList"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toTopOf="@+id/doneButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider">
|
||||
|
||||
<include layout="@layout/recovery_code_input" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/doneButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/recovery_code_done_button"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</ScrollView>
|
83
app/src/main/res/layout/fragment_recovery_code_output.xml
Normal file
83
app/src/main/res/layout/fragment_recovery_code_output.xml
Normal file
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".settings.RecoveryCodeFragment">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/introIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:src="@drawable/ic_info_outline"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/introText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/recovery_code_12_word_intro"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/introIcon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/introText2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/recovery_code_write_it_down"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/introIcon"
|
||||
app:layout_constraintTop_toBottomOf="@+id/introText" />
|
||||
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/divider"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/introText2" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/wordList"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="horizontal"
|
||||
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
|
||||
app:layout_constraintBottom_toTopOf="@+id/confirmCodeButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider"
|
||||
app:spanCount="6"
|
||||
tools:itemCount="12"
|
||||
tools:listitem="@layout/list_item_recovery_code_output" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/confirmCodeButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/recovery_code_confirm_button"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
36
app/src/main/res/layout/list_item_recovery_code_output.xml
Normal file
36
app/src/main/res/layout/list_item_recovery_code_output.xml
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
tools:showIn="@layout/fragment_recovery_code_output">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/num"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="0dp"
|
||||
android:gravity="center_vertical|end"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/word"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="1." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/word"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:textSize="24sp"
|
||||
android:autoSizeTextType="uniform"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/num"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Test1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
263
app/src/main/res/layout/recovery_code_input.xml
Normal file
263
app/src/main/res/layout/recovery_code_input.xml
Normal file
|
@ -0,0 +1,263 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
|
||||
tools:showIn="@layout/fragment_recovery_code_input">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_1"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout2"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="spread_inside">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput2" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_2"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout3"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout1">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput3" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_3"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout4"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout2">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput4" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_4"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout5"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout3">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput5" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_5"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout6"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout4">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput6" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_6"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout5">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput7" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout7"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_7"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout8"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="spread_inside">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput7"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput8" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout8"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_8"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout9"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout7">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput8"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput9" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout9"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_9"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout10"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout8">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput9"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput10" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout10"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_10"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout11"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout9">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput10"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput11" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout11"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_11"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/wordLayout12"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout10">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput11"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete"
|
||||
android:nextFocusForward="@+id/wordInput12" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/wordLayout12"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/recovery_code_input_hint_12"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
|
||||
app:layout_constraintTop_toBottomOf="@+id/wordLayout11">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wordInput12"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone|flagNoPersonalizedLearning"
|
||||
android:inputType="textAutoComplete" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</merge>
|
|
@ -3,4 +3,6 @@
|
|||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#FF4081</color>
|
||||
|
||||
<color name="divider">#8A000000</color>
|
||||
</resources>
|
||||
|
|
|
@ -32,4 +32,27 @@
|
|||
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data</string>
|
||||
<string name="settings_backup_now">Backup now</string>
|
||||
|
||||
<!-- Recovery Code -->
|
||||
<string name="recovery_code_title">Recovery Code</string>
|
||||
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>
|
||||
<string name="recovery_code_write_it_down">Write it down on paper now!</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_done_button">Done</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_3">Word 3</string>
|
||||
<string name="recovery_code_input_hint_4">Word 4</string>
|
||||
<string name="recovery_code_input_hint_5">Word 5</string>
|
||||
<string name="recovery_code_input_hint_6">Word 6</string>
|
||||
<string name="recovery_code_input_hint_7">Word 7</string>
|
||||
<string name="recovery_code_input_hint_8">Word 8</string>
|
||||
<string name="recovery_code_input_hint_9">Word 9</string>
|
||||
<string name="recovery_code_input_hint_10">Word 10</string>
|
||||
<string name="recovery_code_input_hint_11">Word 11</string>
|
||||
<string name="recovery_code_input_hint_12">Word 12</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_checksum_word">We are so sorry! An unexpected error occurred.</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
buildscript {
|
||||
|
||||
ext.kotlin_version = '1.3.40'
|
||||
ext.kotlin_version = '1.3.41'
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
|
|
Loading…
Reference in a new issue