From ab62d8f333704de86faf6a95067260cab7fd88a7 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 29 Feb 2024 14:56:30 +1030 Subject: [PATCH] Add Recovery Phrase tab --- app/build.gradle | 1 + .../onboarding/LinkDeviceActivity.kt | 268 ++++++++++-------- .../securesms/onboarding/LinkDeviceState.kt | 6 + .../onboarding/LinkDeviceViewModel.kt | 77 +++++ 4 files changed, 226 insertions(+), 126 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index b291597a35..855d37df41 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 0e06bdb93a..b3ea96f65e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceState.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceState.kt new file mode 100644 index 0000000000..5627e31bbc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceState.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.onboarding + +data class LinkDeviceState( + val recoveryPhrase: String = "", + val error: String? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt new file mode 100644 index 0000000000..1bb8413b40 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceViewModel.kt @@ -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() + 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) + } +}