Add Recovery Phrase tab

pull/1331/head
Andrew 1 year ago
parent 7a1b5749aa
commit ab62d8f333

@ -363,6 +363,7 @@ dependencies {
implementation 'androidx.compose.animation:animation:1.6.2' implementation 'androidx.compose.animation:animation:1.6.2'
implementation 'androidx.compose.ui:ui-tooling: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-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 "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
implementation "androidx.compose.runtime:runtime-livedata:1.6.2" implementation "androidx.compose.runtime:runtime-livedata:1.6.2"

@ -3,154 +3,170 @@ package org.thoughtcrime.securesms.onboarding
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.text.InputType import androidx.activity.viewModels
import android.view.LayoutInflater import androidx.compose.foundation.ExperimentalFoundationApi
import android.view.View import androidx.compose.foundation.layout.Arrangement
import android.view.ViewGroup import androidx.compose.foundation.layout.Column
import android.view.inputmethod.EditorInfo import androidx.compose.foundation.layout.Row
import android.view.inputmethod.InputMethodManager import androidx.compose.foundation.layout.Spacer
import android.widget.Toast import androidx.compose.foundation.layout.height
import androidx.fragment.app.Fragment import androidx.compose.foundation.layout.padding
import androidx.fragment.app.FragmentPagerAdapter 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R 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.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.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.ui.baseBold
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.ui.colorDestructive
import javax.inject.Inject
@AndroidEntryPoint @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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setUpActionBarSessionLogo() supportActionBar?.title = "Load Account"
TextSecurePreferences.apply { prefs.setHasViewedSeed(true)
setHasViewedSeed(this@LinkDeviceActivity, true) prefs.setConfigurationMessageSynced(false)
setConfigurationMessageSynced(this@LinkDeviceActivity, false) prefs.setRestorationTime(System.currentTimeMillis())
setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis()) prefs.setLastProfileUpdateTime(0)
setLastProfileUpdateTime(this@LinkDeviceActivity, 0)
} lifecycleScope.launch {
binding = ActivityLinkDeviceBinding.inflate(layoutInflater) viewModel.eventFlow.collect {
setContentView(binding.root) startLoadingActivity(it.mnemonic)
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
}
// endregion
// 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."
} }
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 { ComposeView(this).apply {
return 2 setContent {
val state by viewModel.stateFlow.collectAsState()
AppTheme {
LoadAccountScreen(state, viewModel::onChange, viewModel::onRecoveryPhrase)
}
}
}.let(::setContentView)
} }
override fun getItem(index: Int): Fragment { @OptIn(ExperimentalFoundationApi::class)
return when (index) { @Composable
0 -> recoveryPhraseFragment fun LoadAccountScreen(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
1 -> { val tabs = listOf(R.string.activity_recovery_password, R.string.activity_link_device_scan_qr_code)
val result = ScanQRCodeWrapperFragment() val pagerState = rememberPagerState { tabs.size }
result.delegate = activity
result.message = activity.getString(R.string.activity_link_device_qr_message) Column {
result 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()
}
} }
else -> throw IllegalStateException()
} }
} }
}
override fun getPageTitle(index: Int): CharSequence { @Composable
return when (index) { fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
0 -> activity.getString(R.string.activity_link_device_recovery_phrase) Column(
1 -> activity.getString(R.string.activity_link_device_scan_qr_code) modifier = Modifier.padding(horizontal = 60.dp)
else -> throw IllegalStateException() ) {
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() }
} }
} }
// endregion
// region Recovery Phrase Fragment @Composable
class RecoveryPhraseFragment : Fragment() { fun ScanQrCode() {
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
}
}
continueButton.setOnClickListener { handleContinueButtonTapped() }
}
}
private fun handleContinueButtonTapped() {
val mnemonic = binding.mnemonicEditText.text?.trim().toString()
(requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic)
}
} }
// endregion
fun Context.startLinkDeviceActivity() { fun Context.startLinkDeviceActivity() {
Intent(this, LinkDeviceActivity::class.java).let(::startActivity) Intent(this, LinkDeviceActivity::class.java).let(::startActivity)
} }

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

@ -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)
}
}
Loading…
Cancel
Save