From 835dd98f4ba70092e66273f52926151550111cdd Mon Sep 17 00:00:00 2001 From: Meng Date: Tue, 3 Mar 2026 10:05:51 +0800 Subject: [PATCH 1/4] feat(key-storage): add independent key storage decoupled from Account Co-Authored-By: Claude Sonnet 4.6 --- app/build.gradle | 2 +- .../wallet/manager/account/AccountManager.kt | 64 ++++-- .../manager/key/CryptoProviderManager.kt | 92 +++++++- .../manager/key/storage/KeyStorageManager.kt | 196 ++++++++++++++++++ .../key/storage/KeyStorageMigration.kt | 134 ++++++++++++ .../manager/wallet/WalletCreationHelper.kt | 43 ++++ .../viewmodel/KeyStoreRestoreViewModel.kt | 34 +++ .../reactnative/bridge/NativeFRWBridge.kt | 7 + .../bridge/handlers/AuthBridgeHandler.kt | 3 + 9 files changed, 546 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageManager.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageMigration.kt diff --git a/app/build.gradle b/app/build.gradle index 4437df737..40028dd7a 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.1' + implementation 'com.github.onflow.flow-wallet-kit:flow-wallet-android:0.2.4' 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/manager/account/AccountManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt index f4bf6fc9c..42f9e0331 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt @@ -52,7 +52,10 @@ import com.flowfoundation.wallet.utils.storeWalletPassword import com.flowfoundation.wallet.manager.walletdata.WalletDataManager import com.flowfoundation.wallet.manager.walletdata.MainWallet +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager +import com.flowfoundation.wallet.manager.key.storage.KeyStorageMigration import com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress +import com.flowfoundation.wallet.utils.safeRun import kotlin.text.isNullOrEmpty object AccountManager { @@ -62,7 +65,6 @@ object AccountManager { private val listeners = CopyOnWriteArrayList>() private val listListeners = CopyOnWriteArrayList>() private val userPrefixes = mutableListOf() - private val switchAccounts = mutableListOf() private var currentAccount: Account? = null private var isInitialized = false @@ -80,7 +82,6 @@ object AccountManager { logd(TAG, "Starting AccountManager initialization") accounts.clear() userPrefixes.clear() - switchAccounts.clear() currentAccount = null ioScope { @@ -125,6 +126,9 @@ object AccountManager { } logd(TAG, "AccountManager initialization completed successfully") + // Migrate key material to independent storage now that accounts are loaded. + // This must run inside the ioScope block so accounts list is fully populated. + safeRun { KeyStorageMigration.runMigrationIfNeeded() } // Update Accounts info with WalletDataManager WalletDataManager.updateWalletData() @@ -140,7 +144,6 @@ object AccountManager { // Clear potentially corrupted state accounts.clear() userPrefixes.clear() - switchAccounts.clear() currentAccount = null // Initialization failed - user needs to login/restore @@ -151,29 +154,50 @@ object AccountManager { fun getSwitchAccountList(): List { logd(TAG, "getSwitchAccountList() called") logd(TAG, "Current accounts: $accounts") - logd(TAG, "Current switchAccounts: $switchAccounts") val list = mutableListOf() list.addAll(accounts) - // Collect all FlowWallet addresses for the current network from walletNodes - val currentNetwork = chainNetWorkString() - val addressSet = accounts.flatMap { account -> - account.walletNodes.filterIsInstance() - .filter { it.chainIdString == currentNetwork } - .map { it.address } - }.toSet() + val keyOnlyAccounts = buildLocalKeyAccounts() + logd(TAG, "Key-only accounts (no Account object): $keyOnlyAccounts") - logd(TAG, "Address set from accounts (current network): $addressSet") - - val filteredSwitchAccounts = switchAccounts.filter { it.address !in addressSet } - logd(TAG, "Filtered switch accounts: $filteredSwitchAccounts") - - list.addAll(filteredSwitchAccounts) + list.addAll(keyOnlyAccounts) logd(TAG, "Final list size: ${list.size}") return list } + /** + * Builds a list of [LocalSwitchAccount] entries for UIDs that have key material stored + * in [KeyStorageManager] but no matching [Account] in [accounts] (i.e. the account cache + * was lost while the key survived). + * + * The returned list is computed fresh on each call and is not stored persistently. + */ + private fun buildLocalKeyAccounts(): List { + // Collect all UIDs known to the accounts list + val knownUids = accounts.mapNotNull { it.wallet?.id }.toSet() + + // Union of all UIDs present in any of the three key stores + val allKeyUids = ( + KeyStorageManager.getAllSeedPhraseUids() + + KeyStorageManager.getAllPrivateKeyUids() + + KeyStorageManager.getAllAndroidKeystoreUids() + ).toSet() + + // Only keep UIDs that have a key but no account + val orphanUids = allKeyUids - knownUids + + return orphanUids.map { uid -> + val akPrefix = KeyStorageManager.getAndroidKeystorePrefix(uid) + LocalSwitchAccount( + username = uid, + address = "", + userId = uid, + prefix = akPrefix + ) + } + } + fun add(account: Account, uid: String? = null) { // Clear WalletManager state before setting new account WalletManager.clear() @@ -191,6 +215,8 @@ object AccountManager { userPrefixes.removeAll { it.userId == uid} userPrefixes.add(UserPrefix(uid, prefix)) UserPrefixCacheManager.cache(UserPrefixes().apply { addAll(userPrefixes) }) + // Write to independent key storage so prefix survives account-cache loss + KeyStorageManager.saveAndroidKeystorePrefix(uid, prefix) } AccountEmojiManager.init() @@ -488,7 +514,9 @@ object AccountManager { switchAccount(switchAccount) { isSuccess -> if (isSuccess) { isSwitching = false - switchAccounts.remove(switchAccount) + // localKeyAccounts is computed dynamically from KeyStorageManager; once the + // account is re-added via the login flow the UID will appear in accounts and + // will be excluded from buildLocalKeyAccounts() automatically. uiScope { clearUserCache() MainActivity.relaunch(Env.getApp(), true) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/key/CryptoProviderManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/key/CryptoProviderManager.kt index d5bd195a1..4d9d15f25 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/key/CryptoProviderManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/key/CryptoProviderManager.kt @@ -28,9 +28,11 @@ import com.flowfoundation.wallet.utils.readWalletPassword import com.flow.wallet.storage.StorageProtocol import com.flowfoundation.wallet.manager.account.HardwareBackedKeyException import com.flowfoundation.wallet.manager.account.firstFlowWalletAddress +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.wallet.DERIVATION_PATH import com.flowfoundation.wallet.wallet.Wallet +import com.flow.wallet.keys.PrivateKey import org.onflow.flow.models.toHexString object CryptoProviderManager { @@ -101,6 +103,25 @@ object CryptoProviderManager { logd(TAG, "generateAccountCryptoProvider: Generating for account: ${account.userInfo.username}, isActive: ${account.isActive}, hasKeystore: ${!account.keyStoreInfo.isNullOrBlank()}") return try { + // --- New independent key storage (checked first) --- + val uid = account.wallet?.id + if (!uid.isNullOrBlank()) { + // SeedPhraseKey object retrieved directly – no intermediate string reconstruction + val seedPhraseKey = KeyStorageManager.getSeedPhraseKey(uid) + if (seedPhraseKey != null) { + logd(TAG, "New storage: found seed phrase key for uid: $uid") + return HDWalletCryptoProvider(seedPhraseKey) + } + // PrivateKey object retrieved directly – no intermediate string reconstruction + val privateKey = KeyStorageManager.getPrivateKeyObject(uid) + if (privateKey != null) { + logd(TAG, "New storage: found private key object for uid: $uid") + val keyWallet = WalletFactory.createKeyWallet(privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) as KeyWallet + return PrivateKeyCryptoProvider(privateKey, keyWallet) + } + // AKP prefix from new storage falls through to prefix-based branch below + } + // Handle keystore-based accounts if (!account.keyStoreInfo.isNullOrBlank()) { logd(TAG, " Branch: Keystore-based account. Info (first 100 chars): ${account.keyStoreInfo!!.take(100)}") @@ -125,15 +146,24 @@ object CryptoProviderManager { return HDWalletCryptoProvider(seedPhraseKey) } // Handle prefix-based accounts (legacy or hardware-backed) - else if (!account.prefix.isNullOrBlank()) { - logd(TAG, " Branch: Prefix-based account") + // Also check new AKP storage when account.prefix is absent + else if (!account.prefix.isNullOrBlank() || (!uid.isNullOrBlank() && KeyStorageManager.hasAndroidKeystorePrefix(uid))) { + val effectivePrefix = account.prefix?.takeIf { it.isNotBlank() } + ?: uid?.let { KeyStorageManager.getAndroidKeystorePrefix(it) } + + logd(TAG, " Branch: Prefix-based account (effectivePrefix from ${if (account.prefix.isNullOrBlank()) "new storage" else "account"})") + + if (effectivePrefix.isNullOrBlank()) { + loge(TAG, " Prefix-based: resolved prefix is blank, skipping") + return null + } // Standard prefix-based account handling (for non-multi-restore accounts) logd(TAG, " Standard prefix-based account handling") // Try to get the private key, handling hardware-backed keys val privateKey = try { - KeyCompatibilityManager.getPrivateKeyWithFallback(account.prefix!!, storage) + KeyCompatibilityManager.getPrivateKeyWithFallback(effectivePrefix, storage) } catch (e: HardwareBackedKeyException) { loge(TAG, "Hardware-backed key detected") return AndroidKeystoreCryptoProvider(e.prefix!!) @@ -189,7 +219,7 @@ object CryptoProviderManager { // Create the provider with the correct algorithms return PrivateKeyCryptoProvider(privateKey, wallet, determinedSigningAlgorithm, matchedKey.hashingAlgorithm) } else { - logd(TAG, " Prefix-based: Could NOT find matching on-chain key for ${account.prefix}. Using default signing algorithm: $determinedSigningAlgorithm") + logd(TAG, " Prefix-based: Could NOT find matching on-chain key for $effectivePrefix. Using default signing algorithm: $determinedSigningAlgorithm") } } else { logd(TAG, " Prefix-based: No account address available for on-chain key lookup") @@ -234,6 +264,22 @@ object CryptoProviderManager { val storage = getStorage() return try { + // --- New independent key storage (checked first) --- + val switchUid = account.wallet?.id + if (!switchUid.isNullOrBlank()) { + val seedPhraseKey = KeyStorageManager.getSeedPhraseKey(switchUid) + if (seedPhraseKey != null) { + logd("CryptoProviderManager", "Switch account new storage: seed phrase key for uid: $switchUid") + return HDWalletCryptoProvider(seedPhraseKey) + } + val privateKey = KeyStorageManager.getPrivateKeyObject(switchUid) + if (privateKey != null) { + logd("CryptoProviderManager", "Switch account new storage: private key object for uid: $switchUid") + val keyWallet = WalletFactory.createKeyWallet(privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) as KeyWallet + return PrivateKeyCryptoProvider(privateKey, keyWallet) + } + } + // Handle keystore-based accounts if (account.keyStoreInfo.isNullOrBlank().not()) { PrivateKeyStoreCryptoProvider(account.keyStoreInfo!!) @@ -256,10 +302,17 @@ object CryptoProviderManager { } // Handle prefix-based accounts (legacy or hardware-backed) - else if (account.prefix.isNullOrBlank().not()) { + // Also check new AKP storage when account.prefix is absent + else if (!account.prefix.isNullOrBlank() || (!switchUid.isNullOrBlank() && KeyStorageManager.hasAndroidKeystorePrefix(switchUid))) { + val switchEffectivePrefix = account.prefix?.takeIf { it.isNotBlank() } + ?: switchUid?.let { KeyStorageManager.getAndroidKeystorePrefix(it) } + if (switchEffectivePrefix.isNullOrBlank()) { + loge("CryptoProviderManager", "Switch account: resolved prefix is blank") + return null + } // Load the stored private key using the prefix-based ID with backward compatibility val privateKey = try { - KeyCompatibilityManager.getPrivateKeyWithFallback(account.prefix!!, storage) + KeyCompatibilityManager.getPrivateKeyWithFallback(switchEffectivePrefix, storage) } catch (e: HardwareBackedKeyException) { loge("CryptoProviderManager", "Hardware-backed key detected for switch account") loge("CryptoProviderManager", "Creating AndroidKeystoreCryptoProvider for hardware-backed key") @@ -362,13 +415,32 @@ object CryptoProviderManager { val storage = getStorage() return try { + // --- New independent key storage (checked first for LocalSwitchAccount) --- + val localUid = switchAccount.userId + if (!localUid.isNullOrBlank()) { + val seedPhraseKey = KeyStorageManager.getSeedPhraseKey(localUid) + if (seedPhraseKey != null) { + logd("CryptoProviderManager", "LocalSwitchAccount new storage: seed phrase key for uid: $localUid") + return HDWalletCryptoProvider(seedPhraseKey) + } + val privateKey = KeyStorageManager.getPrivateKeyObject(localUid) + if (privateKey != null) { + logd("CryptoProviderManager", "LocalSwitchAccount new storage: private key object for uid: $localUid") + val keyWallet = WalletFactory.createKeyWallet(privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) as KeyWallet + return PrivateKeyCryptoProvider(privateKey, keyWallet) + } + } + // Handle prefix-based accounts - if (switchAccount.prefix.isNullOrBlank().not()) { + // Also check new AKP storage when switchAccount.prefix is absent + val localEffectivePrefix = switchAccount.prefix?.takeIf { it.isNotBlank() } + ?: localUid?.let { KeyStorageManager.getAndroidKeystorePrefix(it) } + if (!localEffectivePrefix.isNullOrBlank()) { // Load the stored private key using the prefix-based ID with backward compatibility val privateKey = try { - KeyCompatibilityManager.getPrivateKeyWithFallback(switchAccount.prefix, storage) + KeyCompatibilityManager.getPrivateKeyWithFallback(localEffectivePrefix, storage) } catch (e: HardwareBackedKeyException) { - loge("CryptoProviderManager", "Hardware-backed key detected for local switch account prefix ${switchAccount.prefix}") + loge("CryptoProviderManager", "Hardware-backed key detected for local switch account prefix $localEffectivePrefix") loge("CryptoProviderManager", "Creating AndroidKeystoreCryptoProvider for hardware-backed key") // For LocalSwitchAccount, we use defaults since we don't have wallet address @@ -376,7 +448,7 @@ object CryptoProviderManager { } if (privateKey == null) { - loge("CryptoProviderManager", "CRITICAL ERROR: Failed to load stored private key for local switch account prefix ${switchAccount.prefix} from both new and old storage") + loge("CryptoProviderManager", "CRITICAL ERROR: Failed to load stored private key for local switch account prefix $localEffectivePrefix from both new and old storage") loge("CryptoProviderManager", "Cannot proceed without the stored key as it would create a different account") return null } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageManager.kt new file mode 100644 index 000000000..1a9de3e94 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageManager.kt @@ -0,0 +1,196 @@ +package com.flowfoundation.wallet.manager.key.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.flow.wallet.keys.CryptoProviderKey +import com.flow.wallet.keys.PrivateKey +import com.flow.wallet.keys.SeedPhraseKey +import com.flow.wallet.storage.FileSystemStorage +import com.flow.wallet.storage.StorageProtocol +import com.flowfoundation.wallet.utils.Env +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.loge +import com.flowfoundation.wallet.wallet.DERIVATION_PATH +import kotlinx.coroutines.runBlocking +import org.onflow.flow.models.bytesToHex +import org.onflow.flow.models.hexToBytes +import java.io.File +import java.security.SecureRandom +import androidx.core.content.edit + +/** + * Independent key storage manager that decouples key material from the Account object. + * + * Mirrors the original three-bucket layout but uses [FileSystemStorage] directories + * instead of SharedPreferences files, and delegates encryption to FlowWalletKit primitives. + * + * Physical storage layout: + * filesDir/frw_sp_storage/{uid} – SeedPhraseKey JSON encrypted with ChaCha20 + * filesDir/frw_pk_storage/{uid} – raw PrivateKey bytes encrypted with ChaCha20 + * filesDir/frw_akp_storage/{uid} – Android Keystore prefix string encrypted with ChaCha20 + * + * Each key type lives in its own dedicated directory (one-type-per-directory), exactly + * matching the old SharedPreferences-per-type separation: + * frw_sp_storage.xml → filesDir/frw_sp_storage/ + * frw_pk_storage.xml → filesDir/frw_pk_storage/ + * frw_akp_storage.xml → filesDir/frw_akp_storage/ + * + * The ChaCha20 password is a random hex string generated once and stored in + * [EncryptedSharedPreferences] under `frw_key_master_password` (hardware-backed AES-256-GCM). + */ +object KeyStorageManager { + + private const val TAG = "KeyStorageManager" + + // Storage directory names – intentionally match the old SharedPreferences names + private const val SP_STORAGE_NAME = "frw_sp_storage" + private const val PK_STORAGE_NAME = "frw_pk_storage" + private const val AKP_STORAGE_NAME = "frw_akp_storage" + + private const val MASTER_PASSWORD_PREFS = "frw_key_master_password" + private const val MASTER_PASSWORD_KEY = "password" + + // ─── Independent per-type StorageProtocol instances ────────────────────── + + /** One FileSystemStorage per key type – mirrors the old one-SharedPreferences-per-type design. */ + private val spStorage: StorageProtocol by lazy { + FileSystemStorage(File(Env.getApp().filesDir, SP_STORAGE_NAME)) + } + private val pkStorage: StorageProtocol by lazy { + FileSystemStorage(File(Env.getApp().filesDir, PK_STORAGE_NAME)) + } + private val akpStorage: StorageProtocol by lazy { + FileSystemStorage(File(Env.getApp().filesDir, AKP_STORAGE_NAME)) + } + + // ─── Master password (EncryptedSharedPreferences) ──────────────────────── + + private val masterPasswordPrefs: SharedPreferences by lazy { + createEncryptedPreference(MASTER_PASSWORD_PREFS) + } + + private fun createEncryptedPreference(name: String): SharedPreferences = try { + EncryptedSharedPreferences.create( + Env.getApp(), + name, + MasterKey.Builder(Env.getApp()) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + logd(TAG, "EncryptedSharedPreferences creation failed, using fallback: ${e.message}") + Env.getApp().getSharedPreferences("${name}_backup", Context.MODE_PRIVATE) + } + + /** Returns the persistent master password, generating it once on first call. */ + @Synchronized + fun getOrCreateMasterPassword(): String { + var password = masterPasswordPrefs.getString(MASTER_PASSWORD_KEY, null) + if (password.isNullOrBlank()) { + password = ByteArray(32).also { SecureRandom().nextBytes(it) }.bytesToHex() + masterPasswordPrefs.edit { putString(MASTER_PASSWORD_KEY, password) } + logd(TAG, "Generated new master password") + } + return password + } + + private fun password() = getOrCreateMasterPassword() + + // ─── Seed Phrase (filesDir/frw_sp_storage/{uid}) ──────────────────────── + + /** + * Encrypts and stores [mnemonic] in the dedicated seed-phrase directory. + * The stored entry also includes derivation path and passphrase for full reconstruction. + */ + fun saveSeedPhrase(uid: String, mnemonic: String) { + try { + val key = SeedPhraseKey( + mnemonicString = mnemonic, + passphrase = "", + derivationPath = DERIVATION_PATH, + storage = spStorage + ) + runBlocking { key.store(uid, password()) } + logd(TAG, "Saved seed phrase for uid: $uid") + } catch (e: Exception) { + loge(TAG, "Failed to save seed phrase for uid $uid: ${e.message}") + } + } + + /** + * Returns a fully initialised [SeedPhraseKey] ready for signing / wallet creation. + * Returns null if nothing was stored for [uid] or if decryption fails. + */ + fun getSeedPhraseKey(uid: String): SeedPhraseKey? = + SeedPhraseKey.load(uid, password(), spStorage) + + /** Convenience: returns the raw mnemonic string. Prefer [getSeedPhraseKey] where possible. */ + fun getSeedPhrase(uid: String): String? = + getSeedPhraseKey(uid)?.mnemonic?.joinToString(" ") + + fun hasSeedPhrase(uid: String): Boolean = spStorage.get(uid) != null + + /** Returns all UIDs that have a stored seed phrase. */ + fun getAllSeedPhraseUids(): List = spStorage.allKeys + + // ─── Private Key (filesDir/frw_pk_storage/{uid}) ──────────────────────── + + /** + * Encrypts and stores the private key in the dedicated private-key directory. + * [privateKeyHex] must be a 64-char lowercase hex string (32 bytes, no "0x" prefix). + */ + fun savePrivateKey(uid: String, privateKeyHex: String) { + try { + val key = PrivateKey.restore(privateKeyHex.hexToBytes(), pkStorage) + runBlocking { key.store(uid, password()) } + logd(TAG, "Saved private key for uid: $uid") + } catch (e: Exception) { + loge(TAG, "Failed to save private key for uid $uid: ${e.message}") + } + } + + /** + * Returns a fully initialised [PrivateKey] object ready for wallet creation. + * Returns null if nothing was stored for [uid] or if decryption fails. + */ + fun getPrivateKeyObject(uid: String): PrivateKey? = try { + PrivateKey.get(uid, password(), pkStorage) + } catch (e: Exception) { null } + + /** Convenience: returns the raw private key as a lowercase hex string. */ + fun getPrivateKey(uid: String): String? = + getPrivateKeyObject(uid)?.secret?.bytesToHex() + + fun hasPrivateKey(uid: String): Boolean = pkStorage.get(uid) != null + + /** Returns all UIDs that have a stored private key. */ + fun getAllPrivateKeyUids(): List = pkStorage.allKeys + + // ─── Android Keystore Prefix (filesDir/frw_akp_storage/{uid}) ─────────── + + /** + * Encrypts and stores the Android Keystore [prefix] string in the dedicated AKP directory. + * The provider is reconstructed from the prefix at the app layer when the wallet is created. + */ + fun saveAndroidKeystorePrefix(uid: String, prefix: String) { + try { + CryptoProviderKey.saveProviderIdentifier(uid, prefix, password(), akpStorage) + logd(TAG, "Saved Android Keystore prefix for uid: $uid") + } catch (e: Exception) { + loge(TAG, "Failed to save AK prefix for uid $uid: ${e.message}") + } + } + + fun getAndroidKeystorePrefix(uid: String): String? = + CryptoProviderKey.getProviderIdentifier(uid, password(), akpStorage) + + fun hasAndroidKeystorePrefix(uid: String): Boolean = + CryptoProviderKey.hasProviderIdentifier(uid, akpStorage) + + /** Returns all UIDs that have a stored Android Keystore prefix. */ + fun getAllAndroidKeystoreUids(): List = akpStorage.allKeys +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageMigration.kt b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageMigration.kt new file mode 100644 index 000000000..70ae570ee --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageMigration.kt @@ -0,0 +1,134 @@ +package com.flowfoundation.wallet.manager.key.storage + +import com.flowfoundation.wallet.manager.account.Account +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.AccountWalletManager +import com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.loge +import com.flowfoundation.wallet.utils.secret.EncryptedMnemonicUtils +import com.google.gson.Gson + +/** + * One-time, idempotent migration that copies key material from Account-bound storage + * into the independent [KeyStorageManager] stores. + * + * This is safe to call on every app start because each account is only written once + * (we skip accounts whose keys are already present in the new stores). + * + * Migration rules (per account, uid = account.wallet?.id): + * 1. keyStoreInfo != null AND encryptedMnemonic != null + * → decrypt mnemonic and write to SP store + * 2. keyStoreInfo != null AND privateKey != null (no encrypted mnemonic) + * → write raw private key hex to PK store + * 3. prefix != null (Android Keystore / legacy prefix key) + * → write prefix to AKP store + * 4. None of the above (plain mnemonic account) + * → fetch mnemonic from AccountWalletManager and write to SP store + */ +object KeyStorageMigration { + + private const val TAG = "KeyStorageMigration" + + fun runMigrationIfNeeded() { + try { + val accounts = AccountManager.list() + if (accounts.isEmpty()) { + logd(TAG, "No accounts found, skipping migration") + return + } + logd(TAG, "Starting key storage migration for ${accounts.size} account(s)") + for (account in accounts) { + val uid = account.wallet?.id + if (uid.isNullOrBlank()) { + logd(TAG, "Skipping account ${account.userInfo.username}: no wallet ID") + continue + } + try { + migrateAccount(uid, account) + } catch (e: Exception) { + loge(TAG, "Failed to migrate account $uid: ${e.message}") + } + } + logd(TAG, "Key storage migration completed") + } catch (e: Exception) { + loge(TAG, "Migration failed: ${e.message}") + } + } + + private fun migrateAccount(uid: String, account: Account) { + val keyStoreInfo = account.keyStoreInfo + val prefix = account.prefix + + when { + // Case 1 & 2: account has keyStoreInfo + !keyStoreInfo.isNullOrBlank() -> migrateFromKeyStoreInfo(uid, keyStoreInfo) + + // Case 3: prefix-based Android Keystore / legacy key + !prefix.isNullOrBlank() -> { + if (!KeyStorageManager.hasAndroidKeystorePrefix(uid)) { + KeyStorageManager.saveAndroidKeystorePrefix(uid, prefix) + logd(TAG, "Migrated prefix → AKP for uid: $uid") + } else { + logd(TAG, "AKP already exists for uid: $uid, skipping") + } + } + + // Case 4: plain HD-wallet mnemonic account + else -> migrateFromAccountWalletManager(uid) + } + } + + private fun migrateFromKeyStoreInfo(uid: String, keyStoreInfo: String) { + val ks = try { + Gson().fromJson(keyStoreInfo, KeystoreAddress::class.java) + } catch (e: Exception) { + loge(TAG, "Failed to parse keyStoreInfo for uid $uid: ${e.message}") + return + } + + when { + // encryptedMnemonic present → seed-phrase restore + !ks.encryptedMnemonic.isNullOrBlank() -> { + if (!KeyStorageManager.hasSeedPhrase(uid)) { + val mnemonic = EncryptedMnemonicUtils.decrypt(ks.encryptedMnemonic, uid) + if (!mnemonic.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + logd(TAG, "Migrated encryptedMnemonic → SP for uid: $uid") + } else { + loge(TAG, "Failed to decrypt mnemonic for uid: $uid") + } + } else { + logd(TAG, "SP already exists for uid: $uid, skipping") + } + } + + // privateKey present → private-key import + !ks.privateKey.isNullOrBlank() -> { + if (!KeyStorageManager.hasPrivateKey(uid)) { + val hex = ks.privateKey.removePrefix("0x") + KeyStorageManager.savePrivateKey(uid, hex) + logd(TAG, "Migrated privateKey → PK for uid: $uid") + } else { + logd(TAG, "PK already exists for uid: $uid, skipping") + } + } + + else -> logd(TAG, "keyStoreInfo for uid $uid has neither encryptedMnemonic nor privateKey") + } + } + + private fun migrateFromAccountWalletManager(uid: String) { + if (!KeyStorageManager.hasSeedPhrase(uid)) { + val mnemonic = AccountWalletManager.getHDWalletMnemonicByUID(uid) + if (!mnemonic.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + logd(TAG, "Migrated AccountWalletManager mnemonic → SP for uid: $uid") + } else { + logd(TAG, "No mnemonic found in AccountWalletManager for uid: $uid") + } + } else { + logd(TAG, "SP already exists for uid: $uid, skipping") + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt index 3dce67eab..de4eaec10 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt @@ -13,6 +13,7 @@ import com.flowfoundation.wallet.manager.account.AccountWalletManager import com.flowfoundation.wallet.manager.account.HardwareBackedKeyException import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider import com.flowfoundation.wallet.manager.key.KeyCompatibilityManager +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress @@ -47,6 +48,28 @@ object WalletCreationHelper { logd(TAG, "Creating wallet from account: ${account.userInfo.username}") val userId = account.wallet?.id + // --- New independent key storage (checked first, avoids Account dependency) --- + if (!userId.isNullOrBlank()) { + // SeedPhraseKey object retrieved directly → no intermediate string reconstruction + val seedPhraseKey = KeyStorageManager.getSeedPhraseKey(userId) + if (seedPhraseKey != null) { + logd(TAG, "New storage: seed phrase key found for uid: $userId") + return createWalletFromSeedPhraseKey(seedPhraseKey, isCurrentAccount) + } + // PrivateKey object retrieved directly → no intermediate string reconstruction + val privateKey = KeyStorageManager.getPrivateKeyObject(userId) + if (privateKey != null) { + logd(TAG, "New storage: private key object found for uid: $userId") + return createWalletFromPrivateKey(privateKey, isCurrentAccount) + } + // Android Keystore prefix → reconstruct provider at app layer + val akPrefix = KeyStorageManager.getAndroidKeystorePrefix(userId) + if (!akPrefix.isNullOrBlank()) { + logd(TAG, "New storage: AK prefix found for uid: $userId") + return createWalletFromPrefix(akPrefix, isCurrentAccount) + } + } + // Create wallet based on account's key information only val wallet = when { // Handle keystore-based accounts @@ -219,6 +242,26 @@ object WalletCreationHelper { } } + /** + * Create wallet from a [SeedPhraseKey] object loaded directly from key storage. + * Avoids reconstructing the key from a mnemonic string. + */ + private fun createWalletFromSeedPhraseKey(seedPhraseKey: SeedPhraseKey, isCurrentAccount: Boolean): Wallet { + val storage = getStorage() + if (isCurrentAccount) WalletManager.setEoaDisabled(false) + return WalletFactory.createKeyWallet(seedPhraseKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) + } + + /** + * Create wallet from a [PrivateKey] object loaded directly from key storage. + * Cannot derive EOA addresses (no mnemonic available). + */ + private fun createWalletFromPrivateKey(privateKey: PrivateKey, isCurrentAccount: Boolean): Wallet { + val storage = getStorage() + if (isCurrentAccount) WalletManager.setEoaDisabled(true) + return WalletFactory.createKeyWallet(privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) + } + /** * Create wallet from keystore private key * Private key imports cannot derive EOA addresses (no mnemonic available). diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt index 46094e18f..b328a8743 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt @@ -51,6 +51,7 @@ import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.containsFlowWalletAddress import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.key.CryptoProviderManager +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.utils.secret.EncryptedMnemonicUtils import com.flowfoundation.wallet.utils.RandomUsernameGenerator import wallet.core.jni.StoredKey @@ -817,6 +818,8 @@ class KeyStoreRestoreViewModel : ViewModel() { wallet = walletData ) ) + // Persist key material in independent storage + saveKeyToNewStorage(userId, keyStoreInfo) logd("KeyStoreRestoreViewModel", "Account added successfully") logd("KeyStoreRestoreViewModel", "Import process completed successfully") callback.invoke(true) @@ -981,6 +984,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) clearUserCache() AccountManager.add(userAccount) + // Persist key material in independent storage + saveKeyToNewStorage(userId, Gson().toJson(keystoreAddress)) MixpanelManager.accountRestore(finalWalletAddress, restoreType) accountSuccessfullyAdded = true logd("KeyStoreRestoreViewModel", "Post-login process completed successfully.") @@ -1121,6 +1126,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(userId, keyStoreInfo) MixpanelManager.accountRestore( cryptoProvider.getAddress(), restoreType @@ -1327,6 +1334,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(firebaseUid().orEmpty(), keyStoreInfo) MixpanelManager.accountCreated( cryptoProvider.getPublicKey(), AccountCreateKeyType.RESTORE_KEYSTORE, @@ -1363,6 +1372,31 @@ class KeyStoreRestoreViewModel : ViewModel() { } } + /** + * Write key material to the independent [KeyStorageManager] store. + * Prefers the in-memory mnemonic (seed-phrase restore path); falls back to + * the private key stored in keyStoreInfo (private-key import path). + */ + private fun saveKeyToNewStorage(uid: String, keyStoreInfo: String?) { + if (uid.isBlank()) return + val mnemonic = currentMnemonic + if (!mnemonic.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + return + } + if (!keyStoreInfo.isNullOrBlank()) { + try { + val ks = Gson().fromJson(keyStoreInfo, KeystoreAddress::class.java) + val hex = ks?.privateKey?.removePrefix("0x") + if (!hex.isNullOrBlank()) { + KeyStorageManager.savePrivateKey(uid, hex) + } + } catch (e: Exception) { + logd("KeyStoreRestoreViewModel", "saveKeyToNewStorage: failed to parse keyStoreInfo: ${e.message}") + } + } + } + private fun encryptedMnemonic(uid: String?): String? { val mnemonic = currentMnemonic ?: return null return if (!uid.isNullOrBlank()) { 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 d4df963f0..15de54455 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 @@ -51,6 +51,7 @@ import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.generatePrefix import org.onflow.flow.infrastructure.Cadence.Companion.uint8 import com.facebook.react.modules.core.DeviceEventManagerModule +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.reactnative.bridge.handlers.AccountBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.AuthBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.UIBridgeHandler @@ -159,6 +160,12 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // Update and store the mnemonic in Wallet Wallet.store().updateMnemonic(seedPhrase).store() + // Also persist in independent key storage so it survives account-cache loss + val uid = firebaseUid() ?: AccountManager.get()?.wallet?.id + if (!uid.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, seedPhrase) + } + logd(TAG, "saveNewKey() - Seed phrase saved successfully") uiScope { promise.resolve(null) diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt index d8dc69408..209c16df6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt @@ -16,6 +16,7 @@ import com.flowfoundation.wallet.manager.account.firstFlowWalletAddress 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.key.storage.KeyStorageManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.walletdata.FlowWallet @@ -567,6 +568,8 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { throw IllegalStateException("Failed to store mnemonic for userId: $userId") } logd(TAG, "saveMnemonic() - Mnemonic stored successfully via AccountWalletManager") + // Also persist in independent key storage so it survives account-cache loss + KeyStorageManager.saveSeedPhrase(userId, mnemonic) // Account discovery is now handled by React Native layer // React Native will handle wallet initialization after Flow address is created From 39fd96f77cc51416be859c64f3605f7db60af2c0 Mon Sep 17 00:00:00 2001 From: Meng Date: Sat, 28 Feb 2026 14:37:05 +0800 Subject: [PATCH 2/4] fix: onboarding issues --- .../manager/wallet/WalletCreationHelper.kt | 14 +- .../wallet/manager/wallet/WalletManager.kt | 13 +- .../manager/walletdata/WalletDataManager.kt | 157 ++++++------ .../wallet/network/UserRegisterUtils.kt | 31 +-- .../wallet/network/model/UserInfoResponse.kt | 10 +- .../network/model/WalletListResponse.kt | 8 +- .../page/account/AccountListViewModel.kt | 232 ++++++------------ .../wallet/page/deeplink/DappPromptDialog.kt | 10 +- .../page/main/drawer/DrawerLayoutViewModel.kt | 8 +- .../fragment/RestoreStartFragment.kt | 13 +- .../token/list/CadenceTokenListProvider.kt | 22 +- .../page/token/list/EVMTokenListProvider.kt | 51 ++-- .../wallet/reactnative/ReactNativeActivity.kt | 1 + .../wallet/reactnative/bridge/BridgeModels.kt | 93 +++---- .../reactnative/bridge/NativeFRWBridge.kt | 3 +- .../bridge/handlers/AuthBridgeHandler.kt | 37 +-- .../bridge/handlers/UtilsBridgeHandler.kt | 5 + .../webview/fcl/FclWebViewExtensions.kt | 2 +- gradle.properties | 4 +- 19 files changed, 324 insertions(+), 390 deletions(-) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt index de4eaec10..91da23455 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletCreationHelper.kt @@ -36,7 +36,7 @@ object WalletCreationHelper { /** * Create Wallet object from Account using only key-related information * This method focuses solely on cryptographic key data and ignores wallet/address info - * + * * Account types and their key storage: * 1. Keystore-based: Has keyStoreInfo (may have encrypted mnemonic or private key) * 2. Prefix-based (legacy/hardware): Has prefix for hardware-backed or legacy keys @@ -164,17 +164,17 @@ object WalletCreationHelper { /** * Create wallet from prefix-based key - * + * * This handles: * - Secure Enclave (hardware-backed) keys: EOA is disabled since we can't derive EOA from hardware keys * - Legacy prefix-based keys: EOA is disabled (no mnemonic available) - * + * * Note: Mnemonic-only accounts (cleaner architecture) are handled separately by createWalletFromHDMnemonic * and should not reach this function. */ private fun createWalletFromPrefix(prefix: String, isCurrentAccount: Boolean): Wallet? { val storage = getStorage() - + return try { val privateKey = KeyCompatibilityManager.getPrivateKeyWithFallback(prefix, storage) if (privateKey != null) { @@ -198,15 +198,15 @@ object WalletCreationHelper { if (isCurrentAccount) { WalletManager.setEoaDisabled(true) } - if (e.alias != null) { - val provider = AndroidKeystoreCryptoProvider(e.alias, SigningAlgorithm.ECDSA_P256, null) + if (e.prefix != null) { + val provider = AndroidKeystoreCryptoProvider(e.prefix) WalletFactory.createProxyWallet( provider, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) } else { - logd(TAG, "Hardware-backed key alias is null") + logd(TAG, "Hardware-backed key prefix is null") null } } 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 13fa200d4..903280d89 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 @@ -392,11 +392,20 @@ object WalletManager { } // Fallback to the first MainWallet address if the preferred address is not found - val defaultAddress = walletNodes?.firstOrNull()?.address.orEmpty() + // Prioritize FlowWallet matching current network, then any FlowWallet, then EOAWallet + val currentNetwork = chainNetWorkString() + val networkFlowWallet = walletNodes?.filterIsInstance() + ?.firstOrNull { it.chainIdString.equals(currentNetwork, ignoreCase = true) } + ?.address + + val defaultAddress = networkFlowWallet + ?: walletNodes?.firstOrNull { it is FlowWallet }?.address + ?: walletNodes?.firstOrNull()?.address.orEmpty() + if (defaultAddress.isNotBlank()) { selectedWalletAddressRef.set(defaultAddress) updateSelectedWalletAddress(defaultAddress) - logd(TAG, "Selected address not found in walletNodes. Falling back to default: $defaultAddress") + logd(TAG, "Selected address not found in walletNodes. Falling back to default (Network/Flow priority): $defaultAddress") return defaultAddress } 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 6412c58ae..5242527e2 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 @@ -232,13 +232,30 @@ object WalletDataManager { /** * Update data for the current account using provided Wallet */ - private suspend fun updateCurrentAccountData(account: Account, wallet: Wallet) { // Method name changed + private suspend fun updateCurrentAccountData(account: Account, wallet: Wallet) { try { logd(TAG, "Refreshing wallet accounts for ${account.userInfo.username}...") wallet.refreshAccounts() + logd(TAG, "wallet.refreshAccounts() done. Internal accounts: ${wallet.accounts.map { "${it.key}: ${it.value.size}" }}") - // Fetch all BlockchainData directly - val allBlockchainData = fetchWalletListData(wallet) + // 1. Fetch data from Indexer + val indexerBlockchainData = fetchWalletListData(wallet) + logd(TAG, "Indexer discovered ${indexerBlockchainData.size} wallets: ${indexerBlockchainData.map { "${it.address} (${it.chainId})" }}") + + // 2. Get data from Backend (preserved in account.wallet) + val backendBlockchainData = account.wallet?.wallets?.flatMap { w -> + w.blockchain?.map { b -> + BlockchainData(address = b.address, chainId = b.chainId) + } ?: emptyList() + } ?: emptyList() + logd(TAG, "Backend record has ${backendBlockchainData.size} wallets: ${backendBlockchainData.map { "${it.address} (${it.chainId})" }}") + + // 3. Merge both sources to avoid losing networks (like Testnet) when indexer is slow + val allBlockchainData = (indexerBlockchainData + backendBlockchainData) + .distinctBy { "${it.address}-${it.chainId}" } + .filter { it.address.isNotBlank() } + + logd(TAG, "Merged blockchain data: ${allBlockchainData.map { "${it.address} (${it.chainId})" }}") // Build Wallet Nodes val nodes = mutableListOf() @@ -251,7 +268,6 @@ object WalletDataManager { if (!WalletManager.isEoaDisabled()) { val eoa = deriveEoaAddress(wallet) logd(TAG, "Generated EOA address: $eoa") - logd(TAG, msg = "EOA Addresses: ${wallet.eoaAddresses.value}") if (eoa.isNotEmpty()) { logd(TAG, "Adding EOA for account: $eoa") val eoaEmojiInfo = getEmojiInfo(eoa) @@ -261,102 +277,80 @@ object WalletDataManager { emojiId = eoaEmojiInfo.emojiId )) } - } else { - logd(TAG, "Skipping EOA derivation (isEoaDisabled=${WalletManager.isEoaDisabled()})") } kotlinx.coroutines.supervisorScope { - val deferredFlowNodes = allBlockchainData.mapNotNull { blockchainData -> + val deferredFlowNodes = allBlockchainData.map { blockchainData -> val address = blockchainData.address val chainId = blockchainData.chainId - if (address.isBlank()) { - null - } else { - async { - val linkedWallets = mutableListOf() + async { + // Find existing node if any to preserve existing linked wallets + val existingNode = account.walletNodes.filterIsInstance() + .firstOrNull { it.address == address && it.chainIdString == chainId } - // Child Accounts + val linkedWallets = mutableListOf() + + // Start with existing linked wallets to prevent flickering/loss on error + existingNode?.linkedWallets?.let { linkedWallets.addAll(it) } + + try { + // Update Child Accounts val children = fetchChildAccountsForAddress(address) - children.forEach { child -> - linkedWallets.add(ChildWallet( - address = child.address, - name = child.name, - icon = child.icon, - emojiId = getEmojiInfo(child.address).emojiId - )) + // Only update if we successfully fetched something or if we know for sure it's empty + // (Here assuming fetchChildAccountsForAddress returns emptyList on error, + // but we might want to check log logs. For now, strict replacement is risky without error diff. + // Better strategy: replace specific types only if fetch succeeds) + + if (children.isNotEmpty()) { + val nonChildLinks = linkedWallets.filter { it !is ChildWallet } + val newChildren = children.map { child -> + ChildWallet( + address = child.address, + name = child.name, + icon = child.icon, + emojiId = getEmojiInfo(child.address).emojiId + ) + } + linkedWallets.clear() + linkedWallets.addAll(nonChildLinks + newChildren) } - // COA + // Update COA val coa = fetchEVMAddressForAddress(address) if (coa != null) { - val coaEmojiInfo = getEmojiInfo(coa) - linkedWallets.add(COAWallet( - address = coa, - name = coaEmojiInfo.emojiName, - emojiId = coaEmojiInfo.emojiId - )) - } - val emojiInfo = getEmojiInfo(address) - FlowWallet( - address = address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - chainIdString = chainId, - linkedWallets = linkedWallets - ) - } - } - } - nodes.addAll(deferredFlowNodes.awaitAll()) - } - - logd(TAG, "Wallet nodes built: ${nodes.size}") - - // Check if we found any FlowWallets from the key indexer - val newFlowWallets = nodes.filterIsInstance() - val existingFlowWallets = account.walletNodes.filterIsInstance() - - // If we didn't find any FlowWallets from key indexer but account already has some, - // preserve the existing ones (key indexer may not have indexed new accounts yet) - // BUT also query for COA on preserved FlowWallets that don't have linkedWallets yet - val finalNodes = if (newFlowWallets.isEmpty() && existingFlowWallets.isNotEmpty()) { - logd(TAG, "No FlowWallets found from key indexer, preserving ${existingFlowWallets.size} existing FlowWallets") - - // Query COA for preserved FlowWallets that have empty linkedWallets - val updatedExistingWallets = existingFlowWallets.map { flowWallet -> - if (flowWallet.linkedWallets.isEmpty()) { - try { - val coa = fetchEVMAddressForAddress(flowWallet.address) - if (coa != null) { - logd(TAG, "Found COA for preserved FlowWallet ${flowWallet.address}: $coa") + val nonCoaLinks = linkedWallets.filter { it !is COAWallet } val coaEmojiInfo = getEmojiInfo(coa) - flowWallet.copy(linkedWallets = listOf(COAWallet( + linkedWallets.clear() + linkedWallets.addAll(nonCoaLinks + COAWallet( address = coa, name = coaEmojiInfo.emojiName, emojiId = coaEmojiInfo.emojiId - ))) - } else { - logd(TAG, "No COA found for preserved FlowWallet ${flowWallet.address}") - flowWallet + )) + logd(TAG, "Updated COA for $address: $coa") } } catch (e: Exception) { - logd(TAG, "Error querying COA for preserved FlowWallet ${flowWallet.address}: ${e.message}") - flowWallet + logd(TAG, "Error updating linked data for $address: ${e.message}") + // Keep existing linkedWallets on error } - } else { - flowWallet + + val emojiInfo = getEmojiInfo(address) + FlowWallet( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + chainIdString = chainId, + linkedWallets = linkedWallets + ) } } - nodes + updatedExistingWallets - } else { - nodes + nodes.addAll(deferredFlowNodes.awaitAll()) } - logd(TAG, "Final wallet nodes: ${finalNodes.size} (${finalNodes.filterIsInstance().size} FlowWallets, ${finalNodes.filterIsInstance().size} EOAWallets)") + logd(TAG, "Final wallet nodes built: ${nodes.size}") - // Persist changes to AccountManager (always use updateCurrentAccount as it's for current) - AccountManager.updateCurrentAccount { it.copy(walletNodes = finalNodes) } + // Persist changes to AccountManager + AccountManager.updateCurrentAccount { it.copy(walletNodes = nodes) } logd(TAG, "Updated current account data for ${account.userInfo.username}") } catch (e: Exception) { @@ -382,7 +376,7 @@ object WalletDataManager { // Build Wallet Nodes val nodes = mutableListOf() fun getEmojiInfo(address: String) = AccountEmojiManager.getEmojiByAddress(address) - + // EOA - WalletCreationHelper.createWalletFromAccount() sets isEoaDisabled // based on key type (Secure Enclave = disabled, others = enabled) if (!WalletManager.isEoaDisabled()) { @@ -491,13 +485,20 @@ object WalletDataManager { return try { val evmAddress = cadenceQueryEVMAddress(address) if (!evmAddress.isNullOrBlank()) { + logd(TAG, "fetchEVMAddressForAddress: raw evmAddress from Cadence: $evmAddress") val formatedAddress = evmAddress.toAddress() + logd(TAG, "fetchEVMAddressForAddress: formattedAddress: $formatedAddress") + if (EVMWalletManager.isValidEVMAddress(formatedAddress)) { - EVMWalletManager.toChecksumEVMAddress(formatedAddress) + val checksumAddress = EVMWalletManager.toChecksumEVMAddress(formatedAddress) + logd(TAG, "fetchEVMAddressForAddress: valid checksum address: $checksumAddress") + checksumAddress } else { + logd(TAG, "fetchEVMAddressForAddress: Invalid EVM address format: $formatedAddress") null } } else { + logd(TAG, "fetchEVMAddressForAddress: Cadence returned empty/null for $address") null } } catch (e: Exception) { diff --git a/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt b/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt index 496586b46..2bcbb6c84 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt @@ -205,18 +205,11 @@ suspend fun initWalletWithTxId( else -> ChainId.Mainnet } - val storage = FileSystemStorage(File(Env.getApp().filesDir, "wallet")) - val keyForWalletSDK = KeyCompatibilityManager.getPrivateKeyWithFallback(prefix, storage) - if (keyForWalletSDK == null) { - loge(TAG, "[InitWallet] Failed to retrieve stored private key") - continuation.resume(Pair(false, null)) - return@ioScope - } - - val walletForSDK = WalletFactory.createKeyWallet( - keyForWalletSDK, + val provider = AndroidKeystoreCryptoProvider(prefix) + val walletForSDK = WalletFactory.createProxyWallet( + provider, setOf(ChainId.Mainnet, ChainId.Testnet), - storage + Env.getStorage() ) logd(TAG, "[InitWallet] Fetching account by txId: $txId") @@ -353,16 +346,10 @@ suspend fun registerOutblock( } // Initialize wallet SDK early to use fetchAccountByCreationTxId - val storage = FileSystemStorage(File(Env.getApp().filesDir, "wallet")) - val keyForWalletSDK = KeyCompatibilityManager.getPrivateKeyWithFallback(prefix, storage) - if (keyForWalletSDK == null) { - logd(TAG, "Failed to retrieve stored private key for Wallet SDK init from both new and old storage.") - continuation.resume(false) - return@ioScope - } - - val walletForSDK = WalletFactory.createKeyWallet( - keyForWalletSDK, + val storage = Env.getStorage() + val provider = AndroidKeystoreCryptoProvider(prefix) + val walletForSDK = WalletFactory.createProxyWallet( + provider, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) @@ -450,7 +437,7 @@ suspend fun registerOutblock( ), firebaseUid() ) - logd(TAG, "Account added to AccountManager with FlowWallet in walletNodes.") + logd(TAG, "Account added to AccountManager with FlowWallet in walletNodes. Nodes: ${initialWalletNodes.map { it.address }}") // Get the Flow address from wallet data val flowAddress = walletListData.wallets diff --git a/app/src/main/java/com/flowfoundation/wallet/network/model/UserInfoResponse.kt b/app/src/main/java/com/flowfoundation/wallet/network/model/UserInfoResponse.kt index 804cfeb32..72f23793c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/model/UserInfoResponse.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/model/UserInfoResponse.kt @@ -5,6 +5,8 @@ import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.ExperimentalSerializationApi data class UserInfoResponse( @SerializedName("data") @@ -19,6 +21,7 @@ data class UserInfoResponse( @Serializable @Parcelize +@OptIn(ExperimentalSerializationApi::class) data class UserInfoData( @SerializedName("nickname") var nickname: String, @@ -28,9 +31,10 @@ data class UserInfoData( var avatar: String, @SerializedName("address") var address: String? = null, - @SerialName("private") // Needed: property name "isPrivate" differs from JSON key "private" + @SerialName("private") @SerializedName("private") - var isPrivate: Int, + @JsonNames("isPrivate") + var isPrivate: Int = 1, @SerializedName("created") var created: String, -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/app/src/main/java/com/flowfoundation/wallet/network/model/WalletListResponse.kt b/app/src/main/java/com/flowfoundation/wallet/network/model/WalletListResponse.kt index 6327cefa5..d8dfa361d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/model/WalletListResponse.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/model/WalletListResponse.kt @@ -5,6 +5,8 @@ import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import kotlinx.serialization.json.JsonNames +import kotlinx.serialization.ExperimentalSerializationApi class WalletListResponse( @SerializedName("data") @@ -37,10 +39,12 @@ data class WalletData( @Serializable @Parcelize +@OptIn(ExperimentalSerializationApi::class) data class BlockchainData( @SerializedName("address") val address: String, - @SerialName("chain_id") // Needed: property name "chainId" differs from JSON key "chain_id" + @SerialName("chain_id") @SerializedName("chain_id") - val chainId: String + @JsonNames("chainId") + val chainId: String = "" ) : Parcelable 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 59c6a66b6..8968b6eaf 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 @@ -2,27 +2,24 @@ package com.flowfoundation.wallet.page.account import androidx.lifecycle.ViewModel import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.AccountVisibilityManager -import com.flowfoundation.wallet.manager.app.NETWORK_NAME_MAINNET -import com.flowfoundation.wallet.manager.app.NETWORK_NAME_TESTNET import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.emoji.OnEmojiUpdate -import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.flowfoundation.wallet.network.ApiService -import com.flowfoundation.wallet.network.retrofitApi -import java.math.BigDecimal +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.manager.walletdata.FlowWallet import com.flowfoundation.wallet.page.main.model.WalletAccountData import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.utils.formatLargeBalanceNumber import com.flowfoundation.wallet.utils.ioScope -import com.flowfoundation.wallet.wallet.toAddress import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import org.onflow.flow.ChainId class AccountListViewModel : ViewModel(), OnEmojiUpdate { @@ -35,11 +32,6 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { private val _hiddenAccounts = MutableStateFlow>(emptySet()) val hiddenAccounts: StateFlow> = _hiddenAccounts.asStateFlow() - private val service by lazy { retrofitApi().create(ApiService::class.java) } - - // Cache for verified EVM addresses that should be included in linkedAccounts - private val verifiedEvmAddresses = mutableSetOf() - init { AccountEmojiManager.addListener(this) } @@ -50,93 +42,79 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { private fun refreshWalletList(refreshBalance: Boolean = true) { ioScope { - val wallet = WalletManager.wallet() ?: return@ioScope - val walletAddresses = wallet.accounts.mapNotNull { (chainId, accounts) -> - val isCurrentChain = when (chainNetWorkString()) { - NETWORK_NAME_MAINNET -> chainId == ChainId.Mainnet - NETWORK_NAME_TESTNET -> chainId == ChainId.Testnet - else -> false - } - if (isCurrentChain) { - accounts.map { it.address.toAddress() } - } else { - null - } - }.flatten() + val currentNetwork = chainNetWorkString() + val walletNodes = AccountManager.walletNodes() ?: return@ioScope val addressList = mutableListOf() val accounts = mutableListOf() - val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping - - // Add EOA account if exists - val eoaAddress = WalletManager.getEOAAddress() - if (eoaAddress != null) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoaAddress) - addressList.add(eoaAddress) - accounts.add( - WalletAccountData( - address = eoaAddress, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == eoaAddress, - isEOAAccount = true - ) - ) - } - // Add wallet accounts - walletAddresses.forEach { address -> - val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) - val linkedAccounts = mutableListOf() - - // Add child accounts - WalletManager.childAccountList(address).forEach { childAccount -> - addressList.add(childAccount.address) - linkedAccounts.add( - LinkedAccountData( - address = childAccount.address, - name = childAccount.name, - icon = childAccount.icon, - emojiId = AccountEmojiManager.getEmojiByAddress(childAccount.address).emojiId, - isSelected = WalletManager.selectedWalletAddress() == childAccount.address, - isCOAAccount = false + walletNodes.forEach { mainNode -> + when (mainNode) { + is EOAWallet -> { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(mainNode.address) + addressList.add(mainNode.address) + accounts.add( + WalletAccountData( + address = mainNode.address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), + isEOAAccount = true + ) ) - ) - } + } + is FlowWallet -> { + if (mainNode.chainIdString == currentNetwork) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(mainNode.address) + val linkedAccounts = mutableListOf() + + mainNode.linkedWallets.forEach { linkedWallet -> + when (linkedWallet) { + is ChildWallet -> { + addressList.add(linkedWallet.address) + linkedAccounts.add( + LinkedAccountData( + address = linkedWallet.address, + name = linkedWallet.name, + icon = linkedWallet.icon, + emojiId = AccountEmojiManager.getEmojiByAddress(linkedWallet.address).emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(linkedWallet.address, ignoreCase = true), + isCOAAccount = false + ) + ) + } + is COAWallet -> { + val evmAddress = linkedWallet.address + addressList.add(evmAddress) + val linkedEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + linkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = linkedEmojiInfo.emojiName, + icon = null, + emojiId = linkedEmojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + isCOAAccount = true + ) + ) + } + } + } - // Handle EVM address (COA) - EVMWalletManager.getEVMAddressByAddress(address)?.let { evmAddress -> - addressList.add(evmAddress) - // Add to pending list for verification if not already verified - if (evmAddress !in verifiedEvmAddresses) { - pendingEvmAddresses.add(Pair(evmAddress, address)) - } else { - // Add directly to linkedAccounts if already verified - val evmEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) - linkedAccounts.add( - LinkedAccountData( - address = evmAddress, - name = evmEmojiInfo.emojiName, - icon = null, - emojiId = evmEmojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, - isCOAAccount = true + accounts.add( + WalletAccountData( + address = mainNode.address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), + linkedAccounts = linkedAccounts, + isEOAAccount = false + ) ) - ) + addressList.add(mainNode.address) + } } } - - accounts.add( - WalletAccountData( - address = address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == address, - linkedAccounts = linkedAccounts, - isEOAAccount = false - ) - ) - addressList.add(address) } // Note: AccountListViewModel shows all accounts (including hidden ones) @@ -153,86 +131,18 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { } if (refreshBalance) { - fetchAllBalances(addressList, pendingEvmAddresses) + fetchAllBalances(addressList) } } } - private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { + private fun fetchAllBalances(addressList: List) { ioScope { val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" } _balanceMap.value = formattedBalanceMap - - // Check each pending EVM address - pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> - val evmBalance = balanceMap[evmAddress] - val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO - var hasNFTs = false - - if (!hasBalance) { - try { - val nftResponse = service.getEVMNFTCollections(evmAddress) - val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 - hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 - } catch (e: Exception) { - // Ignore NFT API errors - } - } - - if (hasBalance || hasNFTs) { - // Add EVM address to linked accounts - val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } - walletAccount?.let { account -> - // Check if EVM address already exists in linked accounts - val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } - if (!alreadyExists) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) - val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.add( - LinkedAccountData( - address = evmAddress, - name = emojiInfo.emojiName, - icon = null, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, - isCOAAccount = true - ) - ) - val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } - if (accountIndex >= 0) { - currentAccounts[accountIndex] = updatedAccount - _accounts.value = currentAccounts - } - } - // Add to verified cache for future refreshWalletList calls - verifiedEvmAddresses.add(evmAddress) - } - } else { - // Remove EVM address from linked accounts if it no longer has assets - val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } - walletAccount?.let { account -> - val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } - if (existingLinkedAccount != null) { - val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.removeAll { it.address == evmAddress } - val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } - if (accountIndex >= 0) { - currentAccounts[accountIndex] = updatedAccount - _accounts.value = currentAccounts - } - } - } - // Remove from verified cache - verifiedEvmAddresses.remove(evmAddress) - } - } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/deeplink/DappPromptDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/deeplink/DappPromptDialog.kt index 70856bce1..4316de5ea 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/deeplink/DappPromptDialog.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/deeplink/DappPromptDialog.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,10 +69,10 @@ fun DappPromptDialog( .height(58.dp), shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = colorResource(id = R.color.button), + containerColor = colorResource(id = R.color.button), contentColor = colorResource(id = R.color.button_text) ), - elevation = ButtonDefaults.elevation(0.dp, 0.dp) + elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp) ) { Text( text = stringResource(id = R.string.continue_str), 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 a11344246..3d8b11794 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 @@ -81,7 +81,7 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { address = mainNode.address, name = emojiInfo.emojiName, emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == mainNode.address, + isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), isEOAAccount = true ) ) @@ -101,7 +101,7 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { name = linkedWallet.name, icon = linkedWallet.icon, emojiId = AccountEmojiManager.getEmojiByAddress(linkedWallet.address).emojiId, - isSelected = WalletManager.selectedWalletAddress() == linkedWallet.address, + isSelected = WalletManager.selectedWalletAddress().equals(linkedWallet.address, ignoreCase = true), isCOAAccount = false ) ) @@ -119,7 +119,7 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { name = linkedEmojiInfo.emojiName, icon = null, emojiId = linkedEmojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), isCOAAccount = true ) ) @@ -133,7 +133,7 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { address = mainNode.address, name = emojiInfo.emojiName, emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == mainNode.address, + isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), linkedAccounts = linkedAccounts ) ) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/fragment/RestoreStartFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/fragment/RestoreStartFragment.kt index f38d7f202..bf5e15a5e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/fragment/RestoreStartFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/fragment/RestoreStartFragment.kt @@ -53,18 +53,7 @@ class RestoreStartFragment: Fragment() { btnNext.setOnClickListener { restoreViewModel.startRestore() } - if (restoreViewModel.isRestoreValid().not()) { - restoreViewModel.selectOption(RestoreOption.RESTORE_FROM_GOOGLE_DRIVE) { isSelected -> - oiGoogleDrive.changeItemStatus(isSelected) - } - restoreViewModel.selectOption(RestoreOption.RESTORE_FROM_RECOVERY_PHRASE) { isSelected -> - oiRecoveryPhrase.changeItemStatus(isSelected) - } - restoreViewModel.selectOption(RestoreOption.RESTORE_FROM_DROPBOX) { isSelected -> - oiDropbox.changeItemStatus(isSelected) - } - checkRestoreValid() - } + checkRestoreValid() } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/token/list/CadenceTokenListProvider.kt b/app/src/main/java/com/flowfoundation/wallet/page/token/list/CadenceTokenListProvider.kt index 0556bcaba..dd01418ef 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/token/list/CadenceTokenListProvider.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/token/list/CadenceTokenListProvider.kt @@ -24,21 +24,23 @@ class CadenceTokenListProvider(private val walletAddress: String): TokenListProv network: String? ): List { val tokenResponse = service.getFlowTokenList(walletAddress, currency?.name, network) - tokenList.clear() - tokenList.addAll( - tokenResponse.data?.result?.map { token -> - token.toFungibleToken() - }?.toList() ?: emptyList() - ) - return tokenList + val newTokens = tokenResponse.data?.result?.map { token -> + token.toFungibleToken() + }?.toList() ?: emptyList() + + synchronized(this) { + tokenList.clear() + tokenList.addAll(newTokens) + } + return newTokens } override fun getTokenById(contractId: String): FungibleToken? { - return tokenList.firstOrNull { it.contractId() == contractId } + return synchronized(this) { tokenList.firstOrNull { it.contractId() == contractId } } } override fun getFlowToken(): FungibleToken? { - return tokenList.firstOrNull { it.isFlowToken() } + return synchronized(this) { tokenList.firstOrNull { it.isFlowToken() } } } override fun addCustomToken() { @@ -54,7 +56,7 @@ class CadenceTokenListProvider(private val walletAddress: String): TokenListProv } override fun getFungibleTokenListSnapshot(): List { - return tokenList + return synchronized(this) { tokenList.toList() } } } \ No newline at end of file diff --git a/app/src/main/java/com/flowfoundation/wallet/page/token/list/EVMTokenListProvider.kt b/app/src/main/java/com/flowfoundation/wallet/page/token/list/EVMTokenListProvider.kt index 9e1fb4cd2..e493ce23d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/token/list/EVMTokenListProvider.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/token/list/EVMTokenListProvider.kt @@ -28,30 +28,47 @@ class EVMTokenListProvider(private val walletAddress: String): TokenListProvider network: String? ): List { val tokenResponse = service.getEVMTokenList(walletAddress, currency?.name, network) - tokenList.clear() - tokenList.addAll( - tokenResponse.data?.map { token -> - token.toFungibleToken() - }?.toList() ?: emptyList() - ) - addCustomToken() - return tokenList + + val newTokens = tokenResponse.data?.map { token -> + token.toFungibleToken() + }?.toMutableList() ?: mutableListOf() + + val customTokens = getCustomTokens() + val uniqueCustomTokens = customTokens.filter { ft -> + newTokens.none { existingToken -> + existingToken.evmAddress?.equals(ft.evmAddress, ignoreCase = true) == true + } + } + newTokens.addAll(uniqueCustomTokens) + + synchronized(this) { + tokenList.clear() + tokenList.addAll(newTokens) + } + return newTokens } - override fun addCustomToken() { + private fun getCustomTokens(): List { val customTokenItems = CustomTokenManager.getCurrentCustomTokenList() - val newFungibleTokens = customTokenItems.mapNotNull { customItem -> + return customTokenItems.mapNotNull { customItem -> if (customItem.tokenType == TokenType.EVM) { customItem.toFungibleToken() } else { null } - }.filter { ft -> - tokenList.none { existingToken -> - existingToken.evmAddress?.equals(ft.evmAddress, ignoreCase = true) == true + } + } + + override fun addCustomToken() { + synchronized(this) { + val customTokens = getCustomTokens() + val newFungibleTokens = customTokens.filter { ft -> + tokenList.none { existingToken -> + existingToken.evmAddress?.equals(ft.evmAddress, ignoreCase = true) == true + } } + tokenList.addAll(newFungibleTokens) } - tokenList.addAll(newFungibleTokens) } override fun deleteCustomToken(contractAddress: String) { @@ -63,14 +80,14 @@ class EVMTokenListProvider(private val walletAddress: String): TokenListProvider } override fun getFungibleTokenListSnapshot(): List { - return tokenList + return synchronized(this) { tokenList.toList() } } override fun getTokenById(contractId: String): FungibleToken? { - return tokenList.firstOrNull { it.contractId() == contractId } + return synchronized(this) { tokenList.firstOrNull { it.contractId() == contractId } } } override fun getFlowToken(): FungibleToken? { - return tokenList.firstOrNull { it.isFlowToken() } + return synchronized(this) { tokenList.firstOrNull { it.isFlowToken() } } } } \ No newline at end of file diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt index c3d9bcc8a..cc375175b 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt @@ -140,6 +140,7 @@ class ReactNativeActivity : ReactActivity() { RNBridge.ScreenType.TOKEN_DETAIL -> RNBridge.InitialRoute.HOME.routeName RNBridge.ScreenType.ONBOARDING -> RNBridge.InitialRoute.GET_STARTED.routeName RNBridge.ScreenType.RECEIVE -> "Receive" + RNBridge.ScreenType.ACTIVITY -> RNBridge.InitialRoute.HOME.routeName } } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt index 23c207888..01961b944 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt @@ -1,6 +1,6 @@ // // BridgeModels.kt -// +// // Auto-generated from TypeScript bridge types // Do not edit manually // @@ -19,10 +19,11 @@ class RNBridge { enum class ScreenType { @SerializedName("send-asset") SEND_ASSET, + @SerializedName("backup-tip") BACKUP_TIP, @SerializedName("token-detail") TOKEN_DETAIL, @SerializedName("onboarding") ONBOARDING, @SerializedName("receive") RECEIVE, - @SerializedName("backup-tip") BACKUP_TIP, + @SerializedName("activity") ACTIVITY, @SerializedName("keystore-migration") KEYSTORE_MIGRATION } @@ -166,7 +167,9 @@ class RNBridge { @SerializedName("GO_API_URL") val GO_API_URL: String, @SerializedName("INSTABUG_TOKEN") - val INSTABUG_TOKEN: String + val INSTABUG_TOKEN: String, + @SerializedName("MIXPANEL_TOKEN") + val MIXPANEL_TOKEN: String? ) data class Currency( @@ -293,48 +296,6 @@ class RNBridge { val currency: String? ) - enum class InitialRoute(val routeName: String) { - @SerializedName("GetStarted") GET_STARTED("GetStarted"), - @SerializedName("ProfileTypeSelection") PROFILE_TYPE_SELECTION("ProfileTypeSelection"), - @SerializedName("ImportProfile") IMPORT_PROFILE("ImportProfile"), - @SerializedName("SelectTokens") SELECT_TOKENS("SelectTokens"), - @SerializedName("SendTo") SEND_TO("SendTo"), - @SerializedName("SendTokens") SEND_TOKENS("SendTokens"), - @SerializedName("Home") HOME("Home") - } - - enum class NativeScreenName { - @SerializedName("multiBackup") MULTI_BACKUP, - @SerializedName("deviceBackup") DEVICE_BACKUP, - @SerializedName("seedPhraseBackup") SEED_PHRASE_BACKUP, - @SerializedName("backupOptions") BACKUP_OPTIONS, - @SerializedName("walletRestore") WALLET_RESTORE, - @SerializedName("recoveryPhraseRestore") RECOVERY_PHRASE_RESTORE, - @SerializedName("keyStoreRestore") KEY_STORE_RESTORE, - @SerializedName("privateKeyRestore") PRIVATE_KEY_RESTORE, - @SerializedName("googleDriveRestore") GOOGLE_DRIVE_RESTORE, - @SerializedName("icloudRestore") ICLOUD_RESTORE, - @SerializedName("multiRestore") MULTI_RESTORE - } - - enum class ScreenName { - @SerializedName("GetStarted") GET_STARTED, - @SerializedName("ProfileTypeSelection") PROFILE_TYPE_SELECTION, - @SerializedName("RecoveryPhrase") RECOVERY_PHRASE, - @SerializedName("ConfirmRecoveryPhrase") CONFIRM_RECOVERY_PHRASE, - @SerializedName("SecureEnclave") SECURE_ENCLAVE, - @SerializedName("ImportProfile") IMPORT_PROFILE, - @SerializedName("ImportOtherMethods") IMPORT_OTHER_METHODS, - @SerializedName("ConfirmImportProfile") CONFIRM_IMPORT_PROFILE, - @SerializedName("NotificationPreferences") NOTIFICATION_PREFERENCES, - @SerializedName("SelectTokens") SELECT_TOKENS, - @SerializedName("SendTo") SEND_TO, - @SerializedName("SendTokens") SEND_TOKENS, - @SerializedName("SendSummary") SEND_SUMMARY, - @SerializedName("NFTList") NFT_LIST, - @SerializedName("NFTDetail") NFT_DETAIL - } - data class NFTModel( @SerializedName("id") val id: String?, @@ -456,4 +417,46 @@ class RNBridge { @SerializedName("evm") EVM } + enum class InitialRoute(val routeName: String) { + @SerializedName("GetStarted") GET_STARTED("GetStarted"), + @SerializedName("ProfileTypeSelection") PROFILE_TYPE_SELECTION("ProfileTypeSelection"), + @SerializedName("ImportProfile") IMPORT_PROFILE("ImportProfile"), + @SerializedName("SelectTokens") SELECT_TOKENS("SelectTokens"), + @SerializedName("SendTo") SEND_TO("SendTo"), + @SerializedName("SendTokens") SEND_TOKENS("SendTokens"), + @SerializedName("Home") HOME("Home") + } + + enum class NativeScreenName { + @SerializedName("multiBackup") MULTI_BACKUP, + @SerializedName("deviceBackup") DEVICE_BACKUP, + @SerializedName("seedPhraseBackup") SEED_PHRASE_BACKUP, + @SerializedName("backupOptions") BACKUP_OPTIONS, + @SerializedName("walletRestore") WALLET_RESTORE, + @SerializedName("recoveryPhraseRestore") RECOVERY_PHRASE_RESTORE, + @SerializedName("keyStoreRestore") KEY_STORE_RESTORE, + @SerializedName("privateKeyRestore") PRIVATE_KEY_RESTORE, + @SerializedName("googleDriveRestore") GOOGLE_DRIVE_RESTORE, + @SerializedName("icloudRestore") ICLOUD_RESTORE, + @SerializedName("multiRestore") MULTI_RESTORE + } + + enum class ScreenName { + @SerializedName("GetStarted") GET_STARTED, + @SerializedName("ProfileTypeSelection") PROFILE_TYPE_SELECTION, + @SerializedName("RecoveryPhrase") RECOVERY_PHRASE, + @SerializedName("ConfirmRecoveryPhrase") CONFIRM_RECOVERY_PHRASE, + @SerializedName("SecureEnclave") SECURE_ENCLAVE, + @SerializedName("ImportProfile") IMPORT_PROFILE, + @SerializedName("ImportOtherMethods") IMPORT_OTHER_METHODS, + @SerializedName("ConfirmImportProfile") CONFIRM_IMPORT_PROFILE, + @SerializedName("NotificationPreferences") NOTIFICATION_PREFERENCES, + @SerializedName("SelectTokens") SELECT_TOKENS, + @SerializedName("SendTo") SEND_TO, + @SerializedName("SendTokens") SEND_TOKENS, + @SerializedName("SendSummary") SEND_SUMMARY, + @SerializedName("NFTList") NFT_LIST, + @SerializedName("NFTDetail") NFT_DETAIL + } + } 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 15de54455..f5689da1c 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 @@ -57,6 +57,7 @@ import com.flowfoundation.wallet.reactnative.bridge.handlers.AuthBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.UIBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.UtilsBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.WalletBridgeHandler +import com.flowfoundation.wallet.wallet.DERIVATION_PATH class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSpec(reactContext) { @@ -122,7 +123,7 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp val seedPhraseKey = SeedPhraseKey( mnemonicString = mnemonic, passphrase = "", - derivationPath = "m/44'/539'/0'/0/0", + derivationPath = DERIVATION_PATH, storage = getStorage() ) val cryptoProvider = HDWalletCryptoProvider(seedPhraseKey) diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt index 209c16df6..7805ce30b 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt @@ -37,6 +37,7 @@ import com.flow.wallet.storage.InMemoryStorage import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.firebase.auth.isAnonymousSignIn import com.flowfoundation.wallet.firebase.auth.signInAnonymously +import com.flowfoundation.wallet.wallet.DERIVATION_PATH import org.onflow.flow.models.toHexString import org.onflow.flow.models.DomainTag import org.onflow.flow.models.SigningAlgorithm @@ -188,12 +189,12 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { // Create SeedPhraseKey from mnemonic to derive account key // IMPORTANT: Use in-memory storage only - mnemonic is NOT confirmed yet // It will be saved to disk later when saveMnemonic() is called after user confirmation - val inMemoryStorage = com.flow.wallet.storage.InMemoryStorage() + val inMemoryStorage = InMemoryStorage() // Use Flow derivation path: m/44'/539'/0'/0/0 - val derivationPath = "m/44'/539'/0'/0/0" + val derivationPath = DERIVATION_PATH - val seedPhraseKey = com.flow.wallet.keys.SeedPhraseKey( + val seedPhraseKey = SeedPhraseKey( mnemonicString = mnemonic, passphrase = "", derivationPath = derivationPath, @@ -201,7 +202,7 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { ) // Derive public key using ECDSA_secp256k1 (matches EOA flow default) - val publicKeyBytes = seedPhraseKey.publicKey(org.onflow.flow.models.SigningAlgorithm.ECDSA_secp256k1) + val publicKeyBytes = seedPhraseKey.publicKey(SigningAlgorithm.ECDSA_secp256k1) ?: throw IllegalStateException("Failed to get public key from seed phrase key") // Convert public key bytes to hex string @@ -222,11 +223,11 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { // ECDSA_secp256k1 = sign_algo 2, SHA2_256 = hash_algo 1 (matches extension defaults) val accountKey = RNBridge.AccountKey( publicKey = publicKeyHex, - hashAlgoStr = "SHA2_256", - signAlgoStr = "ECDSA_secp256k1", + hashAlgoStr = HashingAlgorithm.SHA2_256.value, + signAlgoStr = SigningAlgorithm.ECDSA_secp256k1.value, weight = 1000, // Standard weight for Flow accounts - hashAlgo = 1, // SHA2_256 - signAlgo = 2 // ECDSA_secp256k1 + hashAlgo = HashingAlgorithm.SHA2_256.cadenceIndex, // SHA2_256 + signAlgo = SigningAlgorithm.ECDSA_secp256k1.cadenceIndex // ECDSA_secp256k1 ) // Derive EVM address from the seed phrase for faster display in UI @@ -293,14 +294,14 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { // Step 2: Get Firebase JWT val firebaseJwt = getFirebaseJwt() - if (firebaseJwt.isNullOrBlank()) { + if (firebaseJwt.isBlank()) { throw IllegalStateException("Failed to get Firebase JWT") } logd(TAG, "getV4RegistrationSignatures() - Got Firebase JWT") // Step 3: Derive private key from mnemonic val inMemoryStorage = InMemoryStorage() - val derivationPath = "m/44'/539'/0'/0/0" + val derivationPath = DERIVATION_PATH val seedPhraseKey = SeedPhraseKey( mnemonicString = mnemonic, passphrase = "", @@ -327,11 +328,11 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { // Use Trust Wallet Core's HDWallet for proper derivation val hdWallet = wallet.core.jni.HDWallet(mnemonic, "") val evmDerivationPath = "m/44'/60'/0'/0/0" // Standard Ethereum BIP44 path - + // Get private key for EVM using secp256k1 curve with Ethereum path val evmPrivateKey = hdWallet.getKeyByCurve(wallet.core.jni.Curve.SECP256K1, evmDerivationPath) val evmPublicKey = evmPrivateKey.getPublicKeySecp256k1(false) // uncompressed - + // Derive EVM address from public key using Trust Wallet Core val eoaAddress = wallet.core.jni.AnyAddress(evmPublicKey, wallet.core.jni.CoinType.ETHEREUM).description() logd(TAG, "getV4RegistrationSignatures() - Derived EOA address from mnemonic: $eoaAddress") @@ -340,10 +341,10 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { val jwtBytes = firebaseJwt.toByteArray(Charsets.UTF_8) val jwtHash = wallet.core.jni.Hash.keccak256(jwtBytes) logd(TAG, "getV4RegistrationSignatures() - EVM: keccak256 hash of JWT, hash size: ${jwtHash.size}") - + // Sign the digest with secp256k1 val signatureData = evmPrivateKey.sign(jwtHash, wallet.core.jni.Curve.SECP256K1) - + val evmSignature = "0x" + signatureData.joinToString("") { "%02x".format(it) } logd(TAG, "getV4RegistrationSignatures() - Generated EVM signature from mnemonic, length: ${evmSignature.length} chars") @@ -387,7 +388,7 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { val jwt = getFirebaseJwt(forceRefresh = true) val firebaseUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - if (!jwt.isNullOrBlank() && firebaseUid != null) { + if (jwt.isNotBlank() && firebaseUid != null) { logd(TAG, "signInWithCustomToken() - JWT ready after $attempts attempt(s), Firebase UID: $firebaseUid") // Validate with backend - make sure the user is recognized @@ -479,7 +480,7 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { val jwt = getFirebaseJwt(forceRefresh = true) val currentUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - if (!jwt.isNullOrBlank() && currentUid != null) { + if (jwt.isNotBlank() && currentUid != null) { tokenRefreshed = true logd(TAG, "saveMnemonic() - Firebase ID token refreshed after $refreshAttempts attempt(s), UID: $currentUid") @@ -743,12 +744,12 @@ private fun authenticateWithFirebase( /** * Setup account and wallet for mnemonic-only accounts (cleaner architecture). - * + * * This is used for RN seed phrase accounts that only store the mnemonic, * without a prefix-based private key duplication. The account will have: * - No prefix field set (null) * - Mnemonic stored via AccountWalletManager (keyed by userId) - * + * * CryptoProviderManager and WalletCreationHelper will detect this as a mnemonic-only * account and use AccountWalletManager.getHDWalletMnemonicByUID() to access the key. */ diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt index 07485252b..059c93622 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt @@ -88,6 +88,11 @@ class UtilsBridgeHandler(private val reactContext: ReactApplicationContext) { BuildConfig.INSTABUG_RN_TOKEN_DEV } else { BuildConfig.INSTABUG_RN_TOKEN_PROD + }, + MIXPANEL_TOKEN = if (isTesting() || isDev()) { + BuildConfig.MIXPANEL_TOKEN_DEV + } else { + BuildConfig.MIXPANEL_TOKEN_PROD } ) diff --git a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/fcl/FclWebViewExtensions.kt b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/fcl/FclWebViewExtensions.kt index c297f52ae..88d5add16 100644 --- a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/fcl/FclWebViewExtensions.kt +++ b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/fcl/FclWebViewExtensions.kt @@ -39,7 +39,7 @@ fun WebView?.postAuthnViewReadyResponse(fcl: FclAuthnResponse, address: String) fun WebView?.postPreAuthzResponse() { ioScope { // Use a more reliable method to get the wallet address - var address = WalletManager.wallet().walletAddress() + var address = WalletManager.getCurrentFlowWalletAddress() // If that failed, try getting it from the AccountManager if (address.isNullOrBlank()) { diff --git a/gradle.properties b/gradle.properties index 1e45b69da..695617573 100644 --- a/gradle.properties +++ b/gradle.properties @@ -64,8 +64,8 @@ android.r8.dexing.parallel=true #android.bundle.enableUncompressedNativeLibs=false #android.experimental.enableArtProfiles=true -vCode=332 -vName=r3.0.12 +vCode=333 +vName=r3.1.0 # --- GitHub Packages (Flow Wallet Kit) --- # Set credentials locally (or via environment vars) to resolve From 4f65b2d8a3479a3ad4d1f2828b0d550ce2b6a964 Mon Sep 17 00:00:00 2001 From: Meng Date: Sat, 28 Feb 2026 18:10:20 +0800 Subject: [PATCH 3/4] fix: account list coa account display --- .../page/account/AccountListViewModel.kt | 108 ++++++++++++++++-- 1 file changed, 96 insertions(+), 12 deletions(-) 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 8968b6eaf..1dcb5ad7b 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 @@ -13,6 +13,8 @@ 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.manager.walletdata.FlowWallet +import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.page.main.model.WalletAccountData import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.utils.formatLargeBalanceNumber @@ -20,6 +22,7 @@ import com.flowfoundation.wallet.utils.ioScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.math.BigDecimal class AccountListViewModel : ViewModel(), OnEmojiUpdate { @@ -32,6 +35,11 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { private val _hiddenAccounts = MutableStateFlow>(emptySet()) val hiddenAccounts: StateFlow> = _hiddenAccounts.asStateFlow() + private val service by lazy { retrofitApi().create(ApiService::class.java) } + + // Cache for verified EVM addresses that should be included in linkedAccounts + private val verifiedEvmAddresses = mutableSetOf() + init { AccountEmojiManager.addListener(this) } @@ -47,6 +55,7 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { val addressList = mutableListOf() val accounts = mutableListOf() + val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping walletNodes.forEach { mainNode -> when (mainNode) { @@ -86,17 +95,22 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { is COAWallet -> { val evmAddress = linkedWallet.address addressList.add(evmAddress) - val linkedEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) - linkedAccounts.add( - LinkedAccountData( - address = evmAddress, - name = linkedEmojiInfo.emojiName, - icon = null, - emojiId = linkedEmojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), - isCOAAccount = true + // Restore logic: Only add if verified, otherwise add to pending + if (evmAddress !in verifiedEvmAddresses) { + pendingEvmAddresses.add(Pair(evmAddress, mainNode.address)) + } else { + val linkedEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + linkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = linkedEmojiInfo.emojiName, + icon = null, + emojiId = linkedEmojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + isCOAAccount = true + ) ) - ) + } } } } @@ -131,18 +145,88 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { } if (refreshBalance) { - fetchAllBalances(addressList) + fetchAllBalances(addressList, pendingEvmAddresses) } } } - private fun fetchAllBalances(addressList: List) { + private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { ioScope { val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" } _balanceMap.value = formattedBalanceMap + + // Check each pending EVM address + pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> + val evmBalance = balanceMap[evmAddress] + val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(evmAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add EVM address to linked accounts + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + // Check if EVM address already exists in linked accounts + val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + if (!alreadyExists) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + isCOAAccount = true + ) + ) + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + // Add to verified cache for future refreshWalletList calls + verifiedEvmAddresses.add(evmAddress) + } + } else { + // Remove EVM address from linked accounts if it no longer has assets + // (Though typically it wouldn't be there yet if it was pending, + // this handles the case where it might have been removed or balance drained) + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + if (existingLinkedAccount != null) { + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.removeAll { it.address == evmAddress } + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + } + // Remove from verified cache + verifiedEvmAddresses.remove(evmAddress) + } + } } } From 9a5917011c2e565cc65283b940f00e7583046f44 Mon Sep 17 00:00:00 2001 From: Meng Date: Tue, 3 Mar 2026 01:14:39 +0800 Subject: [PATCH 4/4] fix: switch current account set --- .../wallet/manager/account/AccountManager.kt | 5 +- .../wallet/network/UserRegisterUtils.kt | 55 +------------- .../viewmodel/KeyStoreRestoreViewModel.kt | 71 +++++++++++-------- 3 files changed, 46 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt index 42f9e0331..7efea0fd5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountManager.kt @@ -480,11 +480,12 @@ object AccountManager { return@ioScope } isSwitching = true - currentAccount = account - logd(TAG, "Account switched. Current account is now: $currentAccount") + logd(TAG, "Starting account switch to: ${account.userInfo.username}") switchAccount(account) { isSuccess -> if (isSuccess) { isSwitching = false + currentAccount = account + logd(TAG, "Account switch successful. Current account updated to: $currentAccount") accounts.forEach { it.isActive = it.userInfo.username == account.userInfo.username } diff --git a/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt b/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt index 2bcbb6c84..c9f74c73c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt @@ -558,63 +558,10 @@ private suspend fun registerServer(username: String, prefix: String): RegisterRe firebaseJwt ) ) - // Create EVMAccountInfo for registration - // IMPORTANT: EVM key must be derived from the MNEMONIC, not from the P256 private key - // The extension derives EVM from mnemonic with BIP44 path m/44'/60'/0'/0/0 - val evmAccountInfo = try { - // Generate and store mnemonic globally for potential future EOA support - val mnemonic = BIP39.generate(BIP39.SeedPhraseLength.TWELVE) - logd(TAG, "Generated new 12-word mnemonic for backup support") - - val passwordMap = try { - val pref = readWalletPassword() - if (pref.isBlank()) { - HashMap() - } else { - Gson().fromJson(pref, object : TypeToken>() {}.type) - } - } catch (_: Exception) { - HashMap() - } - - // Store mnemonic globally (available for future EOA enablement if user chooses) - storeWalletPassword(Gson().toJson(passwordMap.apply { put("global", mnemonic) })) - logd(TAG, "Stored mnemonic globally for backup support") - // Use Trust Wallet Core to derive EVM key from mnemonic - val hdWallet = wallet.core.jni.HDWallet(mnemonic, "") - val evmDerivationPath = "m/44'/60'/0'/0/0" // Standard Ethereum BIP44 path - - // Get private key for EVM using secp256k1 curve - val evmPrivateKey = hdWallet.getKeyByCurve(wallet.core.jni.Curve.SECP256K1, evmDerivationPath) - val evmPublicKey = evmPrivateKey.getPublicKeySecp256k1(false) // uncompressed - - // Derive EVM address from public key - val evmAddress = wallet.core.jni.AnyAddress(evmPublicKey, wallet.core.jni.CoinType.ETHEREUM).description() - logd(TAG, "Derived EVM address from mnemonic: $evmAddress") - - // Sign keccak256(idToken) for EVM - NO domain tag, same as extension - val jwtBytes = firebaseJwt.toByteArray(Charsets.UTF_8) - val jwtHash = Hash.keccak256(jwtBytes) - - // Sign the digest with secp256k1 - val signatureData = evmPrivateKey.sign(jwtHash, wallet.core.jni.Curve.SECP256K1) - - val evmSignature = "0x" + signatureData.joinToString("") { "%02x".format(it) } - logd(TAG, "Generated EVM signature from mnemonic, length: ${evmSignature.length}") - - EvmAccountInfo( - eoaAddress = evmAddress, - signature = evmSignature - ) - } catch (e: Exception) { - logd(TAG, "Error creating EVM account info from mnemonic: ${e.message}") - e.printStackTrace() - null - } val request = RegisterRequest( flowAccountInfo = flowAccountInfo, - evmAccountInfo = evmAccountInfo, + evmAccountInfo = null, username = username, deviceInfo = deviceInfoRequest ) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt index b328a8743..ce49c8fda 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt @@ -1256,39 +1256,52 @@ class KeyStoreRestoreViewModel : ViewModel() { // Create EVMAccountInfo for keystore registration val evmAccountInfo = try { val storage = getStorage() - val privateKeyHex = cryptoProvider.getPrivateKey() - val key = PrivateKey.create(storage).apply { - val keyBytes = privateKeyHex.removePrefix("0x").hexToBytes() - importPrivateKey(keyBytes, KeyFormat.RAW) - } + if (currentMnemonic.isNullOrBlank().not()) { + val hdWallet = wallet.core.jni.HDWallet(currentMnemonic, "") + val evmDerivationPath = "m/44'/60'/0'/0/0" + val evmPrivateKey = hdWallet.getKeyByCurve(wallet.core.jni.Curve.SECP256K1, evmDerivationPath) + val evmPublicKey = evmPrivateKey.getPublicKeySecp256k1(false) + val evmAddress = wallet.core.jni.AnyAddress(evmPublicKey, wallet.core.jni.CoinType.ETHEREUM).description() + val jwtHash = Hash.keccak256(firebaseJwt.toByteArray(Charsets.UTF_8)) + val signatureData = evmPrivateKey.sign(jwtHash, wallet.core.jni.Curve.SECP256K1) + val evmSignature = "0x" + signatureData.joinToString("") { "%02x".format(it) } + EvmAccountInfo(eoaAddress = evmAddress, signature = evmSignature) + } else { + val privateKeyHex = cryptoProvider.getPrivateKey() + val key = PrivateKey.create(storage).apply { + val keyBytes = privateKeyHex.removePrefix("0x").hexToBytes() + importPrivateKey(keyBytes, KeyFormat.RAW) + } - // Get secp256k1 public key for EVM address derivation - val evmPublicKeyBytes = key.publicKey(SigningAlgorithm.ECDSA_secp256k1) - if (evmPublicKeyBytes != null) { - // Derive EVM address from public key using Keccak256 - val publicKeyForHash = if (evmPublicKeyBytes.size == 65 && evmPublicKeyBytes[0] == 0x04.toByte()) { - evmPublicKeyBytes.copyOfRange(1, evmPublicKeyBytes.size) + // Get secp256k1 public key for EVM address derivation + val evmPublicKeyBytes = key.publicKey(SigningAlgorithm.ECDSA_secp256k1) + if (evmPublicKeyBytes != null) { + // Derive EVM address from public key using Keccak256 + val publicKeyForHash = if (evmPublicKeyBytes.size == 65 && evmPublicKeyBytes[0] == 0x04.toByte()) { + evmPublicKeyBytes.copyOfRange(1, evmPublicKeyBytes.size) + } else { + evmPublicKeyBytes + } + val addressHash = Hash.keccak256(publicKeyForHash) + val evmAddress = "0x" + addressHash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } + logd("KeyStoreRestoreViewModel", "Derived EVM address: $evmAddress") + + // Sign Firebase JWT for EVM with secp256k1 key + val dataToSign = DomainTag.User.bytes + firebaseJwt.toByteArray(Charsets.UTF_8) + val evmSignatureBytes = key.sign(dataToSign, SigningAlgorithm.ECDSA_secp256k1, HashingAlgorithm.SHA2_256) + val evmSignature = evmSignatureBytes.joinToString("") { "%02x".format(it) } + logd("KeyStoreRestoreViewModel", "Generated EVM signature, length: ${evmSignature.length}") + + EvmAccountInfo( + eoaAddress = evmAddress, + signature = evmSignature + ) } else { - evmPublicKeyBytes + logd("KeyStoreRestoreViewModel", "Could not derive secp256k1 public key, skipping EVM account") + null } - val addressHash = Hash.keccak256(publicKeyForHash) - val evmAddress = "0x" + addressHash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } - logd("KeyStoreRestoreViewModel", "Derived EVM address: $evmAddress") - - // Sign Firebase JWT for EVM with secp256k1 key - val dataToSign = DomainTag.User.bytes + firebaseJwt.toByteArray(Charsets.UTF_8) - val evmSignatureBytes = key.sign(dataToSign, SigningAlgorithm.ECDSA_secp256k1, HashingAlgorithm.SHA2_256) - val evmSignature = evmSignatureBytes.joinToString("") { "%02x".format(it) } - logd("KeyStoreRestoreViewModel", "Generated EVM signature, length: ${evmSignature.length}") - - EvmAccountInfo( - eoaAddress = evmAddress, - signature = evmSignature - ) - } else { - logd("KeyStoreRestoreViewModel", "Could not derive secp256k1 public key, skipping EVM account") - null } + } catch (e: Exception) { logd("KeyStoreRestoreViewModel", "Error creating EVM account info: ${e.message}") null