diff --git a/app/build.gradle b/app/build.gradle index bbc9a386d..6b2a882ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -352,7 +352,7 @@ dependencies { api fileTree(dir: "libs", include: ["*.aar", "*.jar"], exclude: ["trustwalletcore.aar"]) // Fetch Flow Wallet Kit from GitHub Packages - implementation 'com.github.onflow.flow-wallet-kit:flow-wallet-android:0.2.5' + implementation 'com.github.onflow.flow-wallet-kit:flow-wallet-android:0.2.6' implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" /** Android Architecture **/ implementation 'androidx.core:core-ktx:1.13.1' diff --git a/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt b/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt index 8df9aae9d..f66036b5c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt @@ -68,7 +68,7 @@ fun instabugInitialize(application: Application) { childAccounts.toString() ) report.setUserAttribute("COA", EVMWalletManager.getEVMAddress().orEmpty()) - report.setUserAttribute("EOA", WalletManager.getEOAAddress().orEmpty()) + report.setUserAttribute("EOA", WalletManager.getEOAAddresses().toString()) report.setUserAttribute("Network", chainNetWorkString()) } val configuration = ProactiveReportingConfigs.Builder() diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt index 39355a954..ce3b19200 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt @@ -1,7 +1,9 @@ package com.flowfoundation.wallet.manager.evm +import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.flowjvm.cadenceQueryCOATokenBalance import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.utils.formatPrice import com.flowfoundation.wallet.utils.Env import com.flowfoundation.wallet.utils.ioScope @@ -107,12 +109,12 @@ object DAppEVMConnectionManager { } } - // Load EOA account - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { - logd(TAG, "Found EOA account: $eoaAddress") - // EOA balance is hidden as per requirements - accounts.add(DAppEVMAccount(eoaAddress, DAppEVMAccountType.EOA, null)) + // Load all EOA accounts + val eoaWallets = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + eoaWallets.forEach { eoaWallet -> + logd(TAG, "Found EOA account: ${eoaWallet.address} (index=${eoaWallet.index})") + accounts.add(DAppEVMAccount(eoaWallet.address, DAppEVMAccountType.EOA, null)) } _availableAccounts.value = accounts diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt index e6532c1ee..79ddc0dab 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt @@ -1,6 +1,7 @@ package com.flowfoundation.wallet.manager.evm import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.flowjvm.CadenceScript import com.flowfoundation.wallet.manager.flowjvm.cadenceBridgeChildFTFromCOA import com.flowfoundation.wallet.manager.flowjvm.cadenceBridgeChildFTToCOA @@ -119,7 +120,10 @@ object EVMWalletManager { } fun isEOAAddress(address: String): Boolean { - val result = address.equals(WalletManager.getEOAAddress(), ignoreCase = true) + val result = AccountManager.walletNodes() + ?.filterIsInstance() + ?.any { it.address.equals(address, ignoreCase = true) } + ?: false logd(TAG, "isEOAAddress result: $result") return result } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt index b2770eb7e..c12ee8015 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt @@ -72,7 +72,7 @@ suspend fun loadInitJS(): String { } } - WalletManager.getEOAAddress()?.let { eoaAddress -> + WalletManager.getEOAAddresses().forEach { eoaAddress -> if (eoaAddress.isNotEmpty()) { addressList.add(eoaAddress) } @@ -257,11 +257,13 @@ Unit) { return@ioScope } val singer = cryptoProvider.getSigner(HashingAlgorithm.SHA2_256) + val eoaIndex = WalletManager.getSelectedEOAIndex() val result = WalletManager.wallet()?.ethSignTransactionAndSendByCadence( input = input, fromAddress = address, signers = listOf(singer), - flowAddress = FlowAddress(WalletManager.getCurrentFlowWalletAddress().orEmpty()) + flowAddress = FlowAddress(WalletManager.getCurrentFlowWalletAddress().orEmpty()), + index = eoaIndex ) if (result == null) { logd("EOATransaction", "ERROR: Failed to sign transaction") @@ -275,7 +277,8 @@ Unit) { callback.invoke(txHash) } else { - val output = WalletManager.wallet()?.ethSignTransaction(input) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val output = WalletManager.wallet()?.ethSignTransaction(input, index = eoaIndex) if (output == null) { logd("EOATransaction", "ERROR: Failed to sign transaction") diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt index 985980bed..d6b35eadb 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt @@ -40,7 +40,7 @@ object WalletManager { fun canDeriveEoa() = _canDeriveEoa /** - * Get EOA address + * Get the first EOA address (for backward compatibility / single-address scenarios) */ fun getEOAAddress(): String? { val eoaWallet = AccountManager.walletNodes() @@ -55,6 +55,28 @@ object WalletManager { return null } + /** + * Get all EOA addresses + */ + fun getEOAAddresses(): List { + return AccountManager.walletNodes() + ?.filterIsInstance() + ?.map { it.address } + ?: emptyList() + } + + /** + * Get the BIP44 derivation index for the currently selected EOA wallet. + * Returns 0 if no EOA is selected or found. + */ + fun getSelectedEOAIndex(): Int { + val selectedAddress = selectedWalletAddress() + return AccountManager.walletNodes() + ?.filterIsInstance() + ?.firstOrNull { it.address.equals(selectedAddress, ignoreCase = true) } + ?.index ?: 0 + } + private fun initializeWallet(): Boolean { logd(TAG, "initializeWallet() called") @@ -143,7 +165,9 @@ object WalletManager { } fun isEVMAccountSelected(): Boolean { - return selectedWalletAddress().toAddress().equals(EVMWalletManager.getEVMAddress()?.toAddress(), ignoreCase = true) || selectedWalletAddress().toAddress().equals(getEOAAddress(), ignoreCase = true) + val selected = selectedWalletAddress().toAddress() + return selected.equals(EVMWalletManager.getEVMAddress()?.toAddress(), ignoreCase = true) + || EVMWalletManager.isEOAAddress(selected) } fun isSelfFlowAddress(address: String): Boolean { diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt index 509590c81..509373201 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt @@ -188,7 +188,8 @@ suspend fun WCRequest.evmSignTypedData() { EVMSignTypedDataDialog.observe { isApprove -> ioScope { val result = if (DAppEVMConnectionManager.isCurrentEOAAccount()) { - val data = WalletManager.wallet()?.ethSignTypedData(message) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val data = WalletManager.wallet()?.ethSignTypedData(message, index = eoaIndex) Numeric.toHexString(data) } else { signTypedData(hashData) @@ -290,7 +291,8 @@ private suspend fun WCRequest.evmSignMessage() { val result = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signEthereumMessage(message) } else { - val data = WalletManager.wallet()?.ethSignPersonalMessage(hexMessage.hexToBytes()) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val data = WalletManager.wallet()?.ethSignPersonalMessage(hexMessage.hexToBytes(), index = eoaIndex) Numeric.toHexString(data) } if (isApprove) approve(result) else reject() diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt index 4b86455b9..c1c64025d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt @@ -33,7 +33,9 @@ data class EOAWallet( @SerializedName("name") override val name: String, @SerializedName("emojiId") - override val emojiId: Int + override val emojiId: Int, + @SerializedName("index") + val index: Int = 0 ) : MainWallet() // Linked Wallets (Children / COA) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt index cc9babed0..118ac81ee 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt @@ -272,16 +272,32 @@ object WalletDataManager { // EOA Wallet - canDeriveEoa is determined per-wallet by WalletCreationHelper // based on key type (Secure Enclave / private key = false, seed phrase = true) if (canDeriveEoa) { - val eoa = deriveEoaAddress(wallet) - logd(TAG, "Generated EOA address: $eoa") - if (eoa.isNotEmpty()) { - logd(TAG, "Adding EOA for account: $eoa") - val eoaEmojiInfo = getEmojiInfo(eoa) - nodes.add(EOAWallet( - address = eoa, - name = eoaEmojiInfo.emojiName, - emojiId = eoaEmojiInfo.emojiId - )) + val existingEOAs = account.walletNodes.filterIsInstance() + if (existingEOAs.isNotEmpty()) { + // Preserve all existing EOA wallets with their indices + existingEOAs.forEach { existingEoa -> + val eoaEmojiInfo = getEmojiInfo(existingEoa.address) + nodes.add(EOAWallet( + address = existingEoa.address, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = existingEoa.index + )) + } + logd(TAG, "Preserved ${existingEOAs.size} existing EOA wallets") + } else { + val eoa = deriveEoaAddress(wallet) + logd(TAG, "Generated EOA address: $eoa") + if (eoa.isNotEmpty()) { + logd(TAG, "Adding EOA for account: $eoa") + val eoaEmojiInfo = getEmojiInfo(eoa) + nodes.add(EOAWallet( + address = eoa, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = 0 + )) + } } } @@ -392,15 +408,30 @@ object WalletDataManager { // EOA - canDeriveEoa is determined per-wallet by WalletCreationHelper // based on key type (Secure Enclave / private key = false, seed phrase = true) if (canDeriveEoa) { - val eoa = deriveEoaAddress(wallet) - if (eoa.isNotEmpty()) { - logd(TAG, "Adding EOA for non-current account: $eoa") - val eoaEmojiInfo = getEmojiInfo(eoa) - nodes.add(EOAWallet( - address = eoa, - name = eoaEmojiInfo.emojiName, - emojiId = eoaEmojiInfo.emojiId - )) + val existingEOAs = account.walletNodes.filterIsInstance() + if (existingEOAs.isNotEmpty()) { + existingEOAs.forEach { existingEoa -> + val eoaEmojiInfo = getEmojiInfo(existingEoa.address) + nodes.add(EOAWallet( + address = existingEoa.address, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = existingEoa.index + )) + } + logd(TAG, "Preserved ${existingEOAs.size} existing EOA wallets for non-current account") + } else { + val eoa = deriveEoaAddress(wallet) + if (eoa.isNotEmpty()) { + logd(TAG, "Adding EOA for non-current account: $eoa") + val eoaEmojiInfo = getEmojiInfo(eoa) + nodes.add(EOAWallet( + address = eoa, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = 0 + )) + } } } else { logd(TAG, "Skipping EOA for non-current account (canDeriveEoa=false)") diff --git a/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt b/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt index cd9ffd3bd..8f9a024ea 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt @@ -5,6 +5,7 @@ import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.walletdata.FlowWallet import com.flowfoundation.wallet.network.model.ManualAddressRequest import com.flowfoundation.wallet.utils.logd @@ -91,3 +92,50 @@ suspend fun addNewFlowAccount(): String? { null } } + +/** + * Add a new EOA (Externally Owned Account) wallet by deriving the next BIP44 index. + * This is a purely local operation (no network request needed). + * + * @return the new EOA address, or null on failure + */ +suspend fun addNewEOAAccount(): String? { + return try { + val wallet = WalletManager.wallet() + if (wallet == null) { + loge(TAG, "No wallet available from WalletManager") + return null + } + + val existingEOAs = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + val nextIndex = if (existingEOAs.isEmpty()) 0 else existingEOAs.maxOf { it.index } + 1 + + logd(TAG, "Deriving EOA address at index $nextIndex") + val address = wallet.ethAddress(nextIndex) + if (address.isBlank()) { + loge(TAG, "Derived empty EOA address at index $nextIndex") + return null + } + + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + + val eoaWallet = EOAWallet( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + index = nextIndex + ) + + AccountManager.updateCurrentAccount { account -> + account.copy(walletNodes = account.walletNodes + eoaWallet) + } + + logd(TAG, "New EOAWallet added to account: $address (index=$nextIndex)") + address + } catch (e: Exception) { + loge(TAG, "Failed to add new EOA account: ${e.message}") + e.printStackTrace() + null + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt index fc6172273..4e6adbf9d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt @@ -65,6 +65,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity +import com.flowfoundation.wallet.page.dialog.common.AddNewAccountDialog import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.page.wallet.view.AccountItemSection import com.flowfoundation.wallet.page.profile.subpage.wallet.WalletSettingActivity @@ -107,6 +108,7 @@ fun AccountListScreen( val hiddenAccounts by viewModel.hiddenAccounts.collectAsState() val isAddingAccount by viewModel.isAddingAccount.collectAsState() val canAddAccount by viewModel.canAddAccount.collectAsState() + val canAddEOAAccount by viewModel.canAddEOAAccount.collectAsState() val context = LocalContext.current val activity = remember { getActivityFromContext(context) as FragmentActivity } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current @@ -154,10 +156,19 @@ fun AccountListScreen( } }, actions = { + val canAdd = canAddAccount || canAddEOAAccount IconButton( - onClick = { viewModel.addAccount() }, - enabled = canAddAccount, - modifier = Modifier.alpha(if (canAddAccount) 1f else 0f) + onClick = { + AddNewAccountDialog.show( + activity.supportFragmentManager, + canAddCadence = canAddAccount, + canAddEOA = canAddEOAAccount, + onCadence = { viewModel.addCadenceAccount() }, + onEOA = { viewModel.addEOAAccount() } + ) + }, + enabled = canAdd, + modifier = Modifier.alpha(if (canAdd) 1f else 0f) ) { Icon( imageVector = Icons.Default.Add, diff --git a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt index 3037217c6..92026e075 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt @@ -18,6 +18,8 @@ import com.flowfoundation.wallet.manager.config.AppConfig import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flowfoundation.wallet.network.addNewEOAAccount import com.flowfoundation.wallet.network.addNewFlowAccount import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.page.main.model.WalletAccountData @@ -25,6 +27,7 @@ import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.utils.formatLargeBalanceNumber import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.toast import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -48,6 +51,9 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { private val _canAddAccount = MutableStateFlow(false) val canAddAccount: StateFlow = _canAddAccount.asStateFlow() + private val _canAddEOAAccount = MutableStateFlow(false) + val canAddEOAAccount: StateFlow = _canAddEOAAccount.asStateFlow() + private val service by lazy { retrofitApi().create(ApiService::class.java) } // Cache for verified EVM addresses that should be included in linkedAccounts @@ -162,13 +168,21 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { && cryptoProvider != null && cryptoProvider !is AndroidKeystoreCryptoProvider + val eoaWalletCount = walletNodes.filterIsInstance().size + val canCreate = AppConfig.canCreateNewAccount() + val canDerive = WalletManager.canDeriveEoa() + val eoaUnderLimit = eoaWalletCount < 5 + val isHDProvider = cryptoProvider is HDWalletCryptoProvider + _canAddEOAAccount.value = canCreate && canDerive && eoaUnderLimit && isHDProvider + logd("AccountListViewModel", "canAddEOA: canCreate=$canCreate, canDerive=$canDerive, eoaCount=$eoaWalletCount(<5=$eoaUnderLimit), isHDProvider=$isHDProvider => ${_canAddEOAAccount.value}") + if (refreshBalance) { fetchAllBalances(addressList, pendingEvmAddresses) } } } - fun addAccount() { + fun addCadenceAccount() { ioScope { _isAddingAccount.value = true try { @@ -177,7 +191,24 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { _isAddingAccount.value = false toast(msgRes = R.string.common_error_hint) } else { - // New accounts always have 0 balance; inject immediately for instant UI feedback + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } + + fun addEOAAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewEOAAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { _balanceMap.value += (result to "0 FLOW") refreshWalletList(false) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt new file mode 100644 index 000000000..ebe531428 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt @@ -0,0 +1,166 @@ +package com.flowfoundation.wallet.page.dialog.common + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentManager +import com.flowfoundation.wallet.R +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class AddNewAccountDialog : BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + AddNewAccountContent( + canAddCadence = canAddCadence, + canAddEOA = canAddEOA, + onCadenceClick = { + dismiss() + onCadenceClick?.invoke() + }, + onEOAClick = { + dismiss() + onEOAClick?.invoke() + } + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + onCadenceClick = null + onEOAClick = null + } + + companion object { + private var onCadenceClick: (() -> Unit)? = null + private var onEOAClick: (() -> Unit)? = null + private var canAddCadence: Boolean = true + private var canAddEOA: Boolean = true + + fun show( + fragmentManager: FragmentManager, + canAddCadence: Boolean = true, + canAddEOA: Boolean = true, + onCadence: () -> Unit, + onEOA: () -> Unit + ) { + this.onCadenceClick = onCadence + this.onEOAClick = onEOA + this.canAddCadence = canAddCadence + this.canAddEOA = canAddEOA + AddNewAccountDialog().showNow(fragmentManager, "AddNewAccountDialog") + } + } +} + +@Composable +private fun AddNewAccountContent( + canAddCadence: Boolean, + canAddEOA: Boolean, + onCadenceClick: () -> Unit, + onEOAClick: () -> Unit +) { + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.deep_bg), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(18.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(horizontal = 18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (canAddCadence) 1f else 0.4f) + .clickable(enabled = canAddCadence) { onCadenceClick() } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_cadence_account), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.create_new_cadence_account), + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (canAddEOA) 1f else 0.4f) + .clickable(enabled = canAddEOA) { onEOAClick() } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_eoa_account), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.create_new_eoa_account), + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt index 419fb3ba5..84c3a9fd7 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt @@ -18,11 +18,14 @@ import com.flowfoundation.wallet.manager.config.AppConfig import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flowfoundation.wallet.network.addNewEOAAccount import com.flowfoundation.wallet.network.addNewFlowAccount import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.utils.formatLargeBalanceNumber import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.network.model.UserInfoData import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.manager.account.OnAccountUpdate @@ -54,6 +57,9 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { private val _canAddAccount = MutableStateFlow(false) val canAddAccount: StateFlow = _canAddAccount.asStateFlow() + private val _canAddEOAAccount = MutableStateFlow(false) + val canAddEOAAccount: StateFlow = _canAddEOAAccount.asStateFlow() + private val service by lazy { retrofitApi().create(ApiService::class.java) } // Cache for verified EVM addresses that should be included in linkedAccounts @@ -176,13 +182,21 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { && cryptoProvider != null && cryptoProvider !is AndroidKeystoreCryptoProvider + val eoaWalletCount = walletNodes.filterIsInstance().size + val canCreate = AppConfig.canCreateNewAccount() + val canDerive = WalletManager.canDeriveEoa() + val eoaUnderLimit = eoaWalletCount < 5 + val isHDProvider = cryptoProvider is HDWalletCryptoProvider + _canAddEOAAccount.value = canCreate && canDerive && eoaUnderLimit && isHDProvider + logd("DrawerLayoutViewModel", "canAddEOA: canCreate=$canCreate, canDerive=$canDerive, eoaCount=$eoaWalletCount(<5=$eoaUnderLimit), isHDProvider=$isHDProvider => ${_canAddEOAAccount.value}") + if (refreshBalance) { fetchAllBalances(addressList, pendingEvmAddresses) } } } - fun addAccount() { + fun addCadenceAccount() { ioScope { _isAddingAccount.value = true try { @@ -191,7 +205,24 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { _isAddingAccount.value = false toast(msgRes = R.string.common_error_hint) } else { - // New accounts always have 0 balance; inject immediately for instant UI feedback + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } + + fun addEOAAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewEOAAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { _balanceMap.value += (result to "0 FLOW") refreshWalletList(false) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt index 8c1a70787..3c095d5f7 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt @@ -77,6 +77,7 @@ import com.flowfoundation.wallet.manager.transaction.TransactionStateManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.network.clearWebViewCache import com.flowfoundation.wallet.network.model.UserInfoData +import com.flowfoundation.wallet.page.dialog.common.AddNewAccountDialog import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog import com.flowfoundation.wallet.page.evm.EnableEVMActivity import com.flowfoundation.wallet.page.main.MainActivity @@ -119,6 +120,7 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() val isAddingAccount by viewModel.isAddingAccount.collectAsStateWithLifecycle() val canAddAccount by viewModel.canAddAccount.collectAsStateWithLifecycle() + val canAddEOAAccount by viewModel.canAddEOAAccount.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.loadData() @@ -213,8 +215,16 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { onImportWalletClick = { WalletRestoreActivity.launch(activity) }, - onAddAccountClick = { viewModel.addAccount() }, - canAddAccount = canAddAccount + onAddAccountClick = { + AddNewAccountDialog.show( + activity.supportFragmentManager, + canAddCadence = canAddAccount, + canAddEOA = canAddEOAAccount, + onCadence = { viewModel.addCadenceAccount() }, + onEOA = { viewModel.addEOAAccount() } + ) + }, + canAddAccount = canAddAccount || canAddEOAAccount ) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt deleted file mode 100644 index 875b96ba8..000000000 --- a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import com.flowfoundation.wallet.utils.viewModelIOScope -import com.flow.wallet.crypto.BIP39 -import com.flow.wallet.keys.SeedPhraseKey -import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.flowfoundation.wallet.utils.Env.getStorage -import com.flowfoundation.wallet.wallet.DERIVATION_PATH -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - - -class WalletCreateMnemonicViewModel : ViewModel() { - - val mnemonicList = MutableLiveData>() - private var currentMnemonic: String? = null - - fun loadMnemonic() { - viewModelIOScope(this) { - // Generate a new mnemonic using BIP39 - val mnemonic = BIP39.generate(BIP39.SeedPhraseLength.TWELVE) - currentMnemonic = mnemonic - - withContext(Dispatchers.Main) { - val list = mnemonic.split(" ").mapIndexed { index, s -> MnemonicModel(index + 1, s) } - val result = mutableListOf() - (0 until list.size / 2).forEach { i -> - result.add(list[i]) - result.add(list[i + list.size / 2]) - } - mnemonicList.value = result - } - } - } - - fun getMnemonic(): String { - return currentMnemonic ?: throw IllegalStateException("Mnemonic not generated yet") - } - - @OptIn(ExperimentalStdlibApi::class) - fun createWallet() { - viewModelIOScope(this) { - val mnemonic = getMnemonic() - val seedPhraseKey = SeedPhraseKey( - mnemonicString = mnemonic, - passphrase = "", - derivationPath = DERIVATION_PATH, - storage = getStorage() - ) - - // Create keystore info for the account - val privateKeyHex = seedPhraseKey.privateKey(org.onflow.flow.models.SigningAlgorithm.ECDSA_P256)?.toHexString() ?: "" - val publicKeyHex = seedPhraseKey.publicKey(org.onflow.flow.models.SigningAlgorithm.ECDSA_P256)?.toHexString()?.removePrefix("04") ?: "" - val walletAddress = WalletManager.getCurrentFlowWalletAddress() ?: "" - - // Create keystore address info - val keystoreAddress = com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress( - address = walletAddress, - publicKey = publicKeyHex, - privateKey = privateKeyHex, - keyId = 0, - weight = 1000, - hashAlgo = org.onflow.flow.models.HashingAlgorithm.SHA2_256.cadenceIndex, - signAlgo = org.onflow.flow.models.SigningAlgorithm.ECDSA_P256.cadenceIndex - ) - - // Create user info for local wallet - val userInfo = com.flowfoundation.wallet.network.model.UserInfoData( - nickname = "Local User", - username = "local_${System.currentTimeMillis()}", - avatar = "", - address = walletAddress, - isPrivate = 0, - created = java.time.Instant.now().toString() - ) - - // Create and add account to AccountManager with keystore info - val account = com.flowfoundation.wallet.manager.account.Account( - userInfo = userInfo, - keyStoreInfo = com.google.gson.Gson().toJson(keystoreAddress) - ) - - com.flowfoundation.wallet.manager.account.AccountManager.add(account) - - // Mark as registered immediately after wallet creation (like server registration) - // This allows users to proceed even if they exit backup early - com.flowfoundation.wallet.utils.setRegistered() - } - } -} diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt index e3a5fac2e..a0a8c8237 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt @@ -443,7 +443,13 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp override fun closeRN(id: String?) = uiHandler.closeRN(id) - override fun closeRNWithNFT(id: String?) {} + override fun onUpdateDialogActionPress( + actionType: String?, + actionUrl: String?, + actionText: String? + ) {} + + override fun closeRNWithNFT(id: String?) {} override fun getSignKeyIndex(): Double = accountHandler.getSignKeyIndex() diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt index de8b07f4e..aabe25db5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt @@ -15,6 +15,7 @@ import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.walletdata.FlowWallet import com.flowfoundation.wallet.manager.walletdata.COAWallet import com.flowfoundation.wallet.manager.walletdata.ChildWallet +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.reactnative.bridge.RNBridge import com.flowfoundation.wallet.reactnative.bridge.createEmojiInfo import com.flowfoundation.wallet.reactnative.bridge.isSelectedWalletAddress @@ -210,15 +211,17 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { println("EVM account not available: ${e.message}") } - // Get EOA address only if it's different from EVM address + // Get EOA addresses only if they're different from EVM address // For Secure Type (COA) accounts, EOA and EVM addresses are the same, // so we should only show the EVM account to avoid duplicate "EOA" chip try { - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { - // Only add EOA account if it's different from EVM address - // Secure Type accounts have a prefix field, Recovery Phrase accounts don't - val isSecureTypeAccount = !currentAccount?.prefix.isNullOrBlank() + val isSecureTypeAccount = !currentAccount?.prefix.isNullOrBlank() + val eoaWallets = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + + for (eoaWallet in eoaWallets) { + val eoaAddress = eoaWallet.address + if (eoaAddress.isEmpty()) continue // Only add EOA if: // 1. EOA address is different from EVM address, AND @@ -229,7 +232,7 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { if (isDifferentFromEVM && !isSecureTypeAccount) { val eoaEmojiInfo = createEmojiInfo(eoaAddress) val eoaAccount = RNBridge.WalletAccount( - id = "eoa", + id = "eoa_${eoaAddress}", name = eoaEmojiInfo?.name ?: "EOA Account", address = eoaAddress, parentAddress = mainAddress, @@ -245,8 +248,8 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { } } } catch (e: Exception) { - // EOA account might not be available, continue without it - println("EOA account not available: ${e.message}") + // EOA accounts might not be available, continue without them + println("EOA accounts not available: ${e.message}") } val response = RNBridge.WalletAccountsResponse(accounts = bridgeAccounts) @@ -475,15 +478,20 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { } } - // Add EOA address only for currently selected wallet + // Add EOA addresses only for currently selected wallet val mainAddress = flowWallets.firstOrNull()?.address if (mainAddress != null && isSelectedWalletAddress(mainAddress)) { try { - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { + val eoaWallets = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + + for (eoaWallet in eoaWallets) { + val eoaAddress = eoaWallet.address + if (eoaAddress.isEmpty()) continue + val eoaEmojiInfo = createEmojiInfo(eoaAddress) val eoaAccount = RNBridge.WalletAccount( - id = "eoa", + id = "eoa_${eoaAddress}", name = eoaEmojiInfo?.name ?: "EVM Account (EOA)", address = eoaAddress, parentAddress = mainAddress, @@ -498,7 +506,7 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { bridgeAccounts.add(eoaAccount) } } catch (e: Exception) { - logw(TAG, "createWalletProfileFromAccount() - EOA account not available: ${e.message}") + logw(TAG, "createWalletProfileFromAccount() - EOA accounts not available: ${e.message}") } } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt index 14ee004e8..60fe6f8b6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt @@ -49,7 +49,8 @@ class WalletBridgeHandler(private val reactContext: ReactApplicationContext) { ioScope { try { logd(TAG, "ethSign() called with hexData: $hexData") - val signature = WalletManager.wallet()?.ethSignDigest(hexData?.hexToBytes() ?: throw IllegalArgumentException("hexData is null")) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val signature = WalletManager.wallet()?.ethSignDigest(hexData?.hexToBytes() ?: throw IllegalArgumentException("hexData is null"), index = eoaIndex) if (signature != null && signature.isNotEmpty()) { val result = Numeric.toHexString(signature) logd(TAG, "ethSign() - signature $result") diff --git a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt index 6628d54ac..da1fe7bb0 100644 --- a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt +++ b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt @@ -283,7 +283,8 @@ class EvmInterface( val signature = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signEthereumMessage(signMessage) } else { - val result = WalletManager.wallet()?.ethSignPersonalMessage(data) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val result = WalletManager.wallet()?.ethSignPersonalMessage(data, index = eoaIndex) Numeric.toHexString(result) } webView.sendResult(network, signature, id) @@ -310,7 +311,8 @@ class EvmInterface( val signature = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signTypedData(data) } else { - val result = WalletManager.wallet()?.ethSignTypedData(raw) + val eoaIndex = WalletManager.getSelectedEOAIndex() + val result = WalletManager.wallet()?.ethSignTypedData(raw, index = eoaIndex) Numeric.toHexString(result) } webView.sendResult(network, signature, id) diff --git a/app/src/main/res/drawable/ic_add_cadence_account.xml b/app/src/main/res/drawable/ic_add_cadence_account.xml new file mode 100644 index 000000000..23997f142 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_cadence_account.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_eoa_account.xml b/app/src/main/res/drawable/ic_add_eoa_account.xml new file mode 100644 index 000000000..c94851b50 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_eoa_account.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8be83dc35..02db1dd11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,6 +189,8 @@ Hold to Send The transaction is processing Something went wrong, please try again later. + Create a new Flow Cadence account + Create a new Flow EVM account Pending Success Failed diff --git a/gradle.properties b/gradle.properties index c7b150426..94d3747b2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -64,7 +64,7 @@ android.r8.dexing.parallel=true #android.bundle.enableUncompressedNativeLibs=false #android.experimental.enableArtProfiles=true -vCode=400000465 +vCode=400000531 vName=r3.1.0 # --- GitHub Packages (Flow Wallet Kit) ---