From ad57a68f2a00cf19ab8ef842fbc81e22f2c190c2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 8 Jul 2026 13:21:56 +0800 Subject: [PATCH 1/8] feat(auth): add login method selection --- .../main/java/one/mixin/android/Constants.kt | 1 + .../android/crypto/PendingMnemonicImport.kt | 21 ++ .../android/repository/Web3Repository.kt | 55 ++++ .../one/mixin/android/ui/home/MainActivity.kt | 73 ++++- .../ui/home/web3/trade/KeyboardAwareBox.kt | 3 +- .../android/ui/landing/LandingFragment.kt | 5 +- .../landing/LandingMnemonicPhraseFragment.kt | 110 +++++-- .../android/ui/landing/LoadingFragment.kt | 51 +++- .../android/ui/landing/LoginMethodFragment.kt | 114 ++++++++ .../android/ui/landing/MnemonicLoginHelper.kt | 12 + .../ui/landing/MnemonicPhraseFragment.kt | 32 +- .../android/ui/landing/MobileFragment.kt | 48 ++- .../landing/components/MnemonicPhraseInput.kt | 276 +++++++++++------- .../ui/tip/PendingMnemonicTipRouting.kt | 20 ++ .../mixin/android/ui/tip/TipFlowInteractor.kt | 83 +++++- .../ui/wallet/FetchingWalletFragment.kt | 17 +- .../ui/wallet/ImportingWalletFragment.kt | 13 +- .../VerifyPinBeforeImportWalletFragment.kt | 12 + .../ui/wallet/WalletSecurityActivity.kt | 2 + .../ui/wallet/components/FetchWalletPage.kt | 66 +++++ .../ui/wallet/components/WalletDestination.kt | 23 +- .../wallet/viewmodel/FetchWalletViewModel.kt | 43 ++- .../main/res/layout/fragment_login_method.xml | 234 +++++++++++++++ app/src/main/res/layout/fragment_mobile.xml | 43 +-- app/src/main/res/values-zh-rCN/strings.xml | 12 + app/src/main/res/values-zh-rTW/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + .../ui/landing/MnemonicLoginHelperTest.kt | 45 +++ .../ui/tip/PendingMnemonicTipRoutingTest.kt | 60 ++++ .../wallet/components/FetchWalletStateTest.kt | 16 + .../components/WalletDestinationTest.kt | 41 +++ 31 files changed, 1355 insertions(+), 200 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/crypto/PendingMnemonicImport.kt create mode 100644 app/src/main/java/one/mixin/android/ui/landing/LoginMethodFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/landing/MnemonicLoginHelper.kt create mode 100644 app/src/main/java/one/mixin/android/ui/tip/PendingMnemonicTipRouting.kt create mode 100644 app/src/main/res/layout/fragment_login_method.xml create mode 100644 app/src/test/java/one/mixin/android/ui/landing/MnemonicLoginHelperTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/tip/PendingMnemonicTipRoutingTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/wallet/components/FetchWalletStateTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/wallet/components/WalletDestinationTest.kt diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 20cca84677..812d22331d 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -53,6 +53,7 @@ object Constants { const val ALIAS_TIP_PRIV = "alias_tip_priv" const val MNEMONIC = "mnemonic" + const val PENDING_IMPORT_MNEMONIC = "pending_import_mnemonic" const val SPEND_SALT = "spend_salt" const val ALIAS_SPEND_SALT = "alias_spend_salt" diff --git a/app/src/main/java/one/mixin/android/crypto/PendingMnemonicImport.kt b/app/src/main/java/one/mixin/android/crypto/PendingMnemonicImport.kt new file mode 100644 index 0000000000..a8e899b137 --- /dev/null +++ b/app/src/main/java/one/mixin/android/crypto/PendingMnemonicImport.kt @@ -0,0 +1,21 @@ +package one.mixin.android.crypto + +import android.content.Context +import one.mixin.android.Constants + +fun savePendingImportMnemonic(context: Context, words: List) { + storeValueInEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC, toEntropy(words)) +} + +fun getPendingImportMnemonic(context: Context): String? { + val entropy = getValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC) ?: return null + return runCatching { toMnemonic(entropy) }.getOrNull() +} + +fun hasPendingImportMnemonic(context: Context): Boolean { + return getValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC) != null +} + +fun clearPendingImportMnemonic(context: Context) { + removeValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC) +} diff --git a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt index d5d3d87e9f..ecafe486f9 100644 --- a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt +++ b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt @@ -44,9 +44,12 @@ import one.mixin.android.db.web3.vo.Web3TransactionItem import one.mixin.android.db.web3.vo.Web3Wallet import one.mixin.android.db.web3.vo.WalletItem import one.mixin.android.db.web3.vo.isWatch +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.hexStringToByteArray import one.mixin.android.extension.nowInUtc +import one.mixin.android.extension.putBoolean import one.mixin.android.ui.wallet.Web3FilterParams +import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.vo.WalletCategory import one.mixin.android.vo.route.Order import one.mixin.android.vo.safe.toWeb3TokenItem @@ -302,6 +305,58 @@ constructor( suspend fun updateWallet(walletId: String, request: WalletRequest) = routeService.updateWallet(walletId, request) + suspend fun syncWalletsFromRoute(): List? { + return requestRouteAPI( + invokeNetwork = { routeService.getWallets() }, + successBlock = { response -> + val wallets = response.data ?: emptyList() + if (wallets.isEmpty()) { + return@requestRouteAPI wallets + } + web3WalletDao.insertListSuspend(wallets) + wallets.forEach { wallet -> + val embeddedAddresses = wallet.addresses + if (embeddedAddresses.isNullOrEmpty()) { + syncWalletAddressesFromRoute(wallet.id) + } else { + web3AddressDao.insertListSuspend(embeddedAddresses) + context.defaultSharedPreferences.putBoolean(Constants.Account.PREF_WEB3_ADDRESSES_SYNCED, true) + } + } + wallets + }, + failureBlock = { response -> + Timber.e("Failed to sync wallets from route ${response.errorCode} - ${response.errorDescription}") + false + }, + requestSession = { ids -> + userRepository.fetchSessionsSuspend(ids) + }, + defaultErrorHandle = {}, + ) + } + + private suspend fun syncWalletAddressesFromRoute(walletId: String) { + requestRouteAPI( + invokeNetwork = { routeService.getWalletAddresses(walletId) }, + successBlock = { response -> + val addresses = response.data ?: emptyList() + if (addresses.isNotEmpty()) { + web3AddressDao.insertListSuspend(addresses) + context.defaultSharedPreferences.putBoolean(Constants.Account.PREF_WEB3_ADDRESSES_SYNCED, true) + } + }, + failureBlock = { response -> + Timber.e("Failed to sync wallet addresses from route ${response.errorCode} - ${response.errorDescription}") + false + }, + requestSession = { ids -> + userRepository.fetchSessionsSuspend(ids) + }, + defaultErrorHandle = {}, + ) + } + suspend fun insertWallet(wallet: Web3Wallet) = web3WalletDao.insertSuspend(wallet) suspend fun updateWalletName(walletId: String, newName: String) = web3WalletDao.updateWalletName(walletId, newName) diff --git a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index fec197f6ed..c6e68603d6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt @@ -70,6 +70,8 @@ import one.mixin.android.api.service.ConversationService import one.mixin.android.api.service.UserService import one.mixin.android.crypto.PrivacyPreference.getIsLoaded import one.mixin.android.crypto.PrivacyPreference.getIsSyncSession +import one.mixin.android.crypto.clearPendingImportMnemonic +import one.mixin.android.crypto.hasPendingImportMnemonic import one.mixin.android.databinding.ActivityMainBinding import one.mixin.android.db.ConversationDao import one.mixin.android.db.MixinDatabase @@ -159,6 +161,8 @@ import one.mixin.android.ui.tip.TipActivity import one.mixin.android.ui.tip.TipBundle import one.mixin.android.ui.tip.TipType import one.mixin.android.ui.tip.TryConnecting +import one.mixin.android.ui.tip.PendingMnemonicRoute +import one.mixin.android.ui.tip.routePendingMnemonicAfterWalletFetch import one.mixin.android.ui.tip.wc.WalletConnectActivity import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment.Companion.ASSET_PREFERENCE @@ -167,11 +171,14 @@ import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.WalletActivity.Companion.BUY import one.mixin.android.ui.wallet.WalletFragment import one.mixin.android.ui.wallet.WalletMissingBtcAddressFragment +import one.mixin.android.ui.wallet.WalletSecurityActivity import one.mixin.android.ui.wallet.components.WalletDestination +import one.mixin.android.ui.wallet.components.walletDestinationFromJson +import one.mixin.android.ui.wallet.components.walletDestinationForWallet +import one.mixin.android.ui.wallet.components.walletDestinationToJson import one.mixin.android.util.BiometricUtil import one.mixin.android.util.ErrorHandler import one.mixin.android.util.ErrorHandler.Companion.SERVER -import one.mixin.android.util.GsonHelper import one.mixin.android.util.RomUtil import one.mixin.android.util.RootUtil import one.mixin.android.util.analytics.AnalyticsTracker @@ -398,6 +405,10 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba lifecycleScope.launch { if (Session.hasSafe()) { jobManager.addJobInBackground(RefreshAccountJob(checkTip = true)) + if (hasPendingImportMnemonic(this@MainActivity)) { + openPendingMnemonicNext() + return@launch + } val isLoginVerified: Boolean = defaultSharedPreferences.getBoolean(PREF_LOGIN_VERIFY, false) val shouldGoWallet: Boolean = defaultSharedPreferences.getBoolean(PREF_LOGIN_OR_SIGN_UP, false) val shouldBlockNavigation: Boolean = shouldShowWalletMissingBtcAddress() @@ -413,10 +424,7 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba }.showNow(supportFragmentManager, LoginVerifyBottomSheetDialogFragment.TAG) } if (shouldGoWallet && !shouldBlockNavigation) { - binding.bottomNav.selectedItemId = R.id.nav_wallet - switchToDestination(NavigationController.Wallet) - lastBottomNavItemId = R.id.nav_wallet - defaultSharedPreferences.putBoolean(PREF_LOGIN_OR_SIGN_UP, false) + openWalletTabFromLogin() } } else { CheckRegisterBottomSheetDialogFragment.newInstance() @@ -450,6 +458,36 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } } + private suspend fun openPendingMnemonicNext() { + val wallets = web3Repository.syncWalletsFromRoute() + when (routePendingMnemonicAfterWalletFetch(wallets?.map { it.category })) { + PendingMnemonicRoute.WalletHome -> { + clearPendingImportMnemonic(this) + val walletDestination = wallets?.firstOrNull { it.category == WalletCategory.CLASSIC.value }?.let { wallet -> + walletDestinationForWallet(wallet.id, wallet.category) + } + openWalletTabFromLogin(walletDestination) + } + PendingMnemonicRoute.ImportMnemonic -> { + WalletSecurityActivity.show(this, WalletSecurityActivity.Mode.LOGIN_IMPORT_MNEMONIC) + } + PendingMnemonicRoute.WalletFetchFailed -> { + } + } + } + + private fun openWalletTabFromLogin(walletDestination: WalletDestination? = null) { + walletDestination?.let { destination -> + val json = walletDestinationToJson(destination) + defaultSharedPreferences.putString(Account.PREF_USED_WALLET, json) + intent.putExtra(WALLET_DESTINATION, json) + } + binding.bottomNav.selectedItemId = R.id.nav_wallet + switchToDestination(NavigationController.Wallet) + lastBottomNavItemId = R.id.nav_wallet + defaultSharedPreferences.putBoolean(PREF_LOGIN_OR_SIGN_UP, false) + } + override fun onStart() { super.onStart() val notificationManager = getSystemService() ?: return @@ -844,11 +882,17 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba bottomSheet?.showNow(supportFragmentManager, LinkBottomSheetDialogFragment.TAG) clearCodeAfterConsume(intent, URL) } else if (intent.hasExtra(WALLET)) { + intent.getStringExtra(WALLET_DESTINATION)?.let { json -> + if (walletDestinationFromJson(json) != null) { + defaultSharedPreferences.putString(Account.PREF_USED_WALLET, json) + } + } binding.bottomNav.selectedItemId = R.id.nav_wallet if (intent.getBooleanExtra(BUY, false)) { WalletActivity.showBuy(this, false, null, null) clearCodeAfterConsume(intent, BUY) } + clearCodeAfterConsume(intent, WALLET_DESTINATION) clearCodeAfterConsume(intent, WALLET) } else if (intent.hasExtra(TRANSFER)) { val userId = intent.getStringExtra(TRANSFER) ?: return @@ -1108,17 +1152,15 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } private fun loadInitialWalletDestination(): WalletDestination { + intent.getStringExtra(WALLET_DESTINATION)?.let { json -> + walletDestinationFromJson(json)?.let { return it } + } + val walletPref = defaultSharedPreferences.getString( Account.PREF_USED_WALLET, null ) - return walletPref?.let { pref -> - try { - GsonHelper.customGson.fromJson(pref, WalletDestination::class.java) - } catch (_: Exception) { - WalletDestination.Privacy - } - } ?: WalletDestination.Privacy + return walletPref?.let(::walletDestinationFromJson) ?: WalletDestination.Privacy } private fun handleNavigationItemSelected(itemId: Int) { @@ -1354,16 +1396,23 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba const val SCAN = "scan" const val TRANSFER = "transfer" private const val WALLET = "wallet" + private const val WALLET_DESTINATION = "wallet_destination" const val WALLET_CONNECT = "wallet_connect" private const val KEY_LAST_BOTTOM_NAV = "last_bottom_nav_item_id" fun showWallet( context: Context, buy: Boolean = false, + walletDestination: WalletDestination? = null, ) { Intent(context, MainActivity::class.java).apply { putExtra(WALLET, true) putExtra(BUY, buy) + walletDestination?.let { + val json = walletDestinationToJson(it) + context.defaultSharedPreferences.putString(Account.PREF_USED_WALLET, json) + putExtra(WALLET_DESTINATION, json) + } addCategory(Intent.CATEGORY_LAUNCHER) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) }.run { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/KeyboardAwareBox.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/KeyboardAwareBox.kt index fe28f68c8d..c66779c0c5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/KeyboardAwareBox.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/KeyboardAwareBox.kt @@ -28,6 +28,7 @@ import timber.log.Timber @Composable fun KeyboardAwareBox( modifier: Modifier = Modifier, + showFloating: Boolean = true, content: @Composable BoxScope.(Dp?) -> Unit, floating: @Composable BoxScope.() -> Unit, ) { @@ -62,7 +63,7 @@ fun KeyboardAwareBox( Box(modifier = modifier) { content(availableHeight) - if (isKeyboardVisible) { + if (isKeyboardVisible && showFloating) { Timber.e("imeBottom: $imeBottom, systemBarsBottom: $systemBarsBottom") Surface( diff --git a/app/src/main/java/one/mixin/android/ui/landing/LandingFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/LandingFragment.kt index 7e6da4aaa8..1349553309 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/LandingFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/LandingFragment.kt @@ -20,7 +20,6 @@ import one.mixin.android.extension.addFragment import one.mixin.android.extension.colorFromAttribute import one.mixin.android.extension.navTo import one.mixin.android.extension.openUrl -import one.mixin.android.ui.landing.MobileFragment.Companion.FROM_LANDING import one.mixin.android.ui.setting.diagnosis.DiagnosisFragment import one.mixin.android.util.SystemUIManager import one.mixin.android.util.analytics.AnalyticsTracker @@ -190,8 +189,8 @@ class LandingFragment : Fragment(R.layout.fragment_landing) { binding.continueTv.setOnClickListener { activity?.addFragment( this@LandingFragment, - MobileFragment.newInstance(from = FROM_LANDING), - MobileFragment.TAG, + LoginMethodFragment.newInstance(), + LoginMethodFragment.TAG, ) } } diff --git a/app/src/main/java/one/mixin/android/ui/landing/LandingMnemonicPhraseFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/LandingMnemonicPhraseFragment.kt index 98cd94920f..4feec8d2d8 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/LandingMnemonicPhraseFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/LandingMnemonicPhraseFragment.kt @@ -1,39 +1,76 @@ package one.mixin.android.ui.landing +import android.content.Context import android.os.Bundle import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.R +import one.mixin.android.crypto.isMnemonicValid import one.mixin.android.crypto.mnemonicChecksum +import one.mixin.android.crypto.toMnemonicWithChecksum import one.mixin.android.databinding.FragmentComposeBinding -import one.mixin.android.extension.addFragment import one.mixin.android.extension.navTo import one.mixin.android.extension.openCustomerService -import one.mixin.android.extension.openUrl -import one.mixin.android.extension.toast import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.landing.components.MnemonicPhraseInput import one.mixin.android.ui.landing.components.MnemonicState import one.mixin.android.ui.logs.LogViewerBottomSheet +import one.mixin.android.ui.qr.CaptureActivity import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.viewBinding import timber.log.Timber +enum class LoginMnemonicMode( + val shortWordCount: Int, + val legacyWordCount: Int, +) { + TWELVE_OR_TWENTY_FOUR(12, 24), + THIRTEEN_OR_TWENTY_FIVE(13, 25), +} + @AndroidEntryPoint class LandingMnemonicPhraseFragment : BaseFragment(R.layout.fragment_landing_mnemonic_phrase) { companion object { const val TAG: String = "MnemonicPhraseFragment" + private const val ARGS_MODE = "args_mode" fun newInstance( + mode: LoginMnemonicMode = LoginMnemonicMode.THIRTEEN_OR_TWENTY_FIVE, ): LandingMnemonicPhraseFragment = LandingMnemonicPhraseFragment().apply { - + arguments = Bundle().apply { + putString(ARGS_MODE, mode.name) + } } } private val binding by viewBinding(FragmentComposeBinding::bind) + private val mode: LoginMnemonicMode by lazy { + arguments?.getString(ARGS_MODE)?.let(LoginMnemonicMode::valueOf) + ?: LoginMnemonicMode.THIRTEEN_OR_TWENTY_FIVE + } + private var scannedMnemonicList by mutableStateOf>(emptyList()) + private lateinit var getScanResult: ActivityResultLauncher> + + override fun onAttach(context: Context) { + super.onAttach(context) + getScanResult = registerForActivityResult( + CaptureActivity.CaptureContract() + ) { intent -> + intent?.getStringExtra(CaptureActivity.ARGS_FOR_SCAN_RESULT) + ?.trim() + ?.split(Regex("\\s+")) + ?.filter { it.isNotBlank() } + ?.takeIf { it.isNotEmpty() } + ?.let { scannedMnemonicList = it } + } + } override fun onViewCreated( view: View, @@ -45,6 +82,7 @@ class LandingMnemonicPhraseFragment : BaseFragment(R.layout.fragment_landing_mne } Timber.e("LandingMnemonicPhraseFragment onViewCreated") AnalyticsTracker.trackLoginMnemonicPhrase() + binding.titleView.titleTv.setTextOnly(R.string.Log_in) binding.titleView.leftIb.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } @@ -59,40 +97,52 @@ class LandingMnemonicPhraseFragment : BaseFragment(R.layout.fragment_landing_mne true } binding.compose.setContent { - MnemonicPhraseInput(MnemonicState.Input, onComplete = { - val list = ArrayList() - list.addAll(it) - if (mnemonicChecksum(list)) { - navTo(MnemonicPhraseFragment.newInstance(list), MnemonicPhraseFragment.TAG) - } else { - toast(R.string.invalid_mnemonic_phrase) - } - }, onCreate = { - AnalyticsTracker.trackSignUpStart(AnalyticsTracker.SignUpStartSource.LOGIN_MNEMONIC_PHRASE) - CreateAccountConfirmBottomSheetDialogFragment.newInstance() - .setOnCreateAccount { - activity?.addFragment( - this@LandingMnemonicPhraseFragment, - MnemonicPhraseFragment.newInstance(), - MnemonicPhraseFragment.TAG, - ) + MnemonicPhraseInput( + state = MnemonicState.Input, + mnemonicList = scannedMnemonicList, + inputWordCounts = mode.shortWordCount to mode.legacyWordCount, + showOtherWords = false, + compactInput = true, + onComplete = { words -> + val completedWords = completeMnemonicForLogin(words) { sourceWords -> + toMnemonicWithChecksum(sourceWords) } - .setOnPrivacyPolicy { - activity?.openUrl(getString(R.string.landing_privacy_policy_url)) - } - .setOnTermsOfService { - activity?.openUrl(getString(R.string.landing_terms_url)) - } - .showNow(parentFragmentManager, CreateAccountConfirmBottomSheetDialogFragment.TAG) - } + navTo( + MnemonicPhraseFragment.newInstance( + ArrayList(completedWords), + if (mode == LoginMnemonicMode.TWELVE_OR_TWENTY_FOUR) ArrayList(words) else null, + ), + MnemonicPhraseFragment.TAG, + ) + }, + onScan = { + getScanResult.launch(Pair(CaptureActivity.ARGS_FOR_SCAN_RESULT, true)) + }, + validate = ::validateLoginMnemonic, ) } } + private fun validateLoginMnemonic(words: List): String? { + if (words.size != mode.shortWordCount && words.size != mode.legacyWordCount) { + return getString(R.string.invalid_mnemonic_phrase) + } + val valid = when (words.size) { + 12, 24 -> runCatching { isMnemonicValid(words) }.getOrDefault(false) + 13 -> mnemonicChecksum(words) && runCatching { isMnemonicValid(words.subList(0, 12)) }.getOrDefault(false) + 25 -> mnemonicChecksum(words) && runCatching { isMnemonicValid(words.subList(0, 24)) }.getOrDefault(false) + else -> false + } + return if (valid) null else getString(R.string.invalid_mnemonic_phrase) + } + private fun applySafeTopPadding(rootView: View) { val originalPaddingTop: Int = rootView.paddingTop ViewCompat.setOnApplyWindowInsetsListener(rootView) { v: View, insets: WindowInsetsCompat -> - val topInset: Int = insets.getInsets(WindowInsetsCompat.Type.displayCutout()).top + val topInset: Int = maxOf( + insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, + insets.getInsets(WindowInsetsCompat.Type.displayCutout()).top, + ) v.setPadding(v.paddingLeft, originalPaddingTop + topInset, v.paddingRight, v.paddingBottom) insets } diff --git a/app/src/main/java/one/mixin/android/ui/landing/LoadingFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/LoadingFragment.kt index 47972ffcc5..b9ded77126 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/LoadingFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/LoadingFragment.kt @@ -24,16 +24,20 @@ import one.mixin.android.crypto.PrivacyPreference.getIsLoaded import one.mixin.android.crypto.PrivacyPreference.getIsSyncSession import one.mixin.android.crypto.PrivacyPreference.putIsLoaded import one.mixin.android.crypto.PrivacyPreference.putIsSyncSession +import one.mixin.android.crypto.clearPendingImportMnemonic import one.mixin.android.crypto.generateEd25519KeyPair +import one.mixin.android.crypto.hasPendingImportMnemonic import one.mixin.android.databinding.FragmentLoadingBinding import one.mixin.android.extension.base64Encode import one.mixin.android.extension.decodeBase64 import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getStringDeviceId import one.mixin.android.extension.putBoolean +import one.mixin.android.extension.putString import one.mixin.android.extension.viewDestroyed import one.mixin.android.job.InitializeJob import one.mixin.android.job.MixinJobManager +import one.mixin.android.repository.Web3Repository import one.mixin.android.session.Session import one.mixin.android.session.decryptPinToken import one.mixin.android.ui.common.BaseFragment @@ -41,12 +45,17 @@ import one.mixin.android.ui.home.MainActivity import one.mixin.android.ui.logs.LogViewerBottomSheet import one.mixin.android.ui.tip.TipActivity import one.mixin.android.ui.tip.TipType +import one.mixin.android.ui.tip.PendingMnemonicRoute +import one.mixin.android.ui.tip.routePendingMnemonicAfterWalletFetch +import one.mixin.android.ui.wallet.WalletSecurityActivity +import one.mixin.android.ui.wallet.components.walletDestinationForWallet import one.mixin.android.util.ErrorHandler import one.mixin.android.util.ErrorHandler.Companion.FORBIDDEN import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.isSimplifiedChineseLocale import one.mixin.android.util.reportException import one.mixin.android.util.viewBinding +import one.mixin.android.vo.WalletCategory import timber.log.Timber import javax.inject.Inject @@ -66,6 +75,9 @@ class LoadingFragment : BaseFragment(R.layout.fragment_loading) { @Inject lateinit var jobManager: MixinJobManager + @Inject + lateinit var web3Repository: Web3Repository + private val loadingViewModel by viewModels() private val binding by viewBinding(FragmentLoadingBinding::bind) @@ -120,12 +132,16 @@ class LoadingFragment : BaseFragment(R.layout.fragment_loading) { defaultSharedPreferences.putBoolean(PREF_LOGIN_OR_SIGN_UP, true) defaultSharedPreferences.putBoolean(PREF_LOGIN_VERIFY, true) defaultSharedPreferences.putBoolean(PREF_LOGIN_OR_SIGN_UP, true) - MainActivity.show(requireContext()) - } else { - var deviceId = defaultSharedPreferences.getString(DEVICE_ID, null) - if (deviceId == null) { - deviceId = requireActivity().getStringDeviceId() + when { + hasPendingImportMnemonic(requireContext()) -> { + openPendingMnemonicNext() + } + else -> { + MainActivity.show(requireContext()) + } } + } else { + ensureDeviceId() val tipType = if (Session.getAccount()?.hasPin == true) TipType.Upgrade else TipType.Create if (TipType.Create == tipType) { InitializeActivity.showSetupPin(requireActivity()) @@ -137,6 +153,31 @@ class LoadingFragment : BaseFragment(R.layout.fragment_loading) { activity?.finish() } + private suspend fun openPendingMnemonicNext() { + val wallets = web3Repository.syncWalletsFromRoute() + when (routePendingMnemonicAfterWalletFetch(wallets?.map { it.category })) { + PendingMnemonicRoute.WalletHome -> { + clearPendingImportMnemonic(requireContext()) + val walletDestination = wallets?.firstOrNull { it.category == WalletCategory.CLASSIC.value }?.let { wallet -> + walletDestinationForWallet(wallet.id, wallet.category) + } + MainActivity.showWallet(requireContext(), walletDestination = walletDestination) + } + PendingMnemonicRoute.ImportMnemonic -> { + WalletSecurityActivity.show(requireActivity(), WalletSecurityActivity.Mode.LOGIN_IMPORT_MNEMONIC) + } + PendingMnemonicRoute.WalletFetchFailed -> { + MainActivity.show(requireContext()) + } + } + } + + private fun ensureDeviceId() { + if (defaultSharedPreferences.getString(DEVICE_ID, null) == null) { + defaultSharedPreferences.putString(DEVICE_ID, requireActivity().getStringDeviceId()) + } + } + private fun initializeBots() { val phone = Session.getAccount()?.phone.orEmpty() val testAccountPrefix = BuildConfig.TEST_ACCOUNT_PREFIX diff --git a/app/src/main/java/one/mixin/android/ui/landing/LoginMethodFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/LoginMethodFragment.kt new file mode 100644 index 0000000000..82c3ef25d1 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/landing/LoginMethodFragment.kt @@ -0,0 +1,114 @@ +package one.mixin.android.ui.landing + +import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.databinding.FragmentLoginMethodBinding +import one.mixin.android.extension.addFragment +import one.mixin.android.extension.highlightStarTag +import one.mixin.android.extension.openCustomerService +import one.mixin.android.extension.openUrl +import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.landing.MobileFragment.Companion.FROM_LANDING +import one.mixin.android.ui.logs.LogViewerBottomSheet +import one.mixin.android.util.analytics.AnalyticsTracker +import one.mixin.android.util.viewBinding + +@AndroidEntryPoint +class LoginMethodFragment : BaseFragment(R.layout.fragment_login_method) { + companion object { + const val TAG: String = "LoginMethodFragment" + + fun newInstance() = LoginMethodFragment() + } + + private val binding by viewBinding(FragmentLoginMethodBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + if (activity is LandingActivity) { + applySafeTopPadding(view) + } + binding.titleView.rightIb.setImageResource(R.drawable.ic_support) + binding.titleView.rightAnimator.visibility = View.VISIBLE + binding.titleView.rightAnimator.displayedChild = 0 + binding.titleView.leftIb.setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + binding.titleView.rightIb.setOnClickListener { + openCustomerService(source = AnalyticsTracker.CustomerServiceSource.LOGIN_PHONE_NUMER) + } + binding.titleView.setOnLongClickListener { + LogViewerBottomSheet.newInstance().showNow(parentFragmentManager, LogViewerBottomSheet.TAG) + true + } + binding.otherWalletsRow.setOnClickListener { + activity?.addFragment( + this, + LandingMnemonicPhraseFragment.newInstance(LoginMnemonicMode.TWELVE_OR_TWENTY_FOUR), + LandingMnemonicPhraseFragment.TAG, + ) + } + binding.mobileRow.setOnClickListener { + activity?.addFragment( + this, + MobileFragment.newInstance(from = FROM_LANDING), + MobileFragment.TAG, + ) + } + binding.recoveryKitRow.setOnClickListener { + activity?.addFragment( + this, + LandingMnemonicPhraseFragment.newInstance(LoginMnemonicMode.THIRTEEN_OR_TWENTY_FIVE), + LandingMnemonicPhraseFragment.TAG, + ) + } + binding.noAccount.setOnClickListener { + AnalyticsTracker.trackSignUpStart(AnalyticsTracker.SignUpStartSource.LOGIN_START) + CreateAccountConfirmBottomSheetDialogFragment.newInstance() + .setOnCreateAccount { + activity?.addFragment( + this, + MnemonicPhraseFragment.newInstance(), + MnemonicPhraseFragment.TAG, + ) + } + .setOnPrivacyPolicy { + activity?.openUrl(getString(R.string.landing_privacy_policy_url)) + } + .setOnTermsOfService { + activity?.openUrl(getString(R.string.landing_terms_url)) + } + .showNow(parentFragmentManager, CreateAccountConfirmBottomSheetDialogFragment.TAG) + } + + val policy: String = requireContext().getString(R.string.Privacy_Policy) + val termsService: String = requireContext().getString(R.string.Terms_of_Service) + val policyWrapper = requireContext().getString(R.string.landing_introduction, "**$policy**", "**$termsService**") + val policyUrl = getString(R.string.landing_privacy_policy_url) + val termsUrl = getString(R.string.landing_terms_url) + binding.introductionTv.highlightStarTag( + policyWrapper, + arrayOf(policyUrl, termsUrl), + ) + } + + private fun applySafeTopPadding(rootView: View) { + val originalPaddingTop: Int = rootView.paddingTop + ViewCompat.setOnApplyWindowInsetsListener(rootView) { v: View, insets: WindowInsetsCompat -> + val topInset: Int = maxOf( + insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, + insets.getInsets(WindowInsetsCompat.Type.displayCutout()).top, + ) + v.setPadding(v.paddingLeft, originalPaddingTop + topInset, v.paddingRight, v.paddingBottom) + insets + } + ViewCompat.requestApplyInsets(rootView) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/landing/MnemonicLoginHelper.kt b/app/src/main/java/one/mixin/android/ui/landing/MnemonicLoginHelper.kt new file mode 100644 index 0000000000..4a73497b56 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/landing/MnemonicLoginHelper.kt @@ -0,0 +1,12 @@ +package one.mixin.android.ui.landing + +fun completeMnemonicForLogin( + words: List, + checksum: (List) -> List, +): List { + return when (words.size) { + 12, 24 -> checksum(words) + 13, 25 -> words + else -> throw IllegalArgumentException("Unsupported mnemonic word count: ${words.size}") + } +} diff --git a/app/src/main/java/one/mixin/android/ui/landing/MnemonicPhraseFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/MnemonicPhraseFragment.kt index 5ff925a49d..cf4bde11ac 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/MnemonicPhraseFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/MnemonicPhraseFragment.kt @@ -25,13 +25,16 @@ import one.mixin.android.api.request.doAnonymousPOW import one.mixin.android.crypto.CryptoPreference import one.mixin.android.crypto.EdKeyPair import one.mixin.android.crypto.SignalProtocol +import one.mixin.android.crypto.clearPendingImportMnemonic import one.mixin.android.crypto.generateEd25519KeyPair import one.mixin.android.crypto.getValueFromEncryptedPreferences import one.mixin.android.crypto.initFromSeedAndSign import one.mixin.android.crypto.newKeyPairFromMnemonic +import one.mixin.android.crypto.savePendingImportMnemonic import one.mixin.android.crypto.storeValueInEncryptedPreferences import one.mixin.android.crypto.toEntropy import one.mixin.android.crypto.toMnemonic +import one.mixin.android.crypto.toMnemonicWithChecksum import one.mixin.android.databinding.FragmentComposeBinding import one.mixin.android.extension.base64Encode import one.mixin.android.extension.containsIgnoreCase @@ -66,13 +69,16 @@ class MnemonicPhraseFragment : BaseFragment(R.layout.fragment_compose) { companion object { const val TAG: String = "MnemonicPhraseFragment" const val ARGS_MNEMONIC_PHRASE = "mnemonic_phrase" + private const val ARGS_PENDING_IMPORT_MNEMONIC = "pending_import_mnemonic" fun newInstance( - words: ArrayList? = null + words: ArrayList? = null, + pendingImportWords: ArrayList? = null, ): MnemonicPhraseFragment = MnemonicPhraseFragment().apply { withArgs { putStringArrayList(ARGS_MNEMONIC_PHRASE, words) + putStringArrayList(ARGS_PENDING_IMPORT_MNEMONIC, pendingImportWords) } } } @@ -90,6 +96,9 @@ class MnemonicPhraseFragment : BaseFragment(R.layout.fragment_compose) { private val words by lazy { requireArguments().getStringArrayList(ARGS_MNEMONIC_PHRASE) } + private val pendingImportWords by lazy { + requireArguments().getStringArrayList(ARGS_PENDING_IMPORT_MNEMONIC) + } override fun onViewCreated( view: View, @@ -131,14 +140,21 @@ class MnemonicPhraseFragment : BaseFragment(R.layout.fragment_compose) { landingViewModel.updateMnemonicPhraseState(MnemonicPhraseState.Creating) val sessionKey = generateEd25519KeyPair() val edKey = if (!words.isNullOrEmpty()) { - val w = words.let { - when (words.size) { + val completedWords = runCatching { + completeMnemonicForLogin(words) { sourceWords -> + toMnemonicWithChecksum(sourceWords) + } + }.onFailure { + errorInfo = getString(R.string.invalid_mnemonic_phrase) + }.getOrNull() ?: return@launch + val w = completedWords.let { + when (completedWords.size) { 13 -> { - words.subList(0, 12) + completedWords.subList(0, 12) } 25 -> { - words.subList(0, 24) + completedWords.subList(0, 24) } else -> { @@ -365,6 +381,12 @@ class MnemonicPhraseFragment : BaseFragment(R.layout.fragment_compose) { if (r?.isSuccess == true) { val account = r.data!! initializeAccountSession(requireContext(), account, sessionKey) + val importWords = pendingImportWords + if (!importWords.isNullOrEmpty()) { + savePendingImportMnemonic(requireContext(), importWords) + } else { + clearPendingImportMnemonic(requireContext()) + } when { account.fullName.isNullOrBlank() -> { withContext(Dispatchers.IO) { diff --git a/app/src/main/java/one/mixin/android/ui/landing/MobileFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/MobileFragment.kt index 6e8a513af3..a1e7fc645d 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/MobileFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/MobileFragment.kt @@ -9,6 +9,7 @@ import android.text.TextWatcher import android.view.View import android.view.View.AUTOFILL_HINT_PHONE import android.view.WindowManager +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -36,6 +37,7 @@ import one.mixin.android.extension.highlightStarTag import one.mixin.android.extension.inTransaction import one.mixin.android.extension.openCustomerService import one.mixin.android.extension.openUrl +import one.mixin.android.extension.showKeyboard import one.mixin.android.extension.tickVibrate import one.mixin.android.extension.viewDestroyed import one.mixin.android.session.Session @@ -121,6 +123,8 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { private val addPhoneSource: String? by lazy { requireArguments().getString(ARGS_ADD_PHONE_SOURCE) } + private val useSystemKeyboard: Boolean + get() = from == FROM_LANDING private var captchaView: CaptchaView? = null @@ -181,13 +185,14 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { policyWrapper, arrayOf(policyUrl, termsUrl), ) - binding.orLl.isVisible = from == FROM_LANDING - binding.mnemonicPhrase.isVisible = from == FROM_LANDING - binding.noAccount.isVisible = from == FROM_LANDING + binding.introductionTv.isVisible = from != FROM_LANDING + binding.orLl.isVisible = false + binding.mnemonicPhrase.isVisible = false + binding.noAccount.isVisible = false countryIconIv.setOnClickListener { showCountry() } countryCodeEt.addTextChangedListener(countryCodeWatcher) - countryCodeEt.showSoftInputOnFocus = false + countryCodeEt.showSoftInputOnFocus = useSystemKeyboard continueBn.setOnClickListener { if (from == FROM_VERIFY_MOBILE_REMINDER && presetPhoneNumber != null @@ -197,11 +202,18 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { showDialog() } } - mobileEt.showSoftInputOnFocus = false + mobileEt.showSoftInputOnFocus = useSystemKeyboard mobileEt.addTextChangedListener(mWatcher) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { mobileEt.setAutofillHints(AUTOFILL_HINT_PHONE) } + if (useSystemKeyboard) { + (continueBn.layoutParams as ConstraintLayout.LayoutParams).apply { + topToBottom = R.id.mobile_input_bg + topMargin = 44.dp + } + continueBn.requestLayout() + } mobileCover.isClickable = true countryPicker = CountryPicker.newInstance() countryPicker.setListener { _: String, code: String, dialCode: String, flagResId: Int -> @@ -230,6 +242,7 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { keyboard.tipTitleEnabled = false keyboard.initPinKeys() keyboard.setOnClickKeyboardListener(mKeyboardListener) + keyboard.isVisible = !useSystemKeyboard mnemonicPhrase.setOnClickListener { activity?.addFragment( this@MobileFragment, @@ -308,7 +321,7 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { } override fun onBackPressed(): Boolean { - if (binding.keyboard.translationY == 0f) { + if (!useSystemKeyboard && binding.keyboard.translationY == 0f) { binding.mobileEt.clearFocus() binding.countryCodeEt.clearFocus() return true @@ -343,10 +356,19 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { override fun onResume() { super.onResume() - requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) - requireActivity().currentFocus?.clearFocus() - requireActivity().hideKeyboard() - binding.mobileEt.hideKeyboard() + if (useSystemKeyboard) { + requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + binding.mobileEt.post { + if (!viewDestroyed()) { + binding.mobileEt.showKeyboard() + } + } + } else { + requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) + requireActivity().currentFocus?.clearFocus() + requireActivity().hideKeyboard() + binding.mobileEt.hideKeyboard() + } } private fun requestSend(captchaResponse: Pair? = null) { @@ -787,6 +809,7 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { } private fun handleFocusChange(hasFocus: Boolean) { + if (useSystemKeyboard) return if (hasFocus) { binding.orLl.isVisible = false binding.mnemonicPhrase.isVisible = false @@ -801,6 +824,11 @@ class MobileFragment: BaseFragment(R.layout.fragment_mobile) { } private fun handleTextChange(s: Editable?) { + if (useSystemKeyboard) { + binding.orLl.isVisible = false + binding.mnemonicPhrase.isVisible = false + return + } if (s.isNullOrEmpty() && !binding.mobileEt.hasFocus() && !binding.countryCodeEt.hasFocus()) { binding.orLl.isVisible = from == FROM_LANDING binding.mnemonicPhrase.isVisible = from == FROM_LANDING diff --git a/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt b/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt index 4e84b9bfd7..a188238946 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt @@ -73,6 +73,7 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.crypto.getMatchingWords import one.mixin.android.crypto.initFromSeedAndSign import one.mixin.android.crypto.isMnemonicValid +import one.mixin.android.crypto.mnemonicChecksum import one.mixin.android.extension.copySensitiveTextToClipboard import one.mixin.android.extension.getClipboardManager import one.mixin.android.extension.openUrl @@ -96,32 +97,35 @@ fun MnemonicPhraseInput( onQrCode: ((List) -> Unit)? = null, title: @Composable (() -> Unit)? = null, onScan: (() -> Unit)? = null, + inputWordCounts: Pair = 13 to 25, + showOtherWords: Boolean = true, + compactInput: Boolean = false, validate: ((List) -> String?)? = null, onCreate: (() -> Unit)? = null, saltExport: (suspend (ExportRequest) -> MixinResponse)? = null, getEncryptedTipBody: (suspend (String, String) -> String)? = null, ) { - var legacy by remember { mutableStateOf(mnemonicList.size > 13) } + val shortWordCount = if (state == MnemonicState.Import) 12 else inputWordCounts.first + val legacyWordCount = if (state == MnemonicState.Import) 24 else inputWordCounts.second + val showImportSafety = state == MnemonicState.Input && compactInput && shortWordCount == 12 && legacyWordCount == 24 + fun wordCountForLegacy(useLegacy: Boolean) = if (useLegacy) legacyWordCount else shortWordCount + var legacy by remember(shortWordCount, legacyWordCount) { mutableStateOf(mnemonicList.size > shortWordCount) } var other by remember { mutableStateOf(false) } var inputs by remember { mutableStateOf( - when (state) { - MnemonicState.Import -> List(if (!legacy) 12 else 24) { "" } - else -> List(if (!legacy) 13 else 25) { "" } - } + List(wordCountForLegacy(legacy)) { "" } ) } - if (state == MnemonicState.Import) { - LaunchedEffect(mnemonicList) { + if (state == MnemonicState.Input || state == MnemonicState.Import) { + LaunchedEffect(mnemonicList, shortWordCount, legacyWordCount) { + if (mnemonicList.isEmpty()) return@LaunchedEffect + val useLegacy = mnemonicList.size > shortWordCount legacy = - if (mnemonicList.size == 12) false else if (mnemonicList.size > 12) true else legacy - inputs = - if (mnemonicList.size <= 12) { - mnemonicList + List(12 - mnemonicList.size) { "" } - } else { - mnemonicList + List(24 - mnemonicList.size) { "" } - } + if (mnemonicList.size == shortWordCount) false else if (useLegacy) true else legacy + other = false + val targetCount = wordCountForLegacy(useLegacy) + inputs = mnemonicList.take(targetCount) + List((targetCount - mnemonicList.size).coerceAtLeast(0)) { "" } } } @@ -139,9 +143,39 @@ fun MnemonicPhraseInput( focusManager.clearFocus(force = true) keyboardController?.hide() } + fun isValidMnemonicForCurrentMode(words: List): Boolean { + if (words.any { MnemonicCode.INSTANCE.wordList.contains(it).not() }) return false + fun isValidBip39(value: List) = runCatching { isMnemonicValid(value) }.getOrDefault(false) + return when (state) { + MnemonicState.Input -> { + if (words.size != shortWordCount && words.size != legacyWordCount) return false + when (words.size) { + 12, 24 -> isValidBip39(words) + 13 -> mnemonicChecksum(words) && isValidBip39(words.subList(0, 12)) + 25 -> mnemonicChecksum(words) && isValidBip39(words.subList(0, 24)) + else -> false + } + } + MnemonicState.Import -> { + (words.size == 12 || words.size == 24) && isValidBip39(words) + } + else -> true + } + } + val suggestedWords = if ( + !other && + focusIndex >= 0 && + currentText.isNotBlank() && + (state == MnemonicState.Input || state == MnemonicState.Verify || state == MnemonicState.Import) + ) { + getMatchingWords(currentText.trim()).orEmpty() + } else { + emptyList() + } MixinAppTheme { KeyboardAwareBox( modifier = Modifier.fillMaxSize(), + showFloating = suggestedWords.isNotEmpty(), content = { availableHeight -> Column( modifier = if (availableHeight != null) { @@ -160,34 +194,38 @@ fun MnemonicPhraseInput( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - painter = painterResource(id = R.drawable.ic_mnemonic), - contentDescription = null, - ) - Spacer(modifier = Modifier.height(30.dp)) - Text( - text = when (state) { - MnemonicState.Input -> stringResource(R.string.log_in_whit_mnemonic_phrase) - MnemonicState.Import -> stringResource(R.string.import_mnemonic_phrase) - MnemonicState.Display -> stringResource(R.string.write_down_mnemonic_phrase) - MnemonicState.Verify -> stringResource(R.string.check_mnemonic_phrase) - }, - fontSize = 18.sp, - color = MixinAppTheme.colors.textPrimary, - fontWeight = SemiBold, - ) - Spacer(modifier = Modifier.height(12.dp)) + if (state != MnemonicState.Input || !compactInput) { + Image( + painter = painterResource(id = R.drawable.ic_mnemonic), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(30.dp)) + Text( + text = when (state) { + MnemonicState.Input -> stringResource(R.string.log_in_whit_mnemonic_phrase) + MnemonicState.Import -> stringResource(R.string.import_mnemonic_phrase) + MnemonicState.Display -> stringResource(R.string.write_down_mnemonic_phrase) + MnemonicState.Verify -> stringResource(R.string.check_mnemonic_phrase) + }, + fontSize = 18.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = SemiBold, + ) + Spacer(modifier = Modifier.height(12.dp)) - Text( - text = when (state) { - MnemonicState.Input -> stringResource(R.string.enter_mnemonic_phrase, if (legacy) 25 else 13) - MnemonicState.Import -> stringResource(R.string.enter_mnemonic_phrase, if (legacy) 24 else 12) - MnemonicState.Display -> stringResource(R.string.write_down_mnemonic_phrase_desc) - MnemonicState.Verify -> stringResource(R.string.check_mnemonic_phrase_desc) - }, fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, - textAlign = TextAlign.Center - ) + Text( + text = when (state) { + MnemonicState.Input -> stringResource(R.string.enter_mnemonic_phrase, wordCountForLegacy(legacy)) + MnemonicState.Import -> stringResource(R.string.enter_mnemonic_phrase, wordCountForLegacy(legacy)) + MnemonicState.Display -> stringResource(R.string.write_down_mnemonic_phrase_desc) + MnemonicState.Verify -> stringResource(R.string.check_mnemonic_phrase_desc) + }, fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.Center + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } if (state == MnemonicState.Input || state == MnemonicState.Import) { Spacer(modifier = Modifier.height(16.dp)) Row( @@ -198,30 +236,30 @@ fun MnemonicPhraseInput( onClick = { legacy = false other = false - inputs = List(if (state == MnemonicState.Input) 13 else 12) { "" } + inputs = List(shortWordCount) { "" } }, border = BorderStroke( width = 1.dp, color = if (!legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGray, ), ) { - Text(stringResource(R.string.words, if (state == MnemonicState.Input) 13 else 12), color = if (!legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist) + Text(stringResource(R.string.words, shortWordCount), color = if (!legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist) } Spacer(modifier = Modifier.width(8.dp)) WordCountButton( onClick = { legacy = true other = false - inputs = List(if (state == MnemonicState.Input) 25 else 24) { "" } + inputs = List(legacyWordCount) { "" } }, border = BorderStroke( width = 1.dp, color = if (legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGray ), ) { - Text(stringResource(R.string.words, if (state == MnemonicState.Input) 25 else 24), color = if (legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist) + Text(stringResource(R.string.words, legacyWordCount), color = if (legacy && !other) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist) } - if (state == MnemonicState.Input) { + if (state == MnemonicState.Input && showOtherWords) { Spacer(modifier = Modifier.width(8.dp)) WordCountButton( onClick = { @@ -326,7 +364,13 @@ fun MnemonicPhraseInput( ) Spacer(modifier = Modifier.height(20.dp)) } else { - InputGrid(if (legacy) 27 else 15, 10.dp) { index -> + val inputCount = inputs.size + val gridSize = if (state == MnemonicState.Display) { + if (mnemonicList.size > 12) 27 else 15 + } else { + inputCount + 3 + } + InputGrid(gridSize, 10.dp) { index -> if (state == MnemonicState.Display && ((mnemonicList.size == 12 && index == 12) || (mnemonicList.size == 24 && index == 24))) { Row( modifier = Modifier @@ -436,14 +480,9 @@ fun MnemonicPhraseInput( ), keyboardActions = KeyboardActions( onNext = { - if (!legacy && index == 12) { - repeat(4) { - focusManager.moveFocus(FocusDirection.Up) - } - } else if (index == 24) { - repeat(8) { - focusManager.moveFocus(FocusDirection.Up) - } + if (index == inputCount - 1) { + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Right) } else if ((index + 1) % 3 == 0) { focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Left) @@ -540,7 +579,7 @@ fun MnemonicPhraseInput( ) } } - } else if (state == MnemonicState.Import && index == if (legacy) 24 else 12) { + } else if ((state == MnemonicState.Input || state == MnemonicState.Import) && index == inputCount) { Row( modifier = Modifier .fillMaxWidth() @@ -560,7 +599,7 @@ fun MnemonicPhraseInput( color = MixinAppTheme.colors.textPrimary, ) } - } else if (index == if (legacy) 25 else 13) { + } else if (index == inputCount + 1) { Row( modifier = Modifier .fillMaxWidth() @@ -572,18 +611,14 @@ fun MnemonicPhraseInput( val clipData: ClipData? = clipboard.primaryClip if (clipData != null && clipData.itemCount > 0) { val pastedText = clipData.getItemAt(0).text.toString() - val words = pastedText.split(" ") + val words = pastedText.trim().split(Regex("\\s+")).filter { it.isNotBlank() } when { - words.size == (if (state == MnemonicState.Import) 24 else 25) && isMnemonicValid( - if (state == MnemonicState.Import) words else words.subList(0, 24) - ) -> { + words.size == legacyWordCount && isValidMnemonicForCurrentMode(words) -> { legacy = true inputs = words } - words.size == (if (state == MnemonicState.Import) 12 else 13) && isMnemonicValid( - if (state == MnemonicState.Import) words else words.subList(0, 12) - ) -> { + words.size == shortWordCount && isValidMnemonicForCurrentMode(words) -> { legacy = false inputs = words } @@ -625,12 +660,13 @@ fun MnemonicPhraseInput( ) } } - } else if (index == if (legacy) 26 else 14) { + } else if (index == inputCount + 2) { + val clearEnabled = inputs.any { it.isNotBlank() } Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)) - .clickable { + .clickable(enabled = state == MnemonicState.Display || clearEnabled) { if (state == MnemonicState.Input || state == MnemonicState.Verify || state == MnemonicState.Import) { inputs = inputs.map { "" } } else if (state == MnemonicState.Display) { @@ -642,16 +678,17 @@ fun MnemonicPhraseInput( .padding(8.dp) ) { if (state == MnemonicState.Input || state == MnemonicState.Verify || state == MnemonicState.Import) { + val clearColor = if (clearEnabled) MixinAppTheme.colors.textPrimary else MixinAppTheme.colors.textAssist Icon( painter = painterResource(id = R.drawable.ic_action_delete), contentDescription = null, - tint = MixinAppTheme.colors.textPrimary, + tint = clearColor, ) Spacer(modifier = Modifier.width(4.dp)) Text( stringResource(R.string.Clear), fontSize = 12.sp, fontWeight = W500, - color = MixinAppTheme.colors.textPrimary, + color = clearColor, ) } else if (state == MnemonicState.Display) { Icon( @@ -701,6 +738,61 @@ fun MnemonicPhraseInput( ) Spacer(modifier = Modifier.height(8.dp)) } + if (showImportSafety) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.align(Alignment.Start), + text = stringResource(R.string.mnemonic_login_security_title), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + fontWeight = W500, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.align(Alignment.Start), + text = stringResource(R.string.mnemonic_login_security_tip_1), + fontSize = 13.sp, + color = MixinAppTheme.colors.textMinor, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier.align(Alignment.Start), + text = stringResource(R.string.mnemonic_login_security_tip_2), + fontSize = 13.sp, + color = MixinAppTheme.colors.textMinor, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier.align(Alignment.Start), + text = stringResource(R.string.mnemonic_login_security_tip_3), + fontSize = 13.sp, + color = MixinAppTheme.colors.textMinor, + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val previews = listOf( + R.drawable.mixin_import_safety_preview_0, + R.drawable.mixin_import_safety_preview_1, + R.drawable.mixin_import_safety_preview_2, + R.drawable.mixin_import_safety_preview_3, + R.drawable.mixin_import_safety_preview_4, + R.drawable.mixin_import_safety_preview_5, + R.drawable.mixin_import_safety_preview_6, + ) + previews.forEachIndexed { index, res -> + if (index != 0) Spacer(modifier = Modifier.width(8.dp)) + Image( + painter = painterResource(id = res), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } + } + } Spacer(modifier = Modifier.height(if (legacy) 20.dp else 8.dp)) Spacer(modifier = Modifier.weight(1f)) Button( @@ -801,7 +893,7 @@ fun MnemonicPhraseInput( state == MnemonicState.Import -> R.string.Next state == MnemonicState.Display && (mnemonicList.size == 12 || mnemonicList.size == 24) -> R.string.Done state == MnemonicState.Display -> R.string.Check_Backup - state == MnemonicState.Input -> R.string.Confirm + state == MnemonicState.Input -> R.string.Sign_in_with_Mnemonic_Phrase state == MnemonicState.Verify -> R.string.Complete else -> R.string.Next } @@ -817,41 +909,21 @@ fun MnemonicPhraseInput( } } }, floating = { - if (!other && (state == MnemonicState.Input || state == MnemonicState.Verify || state == MnemonicState.Import)) { - InputBar(currentText) { word -> - val maxInput = when (state) { - MnemonicState.Import -> if (!legacy) 24 else 12 - else -> inputs.size - } + if (suggestedWords.isNotEmpty()) { + InputBar(suggestedWords) { word -> + val maxInput = inputs.size if (focusIndex in 0 until maxInput) { inputs = inputs.toMutableList().also { it[focusIndex] = word } } - if (state == MnemonicState.Import) { - if (legacy && focusIndex == 11) { - keyboardController?.hide() - focusManager.moveFocus(FocusDirection.Right) - } else if (!legacy && focusIndex == 23) { - keyboardController?.hide() - } else if ((focusIndex + 1) % 3 == 0) { - focusManager.moveFocus(FocusDirection.Down) - focusManager.moveFocus(FocusDirection.Left) - focusManager.moveFocus(FocusDirection.Left) - } else { - focusManager.moveFocus(FocusDirection.Right) - } + if (focusIndex == maxInput - 1) { + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Right) + } else if ((focusIndex + 1) % 3 == 0) { + focusManager.moveFocus(FocusDirection.Down) + focusManager.moveFocus(FocusDirection.Left) + focusManager.moveFocus(FocusDirection.Left) } else { - if (!legacy && focusIndex == 12) { - keyboardController?.hide() - focusManager.moveFocus(FocusDirection.Right) - } else if (focusIndex == 24) { - keyboardController?.hide() - } else if ((focusIndex + 1) % 3 == 0) { - focusManager.moveFocus(FocusDirection.Down) - focusManager.moveFocus(FocusDirection.Left) - focusManager.moveFocus(FocusDirection.Left) - } else { - focusManager.moveFocus(FocusDirection.Right) - } + focusManager.moveFocus(FocusDirection.Right) } } } @@ -921,9 +993,7 @@ fun WordCountButton(onClick: () -> Unit, border: BorderStroke, content: @Composa } @Composable -fun InputBar(string: String, callback: (String) -> Unit) { - if (string.isBlank()) return - val list = getMatchingWords(string.trim()) ?: return +fun InputBar(list: List, callback: (String) -> Unit) { if (list.isEmpty()) return LazyRow( modifier = Modifier diff --git a/app/src/main/java/one/mixin/android/ui/tip/PendingMnemonicTipRouting.kt b/app/src/main/java/one/mixin/android/ui/tip/PendingMnemonicTipRouting.kt new file mode 100644 index 0000000000..bba0d461af --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/tip/PendingMnemonicTipRouting.kt @@ -0,0 +1,20 @@ +package one.mixin.android.ui.tip + +import one.mixin.android.vo.WalletCategory + +enum class PendingMnemonicRoute { + WalletHome, + ImportMnemonic, + WalletFetchFailed, +} + +fun shouldCreateClassicWalletAfterTip( + tipType: TipType, +): Boolean = tipType != TipType.Change + +fun routePendingMnemonicAfterWalletFetch(walletCategories: List?): PendingMnemonicRoute = + when { + walletCategories == null -> PendingMnemonicRoute.WalletFetchFailed + walletCategories.any { it == WalletCategory.CLASSIC.value } -> PendingMnemonicRoute.WalletHome + else -> PendingMnemonicRoute.ImportMnemonic + } diff --git a/app/src/main/java/one/mixin/android/ui/tip/TipFlowInteractor.kt b/app/src/main/java/one/mixin/android/ui/tip/TipFlowInteractor.kt index 564c0d72aa..cbeb9e5d52 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/TipFlowInteractor.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/TipFlowInteractor.kt @@ -24,6 +24,8 @@ import one.mixin.android.api.service.AccountService import one.mixin.android.api.service.TipNodeService import one.mixin.android.api.service.UtxoService import one.mixin.android.crypto.PinCipher +import one.mixin.android.crypto.clearPendingImportMnemonic +import one.mixin.android.crypto.hasPendingImportMnemonic import one.mixin.android.crypto.PrivacyPreference.putPrefPinInterval import one.mixin.android.crypto.initFromSeedAndSign import one.mixin.android.crypto.newKeyPairFromSeed @@ -31,6 +33,7 @@ import one.mixin.android.crypto.removeValueFromEncryptedPreferences import one.mixin.android.db.web3.vo.Web3Wallet import one.mixin.android.extension.decodeBase64 import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.findFragmentActivityOrNull import one.mixin.android.extension.hexString import one.mixin.android.extension.putBoolean import one.mixin.android.extension.putLong @@ -48,6 +51,8 @@ import one.mixin.android.tip.getTipExceptionMsg import one.mixin.android.tip.privateKeyToAddress import one.mixin.android.tip.tipPrivToPrivateKey import one.mixin.android.ui.home.MainActivity +import one.mixin.android.ui.wallet.WalletSecurityActivity +import one.mixin.android.ui.wallet.components.walletDestinationForWallet import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.BiometricUtil import one.mixin.android.util.ErrorHandler @@ -256,6 +261,10 @@ class TipFlowInteractor @Inject internal constructor( return false } } + val hasPendingImport = hasPendingImportMnemonic(context) + if (shouldCreateClassicWalletAfterTip(tipBundle.tipType) && !ensureClassicWalletAfterTip(context, pin, tipPriv, onStepChanged)) { + return false + } val cur = System.currentTimeMillis() context.defaultSharedPreferences.putBoolean(PREF_LOGIN_OR_SIGN_UP, true) context.defaultSharedPreferences.putLong(Constants.Account.PREF_PIN_CHECK, cur) @@ -268,12 +277,64 @@ class TipFlowInteractor @Inject internal constructor( onShowMessage(ignored.toString()) } } - if (shouldOpenMainActivity) { - MainActivity.show(context) + if (shouldOpenMainActivity || hasPendingImport) { + openNextAfterPin(context) } return true } + private suspend fun ensureClassicWalletAfterTip( + context: Context, + pin: String, + tipPriv: ByteArray?, + onStepChanged: (TipStep) -> Unit, + ): Boolean { + if (web3Repository.getClassicWalletId() != null) { + return true + } + val seed = try { + tipPriv ?: tip.getOrRecoverTipPriv(context, pin).getOrThrow() + } catch (e: Exception) { + val errorInfo = e.getTipExceptionMsg(context) + onStepChanged(RetryRegister(null, errorInfo)) + return false + } + val spendSeed = tip.getSpendPriv(context, seed) + if (ensureClassicWallet(context, spendSeed)) { + return true + } + onStepChanged(RetryRegister(seed, context.getString(R.string.Set_up_pin_error_message))) + return false + } + + private suspend fun openNextAfterPin(context: Context) { + val activity = context.findFragmentActivityOrNull() + if (!hasPendingImportMnemonic(context)) { + MainActivity.show(context) + return + } + val wallets = web3Repository.syncWalletsFromRoute() + when (routePendingMnemonicAfterWalletFetch(wallets?.map { it.category })) { + PendingMnemonicRoute.WalletHome -> { + clearPendingImportMnemonic(context) + val walletDestination = wallets?.firstOrNull { it.category == WalletCategory.CLASSIC.value }?.let { wallet -> + walletDestinationForWallet(wallet.id, wallet.category) + } + MainActivity.showWallet(context, walletDestination = walletDestination) + } + PendingMnemonicRoute.ImportMnemonic -> { + if (activity != null) { + WalletSecurityActivity.show(activity, WalletSecurityActivity.Mode.LOGIN_IMPORT_MNEMONIC) + } else { + MainActivity.show(context) + } + } + PendingMnemonicRoute.WalletFetchFailed -> { + MainActivity.show(context) + } + } + } + private suspend fun registerPublicKey( context: Context, tipBundle: TipBundle, @@ -327,7 +388,10 @@ class TipFlowInteractor @Inject internal constructor( val evmAddress: String = getTipAddress(context, pin, ETHEREUM_CHAIN_ID) Web3Signer.updateAddress(Web3Signer.JsSignerNetwork.Solana.name, solAddress) Web3Signer.updateAddress(Web3Signer.JsSignerNetwork.Ethereum.name, evmAddress) - createWallet(context, spendSeed) + if (!ensureClassicWallet(context, spendSeed)) { + onStepChanged(RetryRegister(seed, context.getString(R.string.Set_up_pin_error_message))) + return false + } if (Session.hasPhone()) { removeValueFromEncryptedPreferences(context, Constants.Tip.MNEMONIC) } @@ -345,10 +409,10 @@ class TipFlowInteractor @Inject internal constructor( return false } - private suspend fun createWallet(context: Context, spendKey: ByteArray) { + private suspend fun ensureClassicWallet(context: Context, spendKey: ByteArray): Boolean { val hasClassicWallet: Boolean = web3Repository.getClassicWalletId() != null if (hasClassicWallet) { - return + return true } val walletName: String = context.getString(R.string.Common_Wallet) val classicIndex = 0 @@ -361,21 +425,26 @@ class TipFlowInteractor @Inject internal constructor( createSignedWeb3AddressRequest(destination = btcAddress, chainId = Constants.ChainId.BITCOIN_CHAIN_ID, path = Bip44Path.bitcoinSegwitPathString(classicIndex), privateKey = tipPrivToPrivateKey(spendKey, Constants.ChainId.BITCOIN_CHAIN_ID, classicIndex), category = WalletCategory.CLASSIC.value), ) val walletRequest = WalletRequest(name = walletName, category = WalletCategory.CLASSIC.value, addresses = addresses) - requestRouteAPI( + val created = requestRouteAPI( invokeNetwork = { web3Repository.createWallet(walletRequest) }, successBlock = { response -> - response.data?.let { wallet -> + val wallet = response.data + if (wallet == null) { + false + } else { web3Repository.insertWallet(Web3Wallet(id = wallet.id, name = wallet.name, category = wallet.category, createdAt = wallet.createdAt, updatedAt = wallet.updatedAt)) val walletAddresses = wallet.addresses ?: emptyList() if (walletAddresses.isNotEmpty()) { web3Repository.insertAddressList(walletAddresses) } + true } }, requestSession = { ids -> web3Repository.fetchSessionsSuspend(ids) }, ) + return created == true && web3Repository.getClassicWalletId() != null } private fun createSignedWeb3AddressRequest( diff --git a/app/src/main/java/one/mixin/android/ui/wallet/FetchingWalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/FetchingWalletFragment.kt index d2c4de6dcc..65396055b5 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/FetchingWalletFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/FetchingWalletFragment.kt @@ -2,6 +2,8 @@ package one.mixin.android.ui.wallet import android.os.Bundle import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch @@ -10,6 +12,7 @@ import one.mixin.android.R import one.mixin.android.databinding.FragmentComposeBinding import one.mixin.android.extension.openUrl import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.wallet.components.FetchErrorContent import one.mixin.android.ui.wallet.components.FetchWalletState import one.mixin.android.ui.wallet.components.FetchingContent import one.mixin.android.ui.wallet.viewmodel.FetchWalletViewModel @@ -38,7 +41,19 @@ class FetchingWalletFragment : BaseFragment(R.layout.fragment_compose) { binding.titleView.rightAnimator.displayedChild = 0 binding.titleView.rightAnimator.setOnClickListener { context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) } binding.compose.setContent { - FetchingContent() + val state by viewModel.state.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + when (state) { + FetchWalletState.FETCH_ERROR -> { + FetchErrorContent( + errorMessage = errorMessage, + onRetry = viewModel::retryFetching, + ) + } + else -> { + FetchingContent() + } + } } viewModel.setMnemonic(mnemonic.orEmpty()) viewLifecycleOwner.lifecycleScope.launch { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/ImportingWalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/ImportingWalletFragment.kt index dc7f2de062..4194edd89b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/ImportingWalletFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/ImportingWalletFragment.kt @@ -2,14 +2,17 @@ package one.mixin.android.ui.wallet import android.os.Bundle import android.view.View +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.fragment.app.activityViewModels import one.mixin.android.Constants import one.mixin.android.R +import one.mixin.android.crypto.clearPendingImportMnemonic import one.mixin.android.databinding.FragmentComposeBinding import one.mixin.android.extension.openUrl import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.home.MainActivity import one.mixin.android.ui.setting.member.MixinMemberUpgradeBottomSheetDialogFragment import one.mixin.android.ui.wallet.components.FetchWalletState import one.mixin.android.ui.wallet.components.ImportErrorContent @@ -34,6 +37,7 @@ class ImportingWalletFragment : BaseFragment(R.layout.fragment_compose) { val errorCode by viewModel.errorCode.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState() val partialSuccess by viewModel.partialSuccess.collectAsState() + val importedWalletDestination by viewModel.importedWalletDestination.collectAsState() when (state) { FetchWalletState.IMPORTING -> { @@ -57,7 +61,14 @@ class ImportingWalletFragment : BaseFragment(R.layout.fragment_compose) { ) } FetchWalletState.IMPORT_SUCCESS ->{ - requireActivity().finish() + LaunchedEffect(importedWalletDestination) { + clearPendingImportMnemonic(requireContext()) + MainActivity.showWallet( + requireContext(), + walletDestination = importedWalletDestination + ) + requireActivity().finish() + } } else -> { ImportingContent { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/VerifyPinBeforeImportWalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/VerifyPinBeforeImportWalletFragment.kt index 5de43fb7da..e4865a0d81 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/VerifyPinBeforeImportWalletFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/VerifyPinBeforeImportWalletFragment.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import one.mixin.android.R +import one.mixin.android.crypto.getPendingImportMnemonic import one.mixin.android.tip.Tip import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.wallet.components.VerifyPinBeforeImportWalletPage @@ -116,6 +117,17 @@ class VerifyPinBeforeImportWalletFragment : BaseFragment(R.layout.fragment_compo WalletSecurityActivity.Mode.VIEW_ADDRESS -> { requireActivity().finish() } + WalletSecurityActivity.Mode.LOGIN_IMPORT_MNEMONIC -> { + val mnemonic = getPendingImportMnemonic(requireContext()) + if (mnemonic.isNullOrBlank()) { + requireActivity().finish() + } else { + replaceAsRoot( + FetchingWalletFragment.newInstance(mnemonic), + FetchingWalletFragment.TAG + ) + } + } } } else { requireActivity().finish() diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt index c78974541e..73d02ca034 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt @@ -34,6 +34,7 @@ class WalletSecurityActivity : BlazeBaseActivity() { Mode.RE_IMPORT_MNEMONIC -> VerifyPinBeforeImportWalletFragment.newInstance(Mode.RE_IMPORT_MNEMONIC, walletId = walletId) Mode.RE_IMPORT_PRIVATE_KEY -> VerifyPinBeforeImportWalletFragment.newInstance(Mode.RE_IMPORT_PRIVATE_KEY, walletId = walletId, chainId = chainId) Mode.VIEW_ADDRESS -> ViewWalletAddressFragment.newInstance(walletId) + Mode.LOGIN_IMPORT_MNEMONIC -> VerifyPinBeforeImportWalletFragment.newInstance(Mode.LOGIN_IMPORT_MNEMONIC) } supportFragmentManager.beginTransaction() @@ -73,6 +74,7 @@ class WalletSecurityActivity : BlazeBaseActivity() { RE_IMPORT_PRIVATE_KEY, CREATE_WALLET, VIEW_ADDRESS, + LOGIN_IMPORT_MNEMONIC, } private fun Mode.requiresSecureWindow(): Boolean = diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt index c159a34d67..286800304e 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt @@ -50,12 +50,16 @@ import java.text.NumberFormat enum class FetchWalletState { FETCHING, + FETCH_ERROR, SELECT, IMPORTING, IMPORT_ERROR, IMPORT_SUCCESS } +fun fetchWalletFailureState(hasExistingWallets: Boolean): FetchWalletState = + if (hasExistingWallets) FetchWalletState.SELECT else FetchWalletState.FETCH_ERROR + data class AssetInfo( val symbol: String, val iconUrl: String, @@ -126,6 +130,68 @@ fun FetchingContent() { ) } +@Composable +fun FetchErrorContent( + errorMessage: String?, + onRetry: () -> Unit, +) { + MixinAppTheme { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(132.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_wallet_warning), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(64.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.Fetch_Failed), + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = errorMessage ?: stringResource(R.string.error_connection_error), + fontSize = 14.sp, + color = MixinAppTheme.colors.red, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + backgroundColor = MixinAppTheme.colors.accent, + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(30.dp), + contentPadding = PaddingValues(vertical = 12.dp), + elevation = + ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + ) { + Text( + text = stringResource(R.string.Retry), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + } + Spacer(modifier = Modifier.height(30.dp)) + } + } +} + @Composable fun SelectContent( wallets: List, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/WalletDestination.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/WalletDestination.kt index 401063ff36..da5f8bb748 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/WalletDestination.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/WalletDestination.kt @@ -5,6 +5,8 @@ import com.google.gson.TypeAdapter import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonWriter +import one.mixin.android.util.GsonHelper +import one.mixin.android.vo.WalletCategory import timber.log.Timber sealed class WalletDestination { @@ -15,6 +17,25 @@ sealed class WalletDestination { data class Safe(val walletId: String, val isOwner: Boolean, val chainId: String?, val url: String?) : WalletDestination() } +fun walletDestinationForWallet(walletId: String, category: String): WalletDestination { + return when (category) { + WalletCategory.CLASSIC.value -> WalletDestination.Classic(walletId) + WalletCategory.WATCH_ADDRESS.value -> WalletDestination.Watch(walletId, category) + else -> WalletDestination.Import(walletId, category) + } +} + +fun walletDestinationToJson(destination: WalletDestination): String { + return GsonHelper.customGson.toJson(destination) +} + +fun walletDestinationFromJson(json: String): WalletDestination? { + return try { + GsonHelper.customGson.fromJson(json, WalletDestination::class.java) + } catch (_: Exception) { + null + } +} class WalletDestinationTypeAdapter : TypeAdapter() { @@ -96,4 +117,4 @@ class WalletDestinationTypeAdapter : TypeAdapter() { else -> throw JsonParseException("Unknown type: $type") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt b/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt index 145d3af4b8..1262c09910 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import one.mixin.android.Constants import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.MixinApplication @@ -30,6 +31,9 @@ import one.mixin.android.tip.tipPrivToPrivateKey import one.mixin.android.ui.wallet.WalletSecurityActivity import one.mixin.android.ui.wallet.components.FetchWalletState import one.mixin.android.ui.wallet.components.IndexedWallet +import one.mixin.android.ui.wallet.components.WalletDestination +import one.mixin.android.ui.wallet.components.fetchWalletFailureState +import one.mixin.android.ui.wallet.components.walletDestinationForWallet import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.ErrorHandler import one.mixin.android.util.encodeToBase58String @@ -77,6 +81,9 @@ class FetchWalletViewModel @Inject constructor( private val _partialSuccess = MutableStateFlow(null) val partialSuccess: StateFlow = _partialSuccess.asStateFlow() + private val _importedWalletDestination = MutableStateFlow(null) + val importedWalletDestination: StateFlow = _importedWalletDestination.asStateFlow() + suspend fun getAllNoKeyWallets() = web3Repository.getAllNoKeyWallets() private var mnemonic: String = "" @@ -98,6 +105,8 @@ class FetchWalletViewModel @Inject constructor( fun setMnemonic(mnemonic: String) { this.mnemonic = mnemonic _wallets.value = emptyList() + _selectedWalletInfos.value = emptySet() + _importedWalletDestination.value = null currentIndex = 0 startFetching(0) } @@ -144,6 +153,10 @@ class FetchWalletViewModel @Inject constructor( startFetching(currentIndex) } + fun retryFetching() { + startFetching(currentIndex) + } + private var localMaxIndex: Int = 0 private fun startFetching(offset: Int) { @@ -219,15 +232,18 @@ class FetchWalletViewModel @Inject constructor( } } } else { - if (offset == 0) { - _wallets.value = listOf(wallets[0]) - } + _errorCode.value = response.errorCode + _errorMessage.value = MixinApplication.appContext.getMixinErrorStringByCode(response.errorCode, response.errorDescription) + _state.value = fetchWalletFailureState(_wallets.value.isNotEmpty()) + return@launch } } _state.value = FetchWalletState.SELECT } catch (e: Exception) { Timber.e(e, "Failed to fetch wallet info") - _state.value = FetchWalletState.SELECT + _errorCode.value = null + _errorMessage.value = ErrorHandler.getErrorMessage(e) + _state.value = fetchWalletFailureState(_wallets.value.isNotEmpty()) } } } @@ -235,6 +251,7 @@ class FetchWalletViewModel @Inject constructor( // Start importing selected wallet infos fun startImporting() { viewModelScope.launch { + _importedWalletDestination.value = null _state.value = FetchWalletState.IMPORTING try { val walletsToCreate = selectedWalletInfos.value.map { @@ -306,7 +323,11 @@ class FetchWalletViewModel @Inject constructor( updatedAt = wallet.updatedAt, ) ) + wallet.addresses?.takeIf { it.isNotEmpty() }?.let { addresses -> + web3Repository.insertAddressList(addresses) + } saveWeb3PrivateKey(MixinApplication.appContext, currentSpendKey, wallet.id, words) + selectImportedWalletIfNeeded(wallet.id, wallet.category) jobManager.addJobInBackground(RefreshSingleWalletJob(wallet.id)) successCount++ } @@ -407,6 +428,7 @@ class FetchWalletViewModel @Inject constructor( try { val address: String val category: WalletCategory + _importedWalletDestination.value = null _state.value = FetchWalletState.IMPORTING when (mode) { WalletSecurityActivity.Mode.IMPORT_PRIVATE_KEY -> { @@ -485,6 +507,10 @@ class FetchWalletViewModel @Inject constructor( if (privateKey.isNullOrEmpty().not()) { saveWeb3ImportedPrivateKey(MixinApplication.appContext, currentSpendKey, wallet.id, privateKey) } + wallet.addresses?.takeIf { it.isNotEmpty() }?.let { addresses -> + web3Repository.insertAddressList(addresses) + } + selectImportedWalletIfNeeded(wallet.id, wallet.category) jobManager.addJobInBackground(RefreshSingleWalletJob(wallet.id)) Timber.d("Successfully imported wallet ${wallet.id}") @@ -563,6 +589,7 @@ class FetchWalletViewModel @Inject constructor( } viewModelScope.launch { try { + _importedWalletDestination.value = null _state.value = FetchWalletState.IMPORTING val names = web3Repository.getAllWalletNames(listOf(WalletCategory.CLASSIC.value, WalletCategory.IMPORTED_PRIVATE_KEY.value, WalletCategory.IMPORTED_MNEMONIC.value)) val classicIndex = web3Repository.getClassicWalletMaxIndex() + 1 @@ -611,6 +638,14 @@ class FetchWalletViewModel @Inject constructor( } } + private suspend fun selectImportedWalletIfNeeded(walletId: String, category: String) { + if (_importedWalletDestination.value != null) return + _importedWalletDestination.value = walletDestinationForWallet(walletId, category) + Web3Signer.setWallet(walletId, category) { queryWalletId -> + runBlocking { web3Repository.getAddresses(queryWalletId) } + } + } + private fun createSignedWeb3AddressRequest( destination: String, chainId: String, diff --git a/app/src/main/res/layout/fragment_login_method.xml b/app/src/main/res/layout/fragment_login_method.xml new file mode 100644 index 0000000000..b50ca29c03 --- /dev/null +++ b/app/src/main/res/layout/fragment_login_method.xml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +