diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportCard.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportCard.kt index 2035d02c..95b2f694 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportCard.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportCard.kt @@ -56,6 +56,7 @@ internal fun WordInputGrid( tabIndex: Int, onWordsChanged: (List>) -> Unit, onFocusChanged: (Int) -> Unit, + onPasteMnemonic: ((String) -> Unit)? = null, ) { val wordCount = when (numberOfWords) { @@ -116,6 +117,7 @@ internal fun WordInputGrid( onFocusChanged(index + 1) } }, + onPasteMnemonic = onPasteMnemonic, ) } } @@ -149,6 +151,7 @@ internal fun WordInputGrid( onFocusChanged(index + 1) } }, + onPasteMnemonic = onPasteMnemonic, ) } } @@ -167,6 +170,7 @@ private fun WordInputField( onWordChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, onNext: () -> Unit, + onPasteMnemonic: ((String) -> Unit)? = null, ) { val focusManager = LocalFocusManager.current val autocomplete = @@ -255,6 +259,13 @@ private fun WordInputField( BasicTextField( value = word, onValueChange = { newValue -> + // detect paste of full mnemonic (12+ words) + val pastedWords = newValue.split(Regex("\\s+")).filter { it.isNotEmpty() } + if (pastedWords.size >= 12 && onPasteMnemonic != null) { + onPasteMnemonic(newValue) + return@BasicTextField + } + val trimmed = newValue.trim().lowercase() val oldWord = previousWord previousWord = trimmed diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt index 9424ef2a..ea2ddbdb 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt @@ -67,6 +67,7 @@ import org.bitcoinppl.cove_core.ImportType import org.bitcoinppl.cove_core.ImportWalletException import org.bitcoinppl.cove_core.NumberOfBip39Words import org.bitcoinppl.cove_core.Route +import org.bitcoinppl.cove_core.groupedPlainWordsOf import org.bitcoinppl.cove_core.types.WalletId private enum class AlertState { @@ -100,27 +101,30 @@ fun HotWalletImportScreen( importType: ImportType, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { + // use local state so we can update when paste changes word count + var currentNumberOfWords by remember { mutableStateOf(numberOfWords) } + val wordCount = - when (numberOfWords) { + when (currentNumberOfWords) { NumberOfBip39Words.TWELVE -> 12 NumberOfBip39Words.TWENTY_FOUR -> 24 } val numberOfGroups = wordCount / GROUPS_OF - var enteredWords by remember(numberOfWords) { + var enteredWords by remember(currentNumberOfWords) { mutableStateOf(List(numberOfGroups) { List(GROUPS_OF) { "" } }) } var alertState by remember { mutableStateOf(AlertState.None) } var duplicateWalletId by remember { mutableStateOf(null) } var genericErrorMessage by remember { mutableStateOf("") } - var focusedField by remember(numberOfWords) { mutableIntStateOf(0) } - var tabIndex by remember(numberOfWords) { mutableIntStateOf(0) } + var focusedField by remember(currentNumberOfWords) { mutableIntStateOf(0) } + var tabIndex by remember(currentNumberOfWords) { mutableIntStateOf(0) } // auto-switch page when focus changes to a word on a different page - LaunchedEffect(focusedField) { + LaunchedEffect(focusedField, enteredWords) { val newTab = focusedField / GROUPS_OF - if (newTab != tabIndex && newTab < numberOfGroups) { + if (newTab != tabIndex && newTab < enteredWords.size) { tabIndex = newTab } } @@ -151,13 +155,18 @@ fun HotWalletImportScreen( val flatWords = words.flatten() val totalWords = flatWords.size - // validate word count matches expected from route - if (totalWords != wordCount) { - Log.w("HotWalletImport", "Word count mismatch: got $totalWords, expected $wordCount") - genericErrorMessage = "Invalid number of words. Expected $wordCount words, got $totalWords" - alertState = AlertState.GenericError - return - } + // update word count based on actual pasted words (matching iOS behavior) + currentNumberOfWords = + when (totalWords) { + 12 -> NumberOfBip39Words.TWELVE + 24 -> NumberOfBip39Words.TWENTY_FOUR + else -> { + Log.w("HotWalletImport", "Invalid word count: $totalWords") + genericErrorMessage = "Invalid number of words: $totalWords. We only support 12 or 24 words." + alertState = AlertState.GenericError + return + } + } // reset scanners showQrScanner = false @@ -166,7 +175,8 @@ fun HotWalletImportScreen( // update words enteredWords = words - // move to last field + // move to last page and last field + tabIndex = words.size - 1 focusedField = totalWords - 1 } @@ -178,10 +188,37 @@ fun HotWalletImportScreen( word.isNotEmpty() && Bip39WordSpecificAutocomplete( wordNumber = (idx + 1).toUShort(), - numberOfWords = numberOfWords, + numberOfWords = currentNumberOfWords, ).use { it.isValidWord(word, enteredWords) } } + fun handlePasteMnemonic(mnemonicString: String) { + // extract word-like tokens, stripping numbers and punctuation + val words = + mnemonicString + .split(Regex("\\s+")) + .map { it.lowercase() } + .filter { word -> word.all { it.isLetter() } } + + // need 12 or 24 words + if (words.size != 12 && words.size != 24) { + alertState = AlertState.InvalidWords + return + } + + // group words into chunks of GROUPS_OF (12) + val grouped = words.chunked(GROUPS_OF) + setWords(grouped) + + // validate - show alert if invalid + try { + groupedPlainWordsOf(words.joinToString(" "), GROUPS_OF.toUByte()) + } catch (e: Exception) { + Log.d("HotWalletImport", "Invalid pasted mnemonic: ${e.message}") + alertState = AlertState.InvalidWords + } + } + val focusManager = LocalFocusManager.current // dismiss keyboard when all words become valid @@ -299,21 +336,22 @@ fun HotWalletImportScreen( WordInputGrid( enteredWords = enteredWords, - numberOfWords = numberOfWords, + numberOfWords = currentNumberOfWords, focusedField = focusedField, tabIndex = tabIndex, onWordsChanged = { newWords -> enteredWords = newWords }, onFocusChanged = { field -> focusedField = field }, + onPasteMnemonic = ::handlePasteMnemonic, ) - // page indicator dots for 24-word import - if (numberOfWords == NumberOfBip39Words.TWENTY_FOUR) { + // page indicator dots for multi-page import + if (enteredWords.size > 1) { Spacer(Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { - repeat(numberOfGroups) { i -> + repeat(enteredWords.size) { i -> val isSelected = i == tabIndex Box( modifier = @@ -462,7 +500,7 @@ fun HotWalletImportScreen( // NFC Scanner Bottom Sheet if (showNfcScanner) { NfcScannerSheet( - numberOfWords = numberOfWords, + numberOfWords = currentNumberOfWords, onDismiss = { showNfcScanner = false }, diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift index c675588f..d3141121 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift @@ -13,10 +13,12 @@ private let rowHeight = 30.0 /// Handles return key press without dismissing keyboard, forwarding other calls to original delegate private final class TextFieldReturnHandler: NSObject, UITextFieldDelegate { var onReturn: () -> Void + var onPasteMnemonic: ((String) -> Void)? weak var originalDelegate: UITextFieldDelegate? - init(onReturn: @escaping () -> Void, originalDelegate: UITextFieldDelegate?) { + init(onReturn: @escaping () -> Void, onPasteMnemonic: ((String) -> Void)?, originalDelegate: UITextFieldDelegate?) { self.onReturn = onReturn + self.onPasteMnemonic = onPasteMnemonic self.originalDelegate = originalDelegate } @@ -39,7 +41,14 @@ private final class TextFieldReturnHandler: NSObject, UITextFieldDelegate { } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - originalDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true + // detect paste of full mnemonic (12+ words) + let words = string.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + if words.count >= 12, let onPasteMnemonic { + onPasteMnemonic(string) + return false + } + + return originalDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true } func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { @@ -62,6 +71,7 @@ private let groupsOf = HotWalletImportScreen.GROUPS_OF struct HotWalletImportCard: View { var numberOfWords: NumberOfBip39Words + var onPasteMnemonic: ((String) -> Void)? @Binding var tabIndex: Int @Binding var enteredWords: [[String]] @@ -81,7 +91,8 @@ struct HotWalletImportCard: View { filteredSuggestions: $filteredSuggestions, focusField: $focusField, allEnteredWords: enteredWords, - numberOfWords: numberOfWords + numberOfWords: numberOfWords, + onPasteMnemonic: onPasteMnemonic ) .tag(index) } @@ -111,6 +122,7 @@ private struct CardTab: View { let allEnteredWords: [[String]] let numberOfWords: NumberOfBip39Words + var onPasteMnemonic: ((String) -> Void)? let cardSpacing: CGFloat = 20 @@ -131,6 +143,7 @@ private struct CardTab: View { ), allEnteredWords: allEnteredWords, numberOfWords: numberOfWords, + onPasteMnemonic: onPasteMnemonic, tabIndex: $tabIndex, text: $fields[index], filteredSuggestions: $filteredSuggestions, @@ -151,6 +164,7 @@ private struct AutocompleteField: View { let autocomplete: Bip39WordSpecificAutocomplete let allEnteredWords: [[String]] let numberOfWords: NumberOfBip39Words + var onPasteMnemonic: ((String) -> Void)? @Binding var tabIndex: Int @Binding var text: String @@ -321,6 +335,7 @@ private struct AutocompleteField: View { if returnHandler == nil { let handler = TextFieldReturnHandler( onReturn: { [self] in submitFocusField() }, + onPasteMnemonic: onPasteMnemonic, originalDelegate: textField.delegate ) returnHandler = handler diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift index aa5b5af1..2e67dfc0 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift @@ -157,9 +157,9 @@ struct HotWalletImportScreen: View { var lastIndex: Int { switch numberOfWords { case .twelve: - 1 + 0 case .twentyFour: - 3 + 1 } } @@ -369,6 +369,7 @@ struct HotWalletImportScreen: View { var Card: some View { HotWalletImportCard( numberOfWords: numberOfWords, + onPasteMnemonic: handlePasteMnemonic, tabIndex: $tabIndex, enteredWords: $enteredWords, filteredSuggestions: $filteredSuggestions, @@ -527,12 +528,13 @@ struct HotWalletImportScreen: View { // MARK: OnChange Functions - func onChangeFocusField(_ old: ImportFieldNumber?, _ new: ImportFieldNumber?) { - // clear suggestions when focus changes + func onChangeFocusField(_: ImportFieldNumber?, _ new: ImportFieldNumber?) { filteredSuggestions = [] - // check if we should move to next page - let focusFieldNumber = new?.fieldNumber ?? old?.fieldNumber ?? 1 + // don't change tab when focus is cleared (e.g., after paste) + guard let new else { return } + + let focusFieldNumber = new.fieldNumber if (focusFieldNumber % groupsOf) == 1 { withAnimation { tabIndex = Int(focusFieldNumber / groupsOf) @@ -592,7 +594,38 @@ struct HotWalletImportScreen: View { enteredWords = words sheetState = .none - tabIndex = lastIndex + tabIndex = words.count - 1 + } + + func handlePasteMnemonic(_ mnemonicString: String) { + // extract word-like tokens, stripping numbers and punctuation + let words = mnemonicString + .split(whereSeparator: { $0.isWhitespace }) + .map { String($0).lowercased() } + .filter { word in + // keep only alphabetic strings (filters out "1.", "2)", etc.) + word.allSatisfy(\.isLetter) + } + + // need 12 or 24 words + guard words.count == 12 || words.count == 24 else { + alertState = .init(.invalidWords) + return + } + + // group words into chunks of groupsOf (12) + let grouped = stride(from: 0, to: words.count, by: groupsOf).map { + Array(words[$0 ..< min($0 + groupsOf, words.count)]) + } + setWords(grouped) + + // validate - show alert if invalid + do { + _ = try groupedPlainWordsOf(mnemonic: words.joined(separator: " "), groups: UInt8(groupsOf)) + } catch { + Log.debug("Invalid pasted mnemonic: \(error)") + alertState = .init(.invalidWords) + } } }