Add Recovery Phrase tab

This commit is contained in:
Andrew 2024-02-29 14:56:30 +10:30
parent 7a1b5749aa
commit ab62d8f333
4 changed files with 226 additions and 126 deletions

View File

@ -363,6 +363,7 @@ dependencies {
implementation 'androidx.compose.animation:animation:1.6.2'
implementation 'androidx.compose.ui:ui-tooling:1.6.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
implementation "androidx.compose.runtime:runtime-livedata:1.6.2"

View File

@ -3,154 +3,170 @@ package org.thoughtcrime.securesms.onboarding
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import androidx.activity.viewModels
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityLinkDeviceBinding
import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.ui.baseBold
import org.thoughtcrime.securesms.ui.colorDestructive
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
class LinkDeviceActivity : BaseActionBarActivity() {
private lateinit var binding: ActivityLinkDeviceBinding
@Inject
lateinit var prefs: TextSecurePreferences
private val adapter = LinkDeviceActivityAdapter(this)
val viewModel: LinkDeviceViewModel by viewModels()
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
TextSecurePreferences.apply {
setHasViewedSeed(this@LinkDeviceActivity, true)
setConfigurationMessageSynced(this@LinkDeviceActivity, false)
setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
setLastProfileUpdateTime(this@LinkDeviceActivity, 0)
}
binding = ActivityLinkDeviceBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
}
// endregion
supportActionBar?.title = "Load Account"
prefs.setHasViewedSeed(true)
prefs.setConfigurationMessageSynced(false)
prefs.setRestorationTime(System.currentTimeMillis())
prefs.setLastProfileUpdateTime(0)
// region Interaction
override fun handleQRCodeScanned(mnemonic: String) {
try {
val seed = Hex.fromStringCondensed(mnemonic)
continueWithSeed(seed)
} catch (e: Exception) {
Log.e("Loki","Error getting seed from QR code", e)
Toast.makeText(this, "An error occurred.", Toast.LENGTH_LONG).show()
}
}
fun continueWithMnemonic(mnemonic: String) {
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName)
}
try {
val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
val seed = Hex.fromStringCondensed(hexEncodedSeed)
continueWithSeed(seed)
} catch (error: Exception) {
val message = if (error is MnemonicCodec.DecodingError) {
error.description
} else {
"An error occurred."
lifecycleScope.launch {
viewModel.eventFlow.collect {
startLoadingActivity(it.mnemonic)
}
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
private fun continueWithSeed(seed: ByteArray) {
startLoadingActivity(seed)
}
// endregion
}
// region Adapter
private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
val recoveryPhraseFragment = RecoveryPhraseFragment()
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> recoveryPhraseFragment
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = activity.getString(R.string.activity_link_device_qr_message)
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence {
return when (index) {
0 -> activity.getString(R.string.activity_link_device_recovery_phrase)
1 -> activity.getString(R.string.activity_link_device_scan_qr_code)
else -> throw IllegalStateException()
}
}
}
// endregion
// region Recovery Phrase Fragment
class RecoveryPhraseFragment : Fragment() {
private lateinit var binding: FragmentRecoveryPhraseBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentRecoveryPhraseBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard
mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT)
mnemonicEditText.setOnEditorActionListener { v, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_DONE) {
val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
handleContinueButtonTapped()
true
} else {
false
ComposeView(this).apply {
setContent {
val state by viewModel.stateFlow.collectAsState()
AppTheme {
LoadAccountScreen(state, viewModel::onChange, viewModel::onRecoveryPhrase)
}
}
continueButton.setOnClickListener { handleContinueButtonTapped() }
}
}.let(::setContentView)
}
private fun handleContinueButtonTapped() {
val mnemonic = binding.mnemonicEditText.text?.trim().toString()
(requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LoadAccountScreen(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
val tabs = listOf(R.string.activity_recovery_password, R.string.activity_link_device_scan_qr_code)
val pagerState = rememberPagerState { tabs.size }
Column {
TabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.height(48.dp)
) {
tabs.forEachIndexed { i, it ->
Tab(i == pagerState.currentPage, onClick = { pagerState.targetPage }) {
Text(stringResource(id = it))
}
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { i ->
when(tabs[i]) {
R.string.activity_recovery_password -> RecoveryPassword(state, onChange, onContinue)
R.string.activity_link_device_scan_qr_code -> ScanQrCode()
}
}
}
}
}
// endregion
@Composable
fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
Column(
modifier = Modifier.padding(horizontal = 60.dp)
) {
Spacer(Modifier.weight(1f))
Row {
Text("Recovery Password", style = MaterialTheme.typography.h4)
Spacer(Modifier.width(6.dp))
Icon(
painter = painterResource(id = R.drawable.ic_recovery_phrase),
contentDescription = "",
)
}
Spacer(Modifier.size(28.dp))
Text("Enter your recovery password to load your account. If you haven't saved it, you can find it in your app settings.")
Spacer(Modifier.size(24.dp))
OutlinedTextField(
value = state.recoveryPhrase,
onValueChange = { onChange(it) },
placeholder = { Text("Enter your recovery password") },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = state.error?.let { colorDestructive } ?: LocalContentColor.current.copy(LocalContentAlpha.current),
focusedBorderColor = Color(0xff414141),
unfocusedBorderColor = Color(0xff414141),
cursorColor = LocalContentColor.current,
placeholderColor = state.error?.let { colorDestructive } ?: MaterialTheme.colors.onSurface.copy(ContentAlpha.medium)
),
singleLine = true,
keyboardActions = KeyboardActions(
onDone = { onContinue() },
onGo = { onContinue() },
onSearch = { onContinue() },
onSend = { onContinue() },
),
isError = state.error != null,
shape = RoundedCornerShape(12.dp)
)
Spacer(Modifier.size(12.dp))
state.error?.let {
Text(it, style = MaterialTheme.typography.baseBold, color = MaterialTheme.colors.error)
}
Spacer(Modifier.weight(2f))
OutlineButton(
text = stringResource(id = R.string.continue_2),
modifier = Modifier.align(Alignment.CenterHorizontally).padding(horizontal = 64.dp, vertical = 20.dp).width(200.dp)
) { onContinue() }
}
}
@Composable
fun ScanQrCode() {
}
fun Context.startLinkDeviceActivity() {
Intent(this, LinkDeviceActivity::class.java).let(::startActivity)
}
}

View File

@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.onboarding
data class LinkDeviceState(
val recoveryPhrase: String = "",
val error: String? = null
)

View File

@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.onboarding
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.Hex
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import javax.inject.Inject
class LinkDeviceEvent(val mnemonic: ByteArray)
@HiltViewModel
class LinkDeviceViewModel @Inject constructor(
application: Application
): AndroidViewModel(application) {
private val state = MutableStateFlow(LinkDeviceState())
val stateFlow = state.asStateFlow()
private val event = Channel<LinkDeviceEvent>()
val eventFlow = event.receiveAsFlow()
fun onRecoveryPhrase() {
val mnemonic = state.value.recoveryPhrase
viewModelScope.launch(Dispatchers.IO) {
try {
MnemonicCodec { MnemonicUtilities.loadFileContents(getApplication(), it) }
.decode(mnemonic)
.let(Hex::fromStringCondensed)
.let(::LinkDeviceEvent)
.let { event.send(it) }
} catch (exception: Exception) {
when (exception) {
is MnemonicCodec.DecodingError -> exception.description
else -> "An error occurred."
}.let { error -> state.update { it.copy(error = error) } }
}
}
}
// override fun handleQRCodeScanned(mnemonic: String) {
// try {
// val seed = Hex.fromStringCondensed(mnemonic)
// continueWithSeed(seed)
// } catch (e: Exception) {
// Log.e("Loki","Error getting seed from QR code", e)
// Toast.makeText(this, "An error occurred.", Toast.LENGTH_LONG).show()
// }
// }
// fun continueWithMnemonic(mnemonic: String) {
// val loadFileContents: (String) -> String = { fileName ->
// MnemonicUtilities.loadFileContents(this, fileName)
// }
// try {
// val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
// val seed = Hex.fromStringCondensed(hexEncodedSeed)
// continueWithSeed(seed)
// } catch (error: Exception) {
// val message = if (error is MnemonicCodec.DecodingError) {
// error.description
// } else {
// "An error occurred."
fun onChange(recoveryPhrase: String) {
state.value = LinkDeviceState(recoveryPhrase)
}
}