diff --git a/app/build.gradle b/app/build.gradle index a2bac220..0545a964 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cce2844f..0bfb69e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,17 +18,21 @@ + + diff --git a/app/src/main/java/com/stevesoltys/backup/LiveEvent.kt b/app/src/main/java/com/stevesoltys/backup/LiveEvent.kt new file mode 100644 index 00000000..83aede27 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/LiveEvent.kt @@ -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 : LiveData>() { + + fun observeEvent(owner: LifecycleOwner, handler: LiveEventHandler) { + val observer = LiveEventObserver(handler) + super.observe(owner, observer) + } + + class ConsumableEvent(private val content: T) { + private var consumed = false + + val contentIfNotConsumed: T? + get() { + if (consumed) return null + consumed = true + return content + } + } + + internal class LiveEventObserver(private val handler: LiveEventHandler) : Observer> { + override fun onChanged(consumableEvent: ConsumableEvent?) { + if (consumableEvent != null) { + val content = consumableEvent.contentIfNotConsumed + if (content != null) handler.onEvent(content) + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java b/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java new file mode 100644 index 00000000..22d86af0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java @@ -0,0 +1,5 @@ +package com.stevesoltys.backup; + +public interface LiveEventHandler { + void onEvent(T t); +} diff --git a/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt b/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt new file mode 100644 index 00000000..7086bd40 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt @@ -0,0 +1,13 @@ +package com.stevesoltys.backup + +class MutableLiveEvent : LiveEvent() { + + fun postEvent(value: T) { + super.postValue(ConsumableEvent(value)) + } + + fun setEvent(value: T) { + super.setValue(ConsumableEvent(value)) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt new file mode 100644 index 00000000..0e737f94 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt @@ -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) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt new file mode 100644 index 00000000..cc4e009c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt @@ -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) : Adapter() { + + 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(R.id.num) + private val word = v.findViewById(R.id.word) + + internal fun bind(number: Int, item: CharSequence) { + num.text = number.toString() + word.text = item + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt new file mode 100644 index 00000000..26918c49 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt @@ -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 = ArrayList(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): 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, 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]) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt new file mode 100644 index 00000000..724cb5a1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt @@ -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 + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt new file mode 100644 index 00000000..24743d84 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt @@ -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 by lazy { + val items: ArrayList = 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() + internal val confirmButtonClicked: LiveEvent = mConfirmButtonClicked + internal fun onConfirmButtonClicked() = mConfirmButtonClicked.setEvent(true) + + internal val recoveryCodeSaved = MutableLiveEvent() + + @Throws(WordNotFoundException::class, InvalidChecksumException::class) + fun validateAndContinue(input: List) { + 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) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt index 9cc06890..3ffb4f4f 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt @@ -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() } diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt index df47f1c7..a49608f9 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -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?) { diff --git a/app/src/main/res/layout/activity_recovery_code.xml b/app/src/main/res/layout/activity_recovery_code.xml new file mode 100644 index 00000000..d64f58e8 --- /dev/null +++ b/app/src/main/res/layout/activity_recovery_code.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_recovery_code_input.xml b/app/src/main/res/layout/fragment_recovery_code_input.xml new file mode 100644 index 00000000..1442389a --- /dev/null +++ b/app/src/main/res/layout/fragment_recovery_code_input.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + +