diff --git a/app/build.gradle b/app/build.gradle index dd524b01a..6b2a882ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,7 @@ plugins { id "org.jetbrains.kotlin.plugin.serialization" version "1.9.0" id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' -// id("instabug-apm") // Disabled to fix ASM instrumentation issue +// id("luciq-apm") // Disabled to fix ASM instrumentation issue id 'com.google.devtools.ksp' id 'com.google.firebase.appdistribution' // id 'jacoco' @@ -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.3' + implementation 'com.github.onflow.flow-wallet-kit:flow-wallet-android:0.2.6' implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" /** Android Architecture **/ implementation 'androidx.core:core-ktx:1.13.1' @@ -462,8 +462,8 @@ dependencies { implementation 'com.google.firebase:firebase-appcheck-debug' /** Instabug **/ - implementation 'com.instabug.library:instabug:15.0.1' - implementation 'com.instabug.library:instabug-with-okhttp-interceptor:15.0.1' + implementation 'ai.luciq.library:luciq:19.3.0' + implementation 'ai.luciq.library:luciq-with-okhttp-interceptor:19.3.0' /** firebase **/ implementation "dev.chrisbanes:insetter-ktx:0.3.1" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f63481a5..e79b7e5fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,6 +76,7 @@ android:name=".reactnative.ReactNativeActivity" android:exported="false" android:launchMode="singleTop" + android:screenOrientation="portrait" android:theme="@style/AppTheme" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:windowSoftInputMode="adjustResize" @@ -84,21 +85,25 @@ + android:launchMode="singleTop" + android:screenOrientation="portrait"/> + android:name=".page.nft.search.NFTSearchActivity" + android:screenOrientation="portrait"/> @@ -412,6 +421,7 @@ (android.R.id.content) ?: return + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> + val navBar = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + contentView.updatePadding(bottom = navBar.bottom) + insets // Return without consuming — let normal child dispatch continue + } + // Trigger an immediate insets dispatch now that the listener is set up and + // setDecorFitsSystemWindows(false) is in its final state. + ViewCompat.requestApplyInsets(window.decorView) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // Re-trigger insets dispatch once the window is actually visible and has valid insets. + // requestApplyInsets() in onPostCreate fires before the window is shown (pre-WindowManager + // attach), so it may receive zero insets. Activities without UltimateBarX have no other + // insets trigger, so this guarantees the listener fires at least once with real values. + if (hasFocus) { + ViewCompat.requestApplyInsets(window.decorView) + } + } + override fun getDelegate() = BaseContextWrappingDelegate(super.getDelegate()) override fun onResume() { diff --git a/app/src/main/java/com/flowfoundation/wallet/cache/AccountCacheManager.kt b/app/src/main/java/com/flowfoundation/wallet/cache/AccountCacheManager.kt index 110fa0706..a34144041 100644 --- a/app/src/main/java/com/flowfoundation/wallet/cache/AccountCacheManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/cache/AccountCacheManager.kt @@ -5,10 +5,13 @@ import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager.walletNodes import com.flowfoundation.wallet.manager.account.AccountWalletManager import com.flowfoundation.wallet.manager.app.chainNetWorkString +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.manager.walletdata.FlowWallet import com.flowfoundation.wallet.utils.* import com.flowfoundation.wallet.utils.error.AccountError import com.flowfoundation.wallet.utils.error.ErrorReporter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import java.io.File @@ -18,6 +21,7 @@ object AccountCacheManager{ private val TAG = AccountCacheManager::class.java.simpleName private val file by lazy { File(ACCOUNT_PATH, "${"accounts".hashCode()}") } private val backupFile by lazy { File(ACCOUNT_PATH, "${"accounts_backup".hashCode()}") } + private val writeMutex = Mutex() @WorkerThread fun read(): List? { @@ -29,11 +33,13 @@ object AccountCacheManager{ logd(TAG, "Successfully read from primary cache: ${primaryResult.size} accounts") // Update backup if primary is good ioScope { - try { - backupFile.writeText(file.readText()) - logd(TAG, "Updated backup from validated primary cache") - } catch (e: Exception) { - loge(TAG, "Failed to update backup: $e") + writeMutex.withLock { + try { + backupFile.writeText(file.readText()) + logd(TAG, "Updated backup from validated primary cache") + } catch (e: Exception) { + loge(TAG, "Failed to update backup: $e") + } } } return primaryResult @@ -44,13 +50,15 @@ object AccountCacheManager{ val backupResult = readFromFile(backupFile) if (backupResult != null) { logd(TAG, "Successfully recovered from backup cache: ${backupResult.size} accounts") - // Restore primary from backup + // Restore primary from backup — must go through mutex to avoid racing with cache() ioScope { - try { - file.writeText(backupFile.readText()) - logd(TAG, "Restored primary from repaired backup cache") - } catch (e: Exception) { - loge(TAG, "Failed to restore primary from backup: $e") + writeMutex.withLock { + try { + backupFile.copyTo(file, overwrite = true) + logd(TAG, "Restored primary from repaired backup cache") + } catch (e: Exception) { + loge(TAG, "Failed to restore primary from backup: $e") + } } } return backupResult @@ -66,12 +74,9 @@ object AccountCacheManager{ return null } - var str = cacheFile.read() + val str = cacheFile.read() logd(TAG, "Reading from ${cacheFile.name}: ${str.length} characters, isBlank=${str.isBlank()}") - // Migrate old field names to new format - str = migrateOldFieldNames(str) - if (str.isBlank()) { logd(TAG, "Warning: Cache file ${cacheFile.name} exists but is empty") return null @@ -91,9 +96,14 @@ object AccountCacheManager{ // Validate account data val validAccounts = result.filter { account -> + val uid = account.wallet?.id ?: "" val isValid = account.userInfo.username.isNotBlank() && - (!account.keyStoreInfo.isNullOrBlank() || !account.prefix - .isNullOrBlank() || AccountWalletManager.isHDWallet(account.wallet?.id ?: "")) + (!account.keyStoreInfo.isNullOrBlank() || + !account.prefix.isNullOrBlank() || + AccountWalletManager.isHDWallet(uid) || + KeyStorageManager.hasSeedPhrase(uid) || + KeyStorageManager.hasPrivateKey(uid) || + KeyStorageManager.hasAndroidKeystorePrefix(uid)) if (!isValid) { logd(TAG, "Invalid account found: ${account.userInfo.username}") } @@ -127,19 +137,12 @@ object AccountCacheManager{ } fun cache(data: List) { - logd(TAG, "cache() called with ${data.size} accounts") - logd(TAG, "cache() called with accounts: $data") - if (data.isEmpty()) { - logd(TAG, "Warning: Caching empty accounts list") - } else { - logd(TAG, "Caching accounts with usernames: ${data.map { it.userInfo.username }}") - } + logd(TAG, "cache() called with ${data.size} accounts: ${data.map { it.userInfo.username }}") ioScope { - try { - cacheSync(data) - // Create backup copy only after verifying main file integrity - if (file.exists() && file.length() > 0) { - // Verify the written data is valid before creating backup + writeMutex.withLock { + try { + cacheSync(data) + // Create backup copy only after verifying main file integrity val writtenData = readFromFile(file) if (writtenData != null && writtenData.size == data.size) { backupFile.writeText(file.readText()) @@ -147,41 +150,24 @@ object AccountCacheManager{ } else { loge(TAG, "Main cache validation failed, not creating backup. Expected: ${data.size}, Got: ${writtenData?.size}") } + } catch (e: Exception) { + loge(TAG, "Error caching accounts: $e") } - } catch (e: Exception) { - loge(TAG, "Error caching accounts: $e") } } } private fun cacheSync(data: List) { - try { - val str = Json.encodeToString(ListSerializer(Account.serializer()), data) - - // Validate JSON before writing - try { - Json.decodeFromString(ListSerializer(Account.serializer()), str) - } catch (e: Exception) { - loge(TAG, "Generated invalid JSON, not writing to cache: $e") - return - } - + val str = Json.encodeToString(ListSerializer(Account.serializer()), data) + // Atomic write: write to a temp file first, then rename to avoid partial writes + val tempFile = File(ACCOUNT_PATH, "${"accounts".hashCode()}.tmp") + tempFile.writeText(str) + if (!tempFile.renameTo(file)) { + // renameTo can fail across filesystems; fall back to direct write + tempFile.delete() str.saveToFile(file) - logd(TAG, "Successfully cached ${data.size} accounts") - } catch (e: Exception) { - loge(TAG, "Error during cacheSync: $e") - loge(TAG, "Exception type: ${e.javaClass.name}") - e.printStackTrace() - // Log account structure for debugging - if (data.isNotEmpty()) { - val firstAccount = data.first() - loge(TAG, "First account structure: userInfo=${firstAccount.userInfo.javaClass.name}, " + - "wallet=${firstAccount.wallet?.javaClass?.name}, " + - "walletEmojiList=${firstAccount.walletEmojiList?.javaClass?.name}, " + - "walletNodes=${firstAccount.walletNodes.javaClass.name}") - } - throw e } + logd(TAG, "Successfully cached ${data.size} accounts") } fun clearCache() { @@ -196,29 +182,4 @@ object AccountCacheManager{ } } - /** - * Migrates old field names in cached JSON to new format. - * This handles the transition from old cache format to new serialization format. - * - * Field mappings: - * - "isPrivate" -> "private" (UserInfoData field name change) - * - "chainId" -> "chain_id" (BlockchainData field name change) - */ - private fun migrateOldFieldNames(json: String): String { - var migrated = json - - // Migrate UserInfoData.isPrivate to private - if (migrated.contains("\"isPrivate\":")) { - logd(TAG, "Migrating old field name: isPrivate -> private") - migrated = migrated.replace("\"isPrivate\":", "\"private\":") - } - - // Migrate BlockchainData.chainId to chain_id - if (migrated.contains("\"chainId\":")) { - logd(TAG, "Migrating old field name: chainId -> chain_id") - migrated = migrated.replace("\"chainId\":", "\"chain_id\":") - } - - return migrated - } } diff --git a/app/src/main/java/com/flowfoundation/wallet/firebase/FirebaseUtils.kt b/app/src/main/java/com/flowfoundation/wallet/firebase/FirebaseUtils.kt index 51149ba4e..4105a7392 100644 --- a/app/src/main/java/com/flowfoundation/wallet/firebase/FirebaseUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/firebase/FirebaseUtils.kt @@ -1,7 +1,7 @@ package com.flowfoundation.wallet.firebase import android.app.Application -import com.google.firebase.BuildConfig +import com.flowfoundation.wallet.BuildConfig import com.google.firebase.FirebaseApp import com.google.firebase.appcheck.FirebaseAppCheck import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory @@ -32,4 +32,4 @@ private fun setupAppCheck() { logd(TAG, "AppCheck token: $token") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/firebase/auth/FirebaseAuth.kt b/app/src/main/java/com/flowfoundation/wallet/firebase/auth/FirebaseAuth.kt index b19cc0b70..47d08e22c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/firebase/auth/FirebaseAuth.kt +++ b/app/src/main/java/com/flowfoundation/wallet/firebase/auth/FirebaseAuth.kt @@ -10,9 +10,32 @@ import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.uiScope import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock private const val TAG = "FirebaseAuth" +// Serialises all "currentUser == null → sign in" operations to prevent +// a concurrent getFirebaseJwt() from racing with setToAnonymous(). +private val authStateMutex = Mutex() + +/** + * Signs out the current non-anonymous user and signs in anonymously. + * Holds [authStateMutex] during signOut + signInAnonymously so that any + * concurrent [getFirebaseJwt] call waits instead of submitting a second + * signInAnonymously task that could later overwrite the custom-token user. + */ +suspend fun setToAnonymous(): Boolean { + if (!isAnonymousSignIn()) { + authStateMutex.withLock { + Firebase.auth.signOut() + signInAnonymously() + } + return isAnonymousSignIn() + } + return true +} + typealias FirebaseAuthCallback = (isSuccessful: Boolean, exception: Exception?) -> Unit fun isAnonymousSignIn(): Boolean { @@ -28,50 +51,20 @@ fun isUserSignIn(): Boolean { fun firebaseCustomLogin(token: String, onComplete: FirebaseAuthCallback) { logd(TAG, "=== firebaseCustomLogin START ===") val auth = Firebase.auth - val currentUser = auth.currentUser - logd(TAG, "Current Firebase user: ${currentUser?.uid ?: "null"}") - - // Always sign in with the new custom token, even if there's already a user - // This ensures we switch to the newly registered user - // Note: signInWithCustomToken automatically replaces the current user, no need to sign out first - if (currentUser != null) { - logd(TAG, "User already signed in, UID: ${currentUser.uid}, isAnonymous: ${currentUser.isAnonymous}") - logd(TAG, "Will replace with new user from custom token...") - } + logd(TAG, "Current Firebase user: ${auth.currentUser?.uid ?: "null"}") - logd(TAG, "Attempting to sign in with custom token (length: ${token.length})") auth.signInWithCustomToken(token).addOnCompleteListener { task -> - logd(TAG, "signInWithCustomToken completed - success: ${task.isSuccessful}") - if (!task.isSuccessful) { - logd(TAG, "ERROR: signInWithCustomToken failed - ${task.exception?.message}") - } - - ioScope { - clearUserCache() - if (task.isSuccessful) { - val newUser = auth.currentUser - logd(TAG, "Sign in successful, new user UID: ${newUser?.uid}") - logd(TAG, "Requesting ID token refresh") - - newUser?.getIdToken(true)?.addOnSuccessListener { result -> - logd(TAG, "ID token obtained successfully") - uiScope { - onComplete.invoke(true, null) - } - getFirebaseMessagingToken() - }?.addOnFailureListener { e -> - logd(TAG, "ERROR: Failed to get ID token - ${e.message}") - uiScope { onComplete.invoke(false, e) } - } - } else { - logd(TAG, "ERROR: Task unsuccessful, calling failure callback") - val exception = task.exception - logd(TAG, "Exception type: ${exception?.javaClass?.simpleName}") - logd(TAG, "Exception message: ${exception?.message}") - uiScope { - onComplete.invoke(false, exception) - } + if (task.isSuccessful) { + logd(TAG, "Sign in successful, new user UID: ${auth.currentUser?.uid}") + onComplete.invoke(true, null) + // Background cleanup — runs after the caller's callback has already returned. + ioScope { + clearUserCache() + getFirebaseMessagingToken() } + } else { + logd(TAG, "ERROR: signInWithCustomToken failed - ${task.exception?.message}") + onComplete.invoke(false, task.exception) } } } @@ -86,7 +79,13 @@ suspend fun getFirebaseJwt(forceRefresh: Boolean = false) = suspendCoroutine { c ioScope { val auth = Firebase.auth if (auth.currentUser == null) { - signInAnonymously() + authStateMutex.withLock { + // Re-check after acquiring lock: setToAnonymous() may have already + // completed and set currentUser while we were waiting. + if (Firebase.auth.currentUser == null) { + signInAnonymously() + } + } } val user = auth.currentUser diff --git a/app/src/main/java/com/flowfoundation/wallet/firebase/messaging/FirebaseMessaging.kt b/app/src/main/java/com/flowfoundation/wallet/firebase/messaging/FirebaseMessaging.kt index b0ea3cbe4..f4fc52985 100644 --- a/app/src/main/java/com/flowfoundation/wallet/firebase/messaging/FirebaseMessaging.kt +++ b/app/src/main/java/com/flowfoundation/wallet/firebase/messaging/FirebaseMessaging.kt @@ -13,7 +13,7 @@ import com.flowfoundation.wallet.utils.isDev import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.updatePushToken -import com.instabug.chat.Replies +import ai.luciq.chat.Replies private const val TAG = "FirebaseMessaging" diff --git a/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt b/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt index c27e2063d..f66036b5c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/instabug/InstabugUtils.kt @@ -7,18 +7,18 @@ import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.instabug.library.Feature -import com.instabug.library.Instabug -import com.instabug.library.IssueType -import com.instabug.library.MaskingType -import com.instabug.library.ReproConfigurations -import com.instabug.library.ReproMode -import com.instabug.library.invocation.InstabugInvocationEvent -import com.instabug.library.ui.onboarding.WelcomeMessage +import ai.luciq.library.Feature +import ai.luciq.library.Luciq +import ai.luciq.library.IssueType +import ai.luciq.library.MaskingType +import ai.luciq.library.ReproConfigurations +import ai.luciq.library.ReproMode +import ai.luciq.library.invocation.LuciqInvocationEvent +import ai.luciq.library.ui.onboarding.WelcomeMessage import com.flowfoundation.wallet.utils.isDev import com.flowfoundation.wallet.utils.isTesting -import com.instabug.bug.BugReporting -import com.instabug.bug.ProactiveReportingConfigs +import ai.luciq.bug.BugReporting +import ai.luciq.bug.ProactiveReportingConfigs fun instabugInitialize(application: Application) { @@ -26,11 +26,11 @@ fun instabugInitialize(application: Application) { return } if (isDev()) { - Instabug.Builder(application, BuildConfig.INSTABUG_TOKEN_DEV) + Luciq.Builder(application, BuildConfig.INSTABUG_TOKEN_DEV) .setInvocationEvents( - InstabugInvocationEvent.SCREENSHOT, - InstabugInvocationEvent.SHAKE, - InstabugInvocationEvent.FLOATING_BUTTON) + LuciqInvocationEvent.SCREENSHOT, + LuciqInvocationEvent.SHAKE, + LuciqInvocationEvent.FLOATING_BUTTON) .setTrackingUserStepsState(Feature.State.ENABLED) .setReproConfigurations( ReproConfigurations.Builder() @@ -38,12 +38,12 @@ fun instabugInitialize(application: Application) { .build()) .setAutoMaskScreenshotsTypes(MaskingType.MASK_NOTHING) .build() - Instabug.setWelcomeMessageState(WelcomeMessage.State.BETA) + Luciq.setWelcomeMessageState(WelcomeMessage.State.BETA) } else { - Instabug.Builder(application, BuildConfig.INSTABUG_TOKEN_PROD) + Luciq.Builder(application, BuildConfig.INSTABUG_TOKEN_PROD) .setInvocationEvents( - InstabugInvocationEvent.SCREENSHOT, - InstabugInvocationEvent.SHAKE + LuciqInvocationEvent.SCREENSHOT, + LuciqInvocationEvent.SHAKE ) .setTrackingUserStepsState(Feature.State.ENABLED) .setReproConfigurations( @@ -52,9 +52,9 @@ fun instabugInitialize(application: Application) { .build()) .setAutoMaskScreenshotsTypes(MaskingType.MASK_NOTHING) .build() - Instabug.setWelcomeMessageState(WelcomeMessage.State.DISABLED) + Luciq.setWelcomeMessageState(WelcomeMessage.State.DISABLED) } - Instabug.onReportSubmitHandler { report -> + Luciq.onReportSubmitHandler { report -> firebaseUid()?.let { report.setUserAttribute("uid", it) } @@ -68,7 +68,7 @@ fun instabugInitialize(application: Application) { childAccounts.toString() ) report.setUserAttribute("COA", EVMWalletManager.getEVMAddress().orEmpty()) - report.setUserAttribute("EOA", WalletManager.getEOAAddress().orEmpty()) + report.setUserAttribute("EOA", WalletManager.getEOAAddresses().toString()) report.setUserAttribute("Network", chainNetWorkString()) } val configuration = ProactiveReportingConfigs.Builder() diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt index 27d6975a1..22b2c3583 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt @@ -41,6 +41,7 @@ object LaunchManager { application.startServiceSafe(Intent(application, MessagingService::class.java)) PageLifecycleObserver.init(application) safeRun { System.loadLibrary("TrustWalletCore") } + safeRun { instabugInitialize(application) } refreshChainNetwork { safeRun { MixpanelManager.init(application) } safeRun { WalletConnect.init(application) } @@ -48,7 +49,6 @@ object LaunchManager { safeRun { FlowCadenceApi.refreshConfig() } safeRun { asyncInit() } safeRun { firebaseInitialize(application) } - safeRun { instabugInitialize(application) } safeRun { crowdinInitialize(application) } safeRun { setNightMode() } safeRun { runWorker() } 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 5d3691f83..c185c7f6c 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 @@ -8,8 +8,7 @@ import com.flowfoundation.wallet.cache.AccountCacheManager import com.flowfoundation.wallet.cache.UserPrefixCacheManager import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.firebase.auth.getFirebaseJwt -import com.flowfoundation.wallet.firebase.auth.isAnonymousSignIn -import com.flowfoundation.wallet.firebase.auth.signInAnonymously +import com.flowfoundation.wallet.firebase.auth.setToAnonymous import com.flowfoundation.wallet.firebase.messaging.uploadPushToken import com.flowfoundation.wallet.manager.account.model.LocalSwitchAccount import com.flowfoundation.wallet.manager.app.chainNetWorkString @@ -22,9 +21,7 @@ import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.clearUserCache import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider import com.flowfoundation.wallet.network.model.AccountKey -import com.flowfoundation.wallet.network.model.EvmAccountInfo import com.flowfoundation.wallet.network.model.FlowAccountInfo -import com.flowfoundation.wallet.network.model.LoginRequest import com.flowfoundation.wallet.network.model.LoginV4Request import com.flowfoundation.wallet.network.model.UserInfoData import com.flowfoundation.wallet.network.model.WalletListData @@ -42,8 +39,6 @@ import com.flowfoundation.wallet.utils.setRegistered import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.utils.uiScope import com.flowfoundation.wallet.wallet.Wallet -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.serialization.Serializable @@ -56,7 +51,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 { @@ -66,7 +64,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 @@ -84,7 +81,6 @@ object AccountManager { logd(TAG, "Starting AccountManager initialization") accounts.clear() userPrefixes.clear() - switchAccounts.clear() currentAccount = null ioScope { @@ -129,6 +125,12 @@ 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() } + // Load emoji list for current account before building wallet data, + // otherwise getEmojiByAddress() sees an empty list and re-randomizes emojis. + AccountEmojiManager.init() // Update Accounts info with WalletDataManager WalletDataManager.updateWalletData() @@ -144,7 +146,6 @@ object AccountManager { // Clear potentially corrupted state accounts.clear() userPrefixes.clear() - switchAccounts.clear() currentAccount = null // Initialization failed - user needs to login/restore @@ -154,36 +155,58 @@ object AccountManager { fun getSwitchAccountList(): List { logd(TAG, "getSwitchAccountList() called") - logd(TAG, "Current accounts: $accounts") - logd(TAG, "Current switchAccounts: $switchAccounts") + logd(TAG, "Current accounts: ${accounts.map { it.userInfo.username }}") 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) + val address = KeyStorageManager.getWalletAddress(uid) ?: "" + LocalSwitchAccount( + username = address, + address = address, + userId = uid, + prefix = akPrefix + ) + } + } + fun add(account: Account, uid: String? = null) { // Clear WalletManager state before setting new account WalletManager.clear() currentAccount = account - logd(TAG, "Account added. Current account is now: $currentAccount") + logd(TAG, "Account added. Current account is now: ${currentAccount?.userInfo?.username.orEmpty()}") accounts.removeAll { it.userInfo.username == account.userInfo.username } accounts.add(account) accounts.forEach { @@ -195,6 +218,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() @@ -243,9 +268,6 @@ object AccountManager { logd(TAG, "Removing active account and clearing all related state") - // Set Firebase to anonymous before clearing state - setToAnonymous() - // Clear the account from the list val account = accounts.removeAt(index) logd(TAG, "Removed account: ${account.userInfo.username}") @@ -263,7 +285,6 @@ object AccountManager { AccountCacheManager.cache(Accounts().apply { addAll(accounts) }) logd(TAG, "Cleared account cache") - // Clear WalletManager state try { WalletManager.clear() @@ -300,14 +321,20 @@ object AccountManager { setUploadedAddressSet(emptySet()) logd(TAG, "Cleared uploaded address set") - uiScope { - // Clear user cache - clearUserCache() - logd(TAG, "Cleared user cache") - - // Navigate to main activity (which should show the get started screen) - logd(TAG, "Relaunching MainActivity after account reset") - MainActivity.relaunch(Env.getApp(), true) + val nextAccount = accounts.firstOrNull() + if (nextAccount != null) { + // Switch to the first remaining account so Firebase re-authenticates properly. + // setToAnonymous() is called inside switchAccount() before the new login. + logd(TAG, "Remaining accounts found, switching to: ${nextAccount.userInfo.username}") + switch(nextAccount) {} + } else { + // No remaining accounts — go back to get started screen. + setToAnonymous() + uiScope { + clearUserCache() + logd(TAG, "Relaunching MainActivity after account reset") + MainActivity.relaunch(Env.getApp(), true) + } } } } @@ -415,14 +442,15 @@ object AccountManager { } fun list(): List { - logd(TAG, "list() called. Accounts: $accounts") + logd(TAG, "list() called. Accounts: ${accounts.map { it.userInfo.username }}") return accounts } + @Volatile private var isSwitching = false fun switch(account: Account, onFinish: () -> Unit) { - logd(TAG, "switch() called. Switching to account: $account") + logd(TAG, "switch() called. Switching to account: ${account.userInfo.username}") // Check if we're already on this account if (account.isActive && currentAccount?.userInfo?.username == account.userInfo.username) { @@ -458,16 +486,22 @@ 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?.userInfo?.username}") accounts.forEach { it.isActive = it.userInfo.username == account.userInfo.username } AccountCacheManager.cache(Accounts().apply { addAll(accounts) }) AccountEmojiManager.init() + // Resume HD wallet state now that currentAccount is updated. + // Only needed for mnemonic accounts (no prefix, no keyStoreInfo). + if (account.prefix == null && account.keyStoreInfo == null) { + Wallet.store().resume() + } uiScope { clearUserCache() MainActivity.relaunch(Env.getApp(), true) @@ -477,7 +511,7 @@ object AccountManager { loge(TAG, "Account switch failed, showing error toast") toast(msgRes = R.string.resume_login_error, duration = Toast.LENGTH_LONG) } - logd(TAG, "switch() completed. Current account: $currentAccount") + logd(TAG, "switch() completed. Current account: ${currentAccount?.userInfo?.username}") onFinish() } } @@ -492,7 +526,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) @@ -533,8 +569,10 @@ object AccountManager { logd(TAG, " Sign Algorithm: ${cryptoProvider.getSignatureAlgorithm()}") logd(TAG, " Key Weight: ${cryptoProvider.getKeyWeight()}") - // Get JWT with force refresh to avoid token expiration issues - val jwt = getFirebaseJwt(true) + // Use the cached token from the anonymous sign-in done in setToAnonymous(). + // forceRefresh=true would cause two separate network fetches (here and in HeaderInterceptor), + // returning tokens with different iat values, making signature verification fail on the server. + val jwt = getFirebaseJwt() logd(TAG, "Retrieved JWT for account switch (length: ${jwt.length})") val publicKey = cryptoProvider.getPublicKey() @@ -580,9 +618,6 @@ object AccountManager { firebaseLogin(resp.data.customToken) { isSuccess -> if (isSuccess) { setRegistered() - if (account.prefix == null && account.keyStoreInfo == null) { - Wallet.store().resume() - } callback.invoke(true) } else { loge(tag = "SWITCH_ACCOUNT", msg = "get firebase login failed :: ${resp.data.customToken}") @@ -619,16 +654,8 @@ object AccountManager { } } - private suspend fun setToAnonymous(): Boolean { - if (!isAnonymousSignIn()) { - Firebase.auth.signOut() - return signInAnonymously() - } - return true - } - private fun dispatchListeners(account: Account) { - logd(TAG, "dispatchListeners: $account") + logd(TAG, "dispatchListeners: ${account.userInfo.username}") uiScope { listeners.removeAll { it.get() == null } listeners.forEach { it.get()?.onAccountUpdate(account) } @@ -690,8 +717,10 @@ object AccountManager { logd(TAG, " Sign Algorithm: ${cryptoProvider.getSignatureAlgorithm()}") logd(TAG, " Key Weight: ${cryptoProvider.getKeyWeight()}") - // Get JWT with force refresh to avoid token expiration issues - val jwt = getFirebaseJwt(true) + // Use the cached token from the anonymous sign-in done in setToAnonymous(). + // forceRefresh=true would cause two separate network fetches (here and in HeaderInterceptor), + // returning tokens with different iat values, making signature verification fail on the server. + val jwt = getFirebaseJwt() logd(TAG, "Retrieved JWT for local account switch (length: ${jwt.length})") val publicKey = cryptoProvider.getPublicKey() @@ -731,21 +760,43 @@ object AccountManager { val resp = service.loginV4(loginRequest) if (resp.data?.customToken.isNullOrBlank()) { loge(tag = "SWITCH_ACCOUNT", msg = "get customToken failed :: ${resp.data?.customToken}") + loge(tag = "SWITCH_ACCOUNT", msg = "Response status: ${resp.status}, message: ${resp.message}") callback.invoke(false) } else { firebaseLogin(resp.data.customToken) { isSuccess -> if (isSuccess) { setRegistered() - if (switchAccount.prefix == null) { - Wallet.store().resume() - } else { - firebaseUid()?.let { userId -> - userPrefixes.removeAll { it.userId == userId} - userPrefixes.add(UserPrefix(userId, switchAccount.prefix)) - UserPrefixCacheManager.cache(UserPrefixes().apply { addAll(userPrefixes) }) + // Fetch user info and persist the account so it survives MainActivity relaunch. + // Without this, AccountManager.init() reads an empty cache after relaunch. + ioScope { + try { + val userInfo = service.userInfo().data + val userId = firebaseUid() ?: "" + clearUserCache() + add(Account( + userInfo = userInfo, + wallet = WalletListData(id = userId, username = userInfo.username, wallets = null), + prefix = switchAccount.prefix + )) + logd(TAG, "LocalSwitchAccount: account stored for uid: $userId") + + if (switchAccount.prefix != null) { + // Hardware-backed key: persist prefix so CryptoProviderManager + // can reconstruct the provider after relaunch. + userPrefixes.removeAll { it.userId == userId } + userPrefixes.add(UserPrefix(userId, switchAccount.prefix)) + UserPrefixCacheManager.cache(UserPrefixes().apply { addAll(userPrefixes) }) + } else if (!KeyStorageManager.hasPrivateKey(switchAccount.userId ?: "")) { + // HD wallet (seed phrase) account only — not private-key import. + // Mirrors the condition in switchAccount(Account): + // account.prefix == null && account.keyStoreInfo == null + Wallet.store().resume() + } + } catch (e: Exception) { + loge(TAG, "LocalSwitchAccount: failed to fetch/store user info: ${e.message}") } + callback.invoke(true) } - callback.invoke(true) } else { loge(tag = "SWITCH_ACCOUNT", msg = "get firebase login failed :: ${resp.data.customToken}") callback.invoke(false) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiManager.kt index bc0f4196d..c25bae319 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiManager.kt @@ -81,7 +81,7 @@ object CadenceApiManager { } } ?: "" logd(TAG, "Response body length: ${responseBody.length}") - + val isSignatureValid = try { verifySignature(signature, responseBody.toByteArray()) } catch (e: Exception) { @@ -90,7 +90,7 @@ object CadenceApiManager { ErrorReporter.reportWithMixpanel(CadenceError.SIGNATURE_VERIFICATION_ERROR, e) false } - + if (!isSignatureValid) { loge(TAG, "Invalid script signature - continuing with cached scripts") loge(TAG, "Response preview (first 200 chars): ${responseBody.take(200)}") @@ -138,40 +138,40 @@ object CadenceApiManager { loge(TAG, "Empty signature provided for verification") return false } - + // Clean the signature - remove any whitespace and non-hex characters val cleanSignature = signature.trim().replace(Regex("[^0-9a-fA-F]"), "") - + if (cleanSignature.isBlank()) { loge(TAG, "Signature contains no valid hex characters: ${signature.take(50)}...") return false } - + // Check if signature contains only valid hex characters val hexPattern = "^[0-9a-fA-F]+$".toRegex() if (!hexPattern.matches(cleanSignature)) { loge(TAG, "Invalid signature format after cleaning - contains non-hex characters: ${cleanSignature.take(50)}...") return false } - + // Validate signature length (should be even number for hex) if (cleanSignature.length % 2 != 0) { loge(TAG, "Invalid signature length - not even number of hex chars: ${cleanSignature.length}") return false } - + // Validate expected signature length for ECDSA (typically 64 bytes = 128 hex chars) if (cleanSignature.length != 128) { logd(TAG, "Warning: Unexpected signature length: ${cleanSignature.length} (expected 128)") } - + val hashedData = Hash.sha256(data) val pubKeyBytes = BuildConfig.X_SIGNATURE_KEY.decodeHex().toByteArray() val public = PublicKey(pubKeyBytes, PublicKeyType.NIST256P1EXTENDED) - + val signatureBytes = cleanSignature.decodeHex().toByteArray() val isValid = public.verify(signatureBytes, hashedData) - + logd(TAG, "Signature verification result: $isValid") isValid } catch (e: IllegalArgumentException) { @@ -308,6 +308,16 @@ object CadenceApiManager { return script } + fun getCadenceLostAndFoundScript(method: String): String { + val script = getCadenceScript()?.lostAndFound?.get(method)?.decodeBase64()?.utf8() + if (script.isNullOrBlank()) { + loge(TAG, "Failed to get lostAndFound script for method: $method, falling back to " + + "assets") + return getFallbackScriptFromAssets(method, "lostAndFound") + } + return script + } + private fun getFallbackScriptFromAssets(method: String, category: String): String { return try { val assetsData = Gson().fromJson( @@ -318,7 +328,7 @@ object CadenceApiManager { NETWORK_TESTNET -> assetsData.data?.scripts?.testnet else -> assetsData.data?.scripts?.mainnet } - + val scriptContent = when (category) { "basic" -> script?.basic?.get(method) "collection" -> script?.collection?.get(method) @@ -331,9 +341,10 @@ object CadenceApiManager { "nft" -> script?.nft?.get(method) "swap" -> script?.swap?.get(method) as? String "bridge" -> script?.bridge?.get(method) + "lostAndFound" -> script?.lostAndFound?.get(method) else -> null }?.decodeBase64()?.utf8() - + scriptContent ?: run { loge(TAG, "No fallback script found for method: $method, category: $category") "" diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiModel.kt b/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiModel.kt index 5378d9b84..5156de1c9 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/cadence/CadenceApiModel.kt @@ -31,5 +31,6 @@ data class CadenceScript( val evm: Map?, val nft: Map?, val swap: Map?, - val bridge: Map? -) \ No newline at end of file + val bridge: Map?, + val lostAndFound: Map? +) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt b/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt index d1b9a91db..2661d3e03 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt @@ -54,6 +54,10 @@ object AppConfig { fun checkBloctoKeyRotation() = isDev() || isTesting() || (config().getFeatures().bloctoKeyRotation ?: false) + fun canCreateNewAccount() = isDev() || isTesting() || (config().getFeatures().createNewAccount ?: false) + + fun canClaimInbox() = isDev() || isTesting() || (config().getFeatures().cadenceInbox ?: false) + fun addressRegistry(network: Int): Map { return when (network) { NETWORK_TESTNET -> flowAddressRegistry().testnet @@ -228,7 +232,11 @@ private data class Features( @SerializedName("blocto_key_rotation") val bloctoKeyRotation: Boolean?, @SerializedName("coa_migration") - val coaMigration: Boolean? + val coaMigration: Boolean?, + @SerializedName("create_new_account") + val createNewAccount: Boolean?, + @SerializedName("cadence_inbox") + val cadenceInbox: Boolean? ) private data class Payer( diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/emoji/AccountEmojiManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/emoji/AccountEmojiManager.kt index aa919a5fe..a92633c78 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/emoji/AccountEmojiManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/emoji/AccountEmojiManager.kt @@ -38,6 +38,7 @@ object AccountEmojiManager { ) } + @Synchronized fun getEmojiByAddress(address: String?): WalletEmojiInfo { val currentUserName = AccountManager.userInfo()?.username val randomEmoji = getRandomEmoji(currentUserName, address) @@ -84,7 +85,7 @@ object AccountEmojiManager { val filterEmojiList = getEmojiList().filter { emoji -> emoji.id !in idList } - return if(filterEmojiList.isEmpty()) Emoji.PENGUIN else filterEmojiList.random() + return if (filterEmojiList.isEmpty()) getEmojiList().random() else filterEmojiList.random() } fun changeEmojiInfo(userName: String, address: String, emojiId: Int, emojiName: String) { @@ -123,6 +124,61 @@ object AccountEmojiManager { } } + /** + * Get or assign an emoji for [address] within a specific account's own emoji list. + * Used for non-current accounts to avoid polluting the current account's emoji state. + * + * [emojiList] is mutated in-place when a new address is encountered and the result + * is persisted to the correct account via [AccountManager.updateWalletEmojiInfo]. + */ + @Synchronized + fun getEmojiByAddressForAccount( + address: String, + username: String, + emojiList: MutableList + ): WalletEmojiInfo { + val existing = emojiList.firstOrNull { it.address == address } + if (existing != null) return existing + + val usedIds = emojiList.map { it.emojiId } + val available = getEmojiList().filter { it.id !in usedIds } + val emoji = if (available.isEmpty()) getEmojiList().random() else available.random() + val info = WalletEmojiInfo(address, emoji.id, emoji.defaultName) + emojiList.add(info) + AccountManager.updateWalletEmojiInfo(username, emojiList.toMutableList()) + return info + } + + /** + * Remove emoji entries whose address is not in [validAddresses]. + * This prevents stale entries from exhausting the 12-emoji pool. + */ + @Synchronized + fun cleanStaleEntries(validAddresses: Set) { + val before = accountEmojiList.size + accountEmojiList.removeAll { it.address !in validAddresses } + if (accountEmojiList.size != before) { + val username = AccountManager.userInfo()?.username ?: return + AccountManager.updateWalletEmojiInfo(username, accountEmojiList.toMutableList()) + } + } + + /** + * Remove emoji entries whose address is not in [validAddresses] from the given per-account list. + */ + @Synchronized + fun cleanStaleEntriesForAccount( + validAddresses: Set, + username: String, + emojiList: MutableList + ) { + val before = emojiList.size + emojiList.removeAll { it.address !in validAddresses } + if (emojiList.size != before) { + AccountManager.updateWalletEmojiInfo(username, emojiList.toMutableList()) + } + } + fun clear() { accountEmojiList.clear() listeners.clear() diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/COAVisibilityCache.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/COAVisibilityCache.kt new file mode 100644 index 000000000..3a35f1234 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/COAVisibilityCache.kt @@ -0,0 +1,40 @@ +package com.flowfoundation.wallet.manager.evm + +import java.util.concurrent.ConcurrentHashMap + +/** + * Thread-safe singleton cache for COA (Cadence-Owned Account) EVM address visibility. + * + * Tracks which EVM addresses have been verified to have assets (FLOW balance, EVM tokens, or NFTs) + * and should be displayed in the wallet list. Each entry has a 30-minute TTL to avoid stale data. + * + * Shared across DrawerLayoutViewModel and AccountListViewModel to avoid redundant API calls. + * Cleared automatically via [com.flowfoundation.wallet.network.clearUserCache] on network/account switch. + */ +object COAVisibilityCache { + + private const val TTL_MS = 30L * 60 * 1000 // 30 minutes + + private val cache = ConcurrentHashMap() + + fun isVerified(evmAddress: String): Boolean { + val timestamp = cache[evmAddress] ?: return false + if (System.currentTimeMillis() - timestamp > TTL_MS) { + cache.remove(evmAddress) + return false + } + return true + } + + fun markVerified(evmAddress: String) { + cache[evmAddress] = System.currentTimeMillis() + } + + fun markUnverified(evmAddress: String) { + cache.remove(evmAddress) + } + + fun clear() { + cache.clear() + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt index 39355a954..2f759c9a5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/DAppEVMConnectionManager.kt @@ -1,7 +1,9 @@ package com.flowfoundation.wallet.manager.evm +import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.flowjvm.cadenceQueryCOATokenBalance import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.utils.formatPrice import com.flowfoundation.wallet.utils.Env import com.flowfoundation.wallet.utils.ioScope @@ -107,19 +109,24 @@ object DAppEVMConnectionManager { } } - // Load EOA account - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { - logd(TAG, "Found EOA account: $eoaAddress") - // EOA balance is hidden as per requirements - accounts.add(DAppEVMAccount(eoaAddress, DAppEVMAccountType.EOA, null)) + // Load all EOA accounts + val eoaWallets = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + eoaWallets.forEach { eoaWallet -> + logd(TAG, "Found EOA account: ${eoaWallet.address} (index=${eoaWallet.index})") + accounts.add(DAppEVMAccount(eoaWallet.address, DAppEVMAccountType.EOA, null)) } _availableAccounts.value = accounts logd(TAG, "Loaded ${accounts.size} available accounts") - // Set default selected account if none is selected - if (_selectedAccount.value == null && accounts.isNotEmpty()) { + // Sync with main wallet's selected address if it matches an available account + val walletSelected = WalletManager.selectedWalletAddress() + val matchingAccount = accounts.firstOrNull { it.address.equals(walletSelected, ignoreCase = true) } + if (matchingAccount != null) { + logd(TAG, "Syncing DApp selection with main wallet address: ${matchingAccount.address} (${matchingAccount.type})") + setSelectedAccount(matchingAccount) + } else if (_selectedAccount.value == null && accounts.isNotEmpty()) { // Default to COA account, fallback to first available val defaultAccount = accounts.find { it.type == DAppEVMAccountType.COA } ?: accounts.first() setSelectedAccount(defaultAccount) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt index e6532c1ee..e59d326ff 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt @@ -1,6 +1,7 @@ package com.flowfoundation.wallet.manager.evm import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.flowjvm.CadenceScript import com.flowfoundation.wallet.manager.flowjvm.cadenceBridgeChildFTFromCOA import com.flowfoundation.wallet.manager.flowjvm.cadenceBridgeChildFTToCOA @@ -71,7 +72,7 @@ object EVMWalletManager { fun getEVMAddressByAddress(address: String): String? { val evmAddress = AccountManager.walletNodes() ?.filterIsInstance() - ?.firstOrNull { it.address == address } + ?.firstOrNull { it.address.equals(address, true) } ?.linkedWallets ?.filterIsInstance() ?.firstOrNull()?.address @@ -119,7 +120,10 @@ object EVMWalletManager { } fun isEOAAddress(address: String): Boolean { - val result = address.equals(WalletManager.getEOAAddress(), ignoreCase = true) + val result = AccountManager.walletNodes() + ?.filterIsInstance() + ?.any { it.address.equals(address, ignoreCase = true) } + ?: false logd(TAG, "isEOAAddress result: $result") return result } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt index b2770eb7e..3a9effc42 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/Utils.kt @@ -5,7 +5,6 @@ import com.flowfoundation.wallet.BuildConfig import com.flowfoundation.wallet.R import com.flowfoundation.wallet.manager.app.networkChainId import com.flowfoundation.wallet.manager.app.networkRPCUrl -import com.flowfoundation.wallet.manager.config.AppConfig import com.flowfoundation.wallet.manager.config.isWrapEOATxWithCadence import com.flowfoundation.wallet.manager.flowjvm.EVM_GAS_LIMIT import com.flowfoundation.wallet.manager.flowjvm.cadenceGetNonce @@ -49,7 +48,7 @@ import wallet.core.jni.proto.Ethereum import wallet.core.jni.Hash import java.math.BigInteger -suspend fun loadInitJS(): String { +fun loadInitJS(): String { // Refresh account data first DAppEVMConnectionManager.refreshAccounts() @@ -72,7 +71,7 @@ suspend fun loadInitJS(): String { } } - WalletManager.getEOAAddress()?.let { eoaAddress -> + WalletManager.getEOAAddresses().forEach { eoaAddress -> if (eoaAddress.isNotEmpty()) { addressList.add(eoaAddress) } @@ -166,7 +165,9 @@ Unit) { // Parse transaction parameters val chainId = networkChainId().toBigInteger() val valueAmount = Numeric.decodeQuantity(transaction.value ?: "0x0") - val gasLimit = Numeric.decodeQuantity(transaction.gas ?: "0x5208") + val gasLimit = transaction.gas?.let { + Numeric.decodeQuantity(it) + } ?: EVM_GAS_LIMIT.toBigInteger() // Check for EIP-1559 parameters val maxFeePerGasStr = transaction.maxFeePerGas @@ -257,11 +258,13 @@ Unit) { return@ioScope } val singer = cryptoProvider.getSigner(HashingAlgorithm.SHA2_256) + val eoaIndex = WalletManager.getEOAIndexForAddress(address) val result = WalletManager.wallet()?.ethSignTransactionAndSendByCadence( input = input, fromAddress = address, signers = listOf(singer), - flowAddress = FlowAddress(WalletManager.getCurrentFlowWalletAddress().orEmpty()) + flowAddress = FlowAddress(WalletManager.getCurrentFlowWalletAddress().orEmpty()), + index = eoaIndex ) if (result == null) { logd("EOATransaction", "ERROR: Failed to sign transaction") @@ -275,7 +278,8 @@ Unit) { callback.invoke(txHash) } else { - val output = WalletManager.wallet()?.ethSignTransaction(input) + val eoaIndex = WalletManager.getEOAIndexForAddress(address) + val output = WalletManager.wallet()?.ethSignTransaction(input, index = eoaIndex) if (output == null) { logd("EOATransaction", "ERROR: Failed to sign transaction") diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceExecutor.kt b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceExecutor.kt index 624d2c13e..681b7f729 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceExecutor.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceExecutor.kt @@ -27,6 +27,8 @@ import com.flowfoundation.wallet.utils.reportCadenceErrorToDebugView import com.flowfoundation.wallet.wallet.toAddress import org.onflow.flow.AddressRegistry import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.infrastructure.Cadence.Companion.address +import org.onflow.flow.infrastructure.Cadence.Companion.array import org.onflow.flow.infrastructure.Cadence.Companion.string import java.math.BigDecimal @@ -58,12 +60,21 @@ suspend fun cadenceQueryAddressByDomainFind(domain: String): String? { suspend fun cadenceGetAllFlowBalance(list: List): Map? { logd(TAG, "cadenceGetAllFlowBalance()") val result = CadenceScript.CADENCE_GET_ALL_FLOW_BALANCE.executeCadence { - arg { Cadence.array(list.map { string(it) }) } + arg { array(list.map { string(it) }) } } logd(TAG, "cadenceGetAllFlowBalance response:${result?.encode()}") return result?.decode>().parseBigDecimalMap() } +suspend fun cadenceQueryInboxUnclaimedNumber(list: List): Int? { + logd(TAG, "cadenceQueryInboxUnclaimedNumber()") + val result = CadenceScript.CADENCE_BATCH_QUERY_UNCLAIMED_NUMBER.executeCadence { + arg { array(list.map { address(it) }) } + } + logd(TAG, "cadenceQueryInboxUnclaimedNumber response:${result?.encode()}") + return result?.decode() +} + suspend fun cadenceQueryTokenBalanceWithAddress(token: FungibleToken?, address: String?): BigDecimal? { if (token == null || address == null) { return null diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceScript.kt b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceScript.kt index 421a1c4ad..9af8d0138 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceScript.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/CadenceScript.kt @@ -107,7 +107,10 @@ enum class CadenceScript(val scriptId: String, val type: CadenceScriptType) { // DOMAIN CADENCE_CLAIM_INBOX_TOKEN("claimFTFromInbox", CadenceScriptType.DOMAIN), - CADENCE_CLAIM_INBOX_NFT("claimNFTFromInbox", CadenceScriptType.DOMAIN); + CADENCE_CLAIM_INBOX_NFT("claimNFTFromInbox", CadenceScriptType.DOMAIN), + + // LOST_AND_FOUND + CADENCE_BATCH_QUERY_UNCLAIMED_NUMBER("batchQueryUnclaimedNumber", CadenceScriptType.LOST_AND_FOUND); fun getScript(): String { return when (type) { @@ -124,6 +127,7 @@ enum class CadenceScript(val scriptId: String, val type: CadenceScriptType) { CadenceScriptType.NFT -> CadenceApiManager.getCadenceNFTScript(scriptId) CadenceScriptType.SWAP -> CadenceApiManager.getCadenceSwapScript(scriptId) CadenceScriptType.BRIDGE -> CadenceApiManager.getCadenceBridgeScript(scriptId) + CadenceScriptType.LOST_AND_FOUND -> CadenceApiManager.getCadenceLostAndFoundScript(scriptId) } } } @@ -141,5 +145,6 @@ enum class CadenceScriptType { EVM, NFT, SWAP, - BRIDGE; -} \ No newline at end of file + BRIDGE, + LOST_AND_FOUND; +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/transaction/Transaction.kt b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/transaction/Transaction.kt index 8f5b5a2b6..def257f73 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/transaction/Transaction.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/flowjvm/transaction/Transaction.kt @@ -20,7 +20,7 @@ import com.flowfoundation.wallet.utils.reportCadenceErrorToDebugView import com.flowfoundation.wallet.utils.safeRunSuspend import com.flowfoundation.wallet.utils.vibrateTransaction import com.flowfoundation.wallet.wallet.toAddress -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flowfoundation.wallet.manager.flow.FlowCadenceApi import org.onflow.flow.models.* import com.flowfoundation.wallet.manager.app.chainNetWorkString @@ -132,7 +132,7 @@ suspend fun sendTransaction( } if (e is InvalidKeyException) { ErrorReporter.reportCriticalWithMixpanel(WalletError.QUERY_ACCOUNT_KEY_FAILED, e) - Instabug.show() + Luciq.show() } return null } @@ -211,7 +211,7 @@ suspend fun sendBridgeTransaction( } if (e is InvalidKeyException) { ErrorReporter.reportCriticalWithMixpanel(WalletError.QUERY_ACCOUNT_KEY_FAILED, e) - Instabug.show() + Luciq.show() } return null } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/inbox/InboxManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/inbox/InboxManager.kt new file mode 100644 index 000000000..760e1f1a3 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/inbox/InboxManager.kt @@ -0,0 +1,103 @@ +package com.flowfoundation.wallet.manager.inbox + +import com.flowfoundation.wallet.manager.config.AppConfig +import com.flowfoundation.wallet.manager.flowjvm.cadenceQueryInboxUnclaimedNumber +import com.flowfoundation.wallet.manager.notification.WalletNotificationManager +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.page.notification.model.DisplayType +import com.flowfoundation.wallet.page.notification.model.Priority +import com.flowfoundation.wallet.page.notification.model.Type +import com.flowfoundation.wallet.page.notification.model.WalletNotification +import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.uiScope +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList + +object InboxManager { + + private const val TAG = "InboxManager" + + private val listeners = CopyOnWriteArrayList>() + + @Volatile + var unclaimedCount: Int = 0 + private set + + private var inboxNotification: WalletNotification? = null + + fun refresh() { + if (AppConfig.canClaimInbox().not()) { + return + } + ioScope { + if (WalletManager.isChildAccountSelected() || WalletManager.isEVMAccountSelected()) { + updateCount(0) + return@ioScope + } + + val address = WalletManager.selectedWalletAddress() + if (address.isBlank()) { + updateCount(0) + return@ioScope + } + + val count = cadenceQueryInboxUnclaimedNumber(listOf(address)) ?: 0 + logd(TAG, "refresh() unclaimed count: $count for $address") + updateCount(count) + } + } + + fun addListener(callback: OnInboxCountUpdate) { + if (listeners.firstOrNull { it.get() == callback } != null) { + return + } + uiScope { + listeners.add(WeakReference(callback)) + } + } + + fun formatBadgeCount(count: Int): String { + return if (count >= 1000) "${count / 1000}k" else count.toString() + } + + fun clearNotificationRef() { + inboxNotification = null + } + + private fun updateCount(count: Int) { + unclaimedCount = count + manageNotification(count) + dispatchListeners() + } + + private fun manageNotification(count: Int) { + if (count > 0 && inboxNotification == null) { + val notification = WalletNotification( + id = "inbox_${System.currentTimeMillis()}", + icon = "https://raw.githubusercontent.com/Outblock/Assets/main/ft/flow/logo.png", + title = "Assets available to claim", + body = "Go to view.", + priority = Priority.URGENT, + type = Type.INBOX, + displayType = DisplayType.CLICK + ) + inboxNotification = notification + WalletNotificationManager.addNotification(notification) + } else if (count <= 0 && inboxNotification != null) { + inboxNotification?.let { WalletNotificationManager.removeNotification(it) } + inboxNotification = null + } + } + + private fun dispatchListeners() { + uiScope { + listeners.removeAll { it.get() == null } + listeners.forEach { it.get()?.onInboxCountUpdate(unclaimedCount) } + } + } +} + +interface OnInboxCountUpdate { + fun onInboxCountUpdate(count: Int) +} 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..919f18e7a 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,26 @@ 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 + val address = account.firstFlowWalletAddress() ?: "" + return runBlocking { createPrivateKeyCryptoProvider(privateKey, keyWallet, address) } + } + // 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 +147,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 +220,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") @@ -203,12 +234,11 @@ object CryptoProviderManager { } // Handle wallet-specific mnemonic accounts else { - logd(TAG, " Branch: Inactive account.") - val mnemonic = if (account.isActive) { - Wallet.store().wallet().mnemonic() - } else { - AccountWalletManager.getHDWalletMnemonicByUID(account.wallet?.id ?: "") - } + logd(TAG, " Branch: Legacy mnemonic account.") + // Always look up the mnemonic by UID to avoid using the wrong account's mnemonic. + // Using Wallet.store().wallet().mnemonic() here would return the CURRENT account's + // mnemonic (the one being switched away from), not the target account's. + val mnemonic = AccountWalletManager.getHDWalletMnemonicByUID(account.wallet?.id ?: "") if (mnemonic == null) { loge(TAG, " Inactive account: Failed to get existing HDWallet by UID: ${account.wallet?.id}") @@ -234,6 +264,23 @@ 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 + val address = account.firstFlowWalletAddress() ?: "" + return runBlocking { createPrivateKeyCryptoProvider(privateKey, keyWallet, address) } + } + } + // Handle keystore-based accounts if (account.keyStoreInfo.isNullOrBlank().not()) { PrivateKeyStoreCryptoProvider(account.keyStoreInfo!!) @@ -256,10 +303,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 +416,33 @@ 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 + val address = switchAccount.address + return runBlocking { createPrivateKeyCryptoProvider(privateKey, keyWallet, address) } + } + } + // 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 +450,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 } @@ -389,7 +463,8 @@ object CryptoProviderManager { ) as KeyWallet // For prefix-based accounts, we use PrivateKeyCryptoProvider instead of BackupCryptoProvider - PrivateKeyCryptoProvider(privateKey, wallet) + val address = switchAccount.address + runBlocking { createPrivateKeyCryptoProvider(privateKey, wallet, address) } } // Handle other accounts @@ -414,6 +489,37 @@ object CryptoProviderManager { } } + /** + * Create a PrivateKeyCryptoProvider by resolving the correct signing and hashing algorithms + * from the on-chain account keys. Falls back to ECDSA_P256 defaults if the lookup fails. + */ + @OptIn(ExperimentalStdlibApi::class) + private suspend fun createPrivateKeyCryptoProvider( + privateKey: PrivateKey, + keyWallet: KeyWallet, + address: String + ): PrivateKeyCryptoProvider { + if (address.isNotEmpty()) { + try { + val onChainAccount = FlowCadenceApi.getAccount(address) + val onChainKeys = onChainAccount.keys?.filter { !it.revoked } ?: emptyList() + + for (sigAlgo in listOf(SigningAlgorithm.ECDSA_P256, SigningAlgorithm.ECDSA_secp256k1)) { + val pubKey = privateKey.publicKey(sigAlgo)?.toHexString() ?: continue + val matched = onChainKeys.find { isKeyMatchRobust(pubKey, it.publicKey) } + if (matched != null) { + logd(TAG, "createPrivateKeyCryptoProvider: matched on-chain key sigAlgo=$sigAlgo hashAlgo=${matched.hashingAlgorithm}") + return PrivateKeyCryptoProvider(privateKey, keyWallet, sigAlgo, matched.hashingAlgorithm) + } + } + logd(TAG, "createPrivateKeyCryptoProvider: no on-chain match for $address, using defaults") + } catch (e: Exception) { + logd(TAG, "createPrivateKeyCryptoProvider: on-chain lookup failed: ${e.message}, using defaults") + } + } + return PrivateKeyCryptoProvider(privateKey, keyWallet, SigningAlgorithm.ECDSA_P256) + } + fun clear() { cryptoProvider = 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..083d44682 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageManager.kt @@ -0,0 +1,225 @@ +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.filter { !it.endsWith("_metadata") } + + // ─── 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 + + /** Removes the private key entry for [uid] from pkStorage. No-op if not present. */ + fun deletePrivateKey(uid: String) { + try { + pkStorage.remove(uid) + logd(TAG, "Deleted private key for uid: $uid") + } catch (e: Exception) { + loge(TAG, "Failed to delete private key for uid $uid: ${e.message}") + } + } + + /** Returns all UIDs that have a stored private key. */ + fun getAllPrivateKeyUids(): List = pkStorage.allKeys.filter { !it.endsWith("_metadata") } + + // ─── 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.filter { !it.endsWith("_metadata") } + + // ─── Wallet Address index (plain SharedPreferences, no encryption) ──────────── + // Stores uid → primary Flow wallet address for display in LocalSwitchAccount. + // Intentionally separate from key material — no master password, no ChaCha20. + + private const val ADDR_PREFS_NAME = "frw_uid_address_map" + + private val addrPrefs: SharedPreferences by lazy { + Env.getApp().getSharedPreferences(ADDR_PREFS_NAME, Context.MODE_PRIVATE) + } + + fun saveWalletAddress(uid: String, address: String) { + addrPrefs.edit { putString(uid, address) } + logd(TAG, "Saved wallet address for uid: $uid") + } + + fun getWalletAddress(uid: String): String? = addrPrefs.getString(uid, null) + + fun hasWalletAddress(uid: String): Boolean = addrPrefs.contains(uid) +} 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..2626b67b8 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/key/storage/KeyStorageMigration.kt @@ -0,0 +1,144 @@ +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.manager.account.firstFlowWalletAddress +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) + } + + // Save wallet address independently (idempotent, no master password needed) + if (!KeyStorageManager.hasWalletAddress(uid)) { + val address = account.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + logd(TAG, "Saved wallet address for uid: $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.isNotBlank() -> { + 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/token/FungibleTokenListManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/token/FungibleTokenListManager.kt index ea0c0589d..6ee0371c5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/token/FungibleTokenListManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/token/FungibleTokenListManager.kt @@ -136,16 +136,6 @@ object FungibleTokenListManager { return@launch } - tokenListCache[address]?.let { cachedList -> - if (cachedList.displayTokenList.isNotEmpty()) { - currentDisplayTokenList.clear() - val filteredList = applyFilters(cachedList.displayTokenList.distinctBy { it.contractId() }) - currentDisplayTokenList.addAll(filteredList) - dispatchListeners() - logd(TAG, "Loaded token list from cache for address: $address") - } - } - updateTokenList(address) } } @@ -174,37 +164,14 @@ object FungibleTokenListManager { logd(TAG, "Fetching token list for address: $address, currency: ${currency.name}, network: $network") val freshList = provider.getTokenList(address, currency, network) + val hiddenIds = getHiddenTokenIds(address) - if (currentDisplayTokenList.isEmpty()) { - val filteredList = applyFilters(freshList.distinctBy { it.contractId() }) - currentDisplayTokenList.addAll(filteredList) - updateDisplayTokenListCache(address) - dispatchListeners() - logd(TAG, "Initial load from provider for token list for address: $address. Count: ${currentDisplayTokenList.size}") - return - } - val freshTokensMap = freshList.associateBy { it.contractId() } - - val updatedFinalTokens = mutableListOf() - for (existingToken in currentDisplayTokenList) { - freshTokensMap[existingToken.contractId()]?.let { updatedVersionOfExistingToken -> - updatedFinalTokens.add(updatedVersionOfExistingToken) - } - } - contractId?.let { targetId -> - freshTokensMap[targetId]?.takeIf { freshToken -> - currentDisplayTokenList.none { it.contractId() == targetId } - }?.let { newToken -> - updatedFinalTokens.add(newToken) - logd(TAG, "Added new token by contractId: $targetId") - } - } + val visibleTokens = freshList.filter { it.contractId() !in hiddenIds } + val filteredList = applyFilters(visibleTokens.distinctBy { it.contractId() }) currentDisplayTokenList.clear() - val finalFilteredList = applyFilters(updatedFinalTokens.distinctBy { it.contractId() }) - currentDisplayTokenList.addAll(finalFilteredList) - updateDisplayTokenListCache(address) - logd(TAG, "Successfully updated token list for address: $address. Count: ${currentDisplayTokenList.size}") + currentDisplayTokenList.addAll(filteredList) + logd(TAG, "Updated token list for address: $address. Count: ${currentDisplayTokenList.size}, hidden: ${hiddenIds.size}") dispatchListeners() } catch (e: Exception) { loge("Error reloading token list for address: $address", e) @@ -212,15 +179,22 @@ object FungibleTokenListManager { } private fun updateDisplayTokenListCache(address: String) { - val oldItem = tokenListCache[address] - if (oldItem != null) { - tokenListCache[address] = oldItem.copy(displayTokenList = ArrayList(currentDisplayTokenList)) - } else { - tokenListCache[address] = DisplayTokenListCache(displayTokenList = ArrayList(currentDisplayTokenList)) - } DisplayTokenCacheManager.cache(tokenListCache) } + private fun getHiddenTokenIds(address: String = WalletManager.selectedWalletAddress()): Set { + return tokenListCache[address]?.hiddenTokenIds ?: emptySet() + } + + private fun rebuildDisplayList(address: String) { + val allTokens = getCurrentTokenListSnapshot() + val hiddenIds = getHiddenTokenIds(address) + val visibleTokens = allTokens.filter { it.contractId() !in hiddenIds } + val filteredList = applyFilters(visibleTokens.distinctBy { it.contractId() }) + currentDisplayTokenList.clear() + currentDisplayTokenList.addAll(filteredList) + } + private fun applyFilters(tokens: List): List { var filteredList = tokens @@ -248,30 +222,10 @@ object FungibleTokenListManager { if (address.isBlank()) { return } - logd(TAG, "setHideDustTokens: hide=$hide, currentDisplayTokenList.size=${currentDisplayTokenList.size}") - currentDisplayTokenList.forEach { token -> - logd(TAG, "setHideDustTokens: Before - ${token.symbol} balance=${token.tokenBalanceInUSD()}") - } + logd(TAG, "setHideDustTokens: hide=$hide") val oldItem = tokenListCache[address] ?: DisplayTokenListCache() - if (hide) { - val filteredList = currentDisplayTokenList.filter { it.tokenBalanceInUSD() > BigDecimal(0.01) } - logd(TAG, "setHideDustTokens: Filtered out ${currentDisplayTokenList.size - filteredList.size} dust tokens") - currentDisplayTokenList.clear() - currentDisplayTokenList.addAll(filteredList) - tokenListCache[address] = oldItem.copy(hideDustTokens = true, displayTokenList = filteredList) - } else { - val tokenList = getCurrentTokenListSnapshot() - val displayListSnapshot = currentDisplayTokenList.toList() - currentDisplayTokenList.clear() - if (isOnlyShowVerifiedTokens()) { - val filteredList = tokenList.filter { it.isVerified }.filter { verified -> displayListSnapshot.any { it.isSameToken(verified.contractId()) } } - currentDisplayTokenList.addAll(filteredList) - } else { - currentDisplayTokenList.addAll(tokenList) - } - tokenListCache[address] = - oldItem.copy(hideDustTokens = false, displayTokenList = currentDisplayTokenList) - } + tokenListCache[address] = oldItem.copy(hideDustTokens = hide) + rebuildDisplayList(address) DisplayTokenCacheManager.cache(tokenListCache) dispatchListeners() } @@ -282,24 +236,8 @@ object FungibleTokenListManager { return } val oldItem = tokenListCache[address] ?: DisplayTokenListCache() - if (show) { - val filteredList = currentDisplayTokenList.filter { it.isVerified } - currentDisplayTokenList.clear() - currentDisplayTokenList.addAll(filteredList) - tokenListCache[address] = - oldItem.copy(onlyShowVerifiedTokens = true, displayTokenList = filteredList) - } else { - val tokenList = getCurrentTokenListSnapshot() - val displayListSnapshot = currentDisplayTokenList.toList() - currentDisplayTokenList.clear() - if (isHideDustTokens()) { - val filteredList = tokenList.filter { it.tokenBalanceInUSD() > BigDecimal(0.01) }.filter { verified -> displayListSnapshot.any{ it.isSameToken(verified.contractId()) } } - currentDisplayTokenList.addAll(filteredList) - } else { - currentDisplayTokenList.addAll(tokenList) - } - tokenListCache[address] = oldItem.copy(onlyShowVerifiedTokens = false, displayTokenList = currentDisplayTokenList) - } + tokenListCache[address] = oldItem.copy(onlyShowVerifiedTokens = show) + rebuildDisplayList(address) DisplayTokenCacheManager.cache(tokenListCache) dispatchListeners() } @@ -346,43 +284,42 @@ object FungibleTokenListManager { fun isTokenAdded(contractId: String) = getCurrentTokenListSnapshot().any { it.isSameToken(contractId) } - fun addDisplayToken(token: FungibleToken) { + fun showToken(token: FungibleToken) { ioScope { - if (currentDisplayTokenList.any { it.isSameToken(token.contractId()) }) { - logd(TAG, "Token ${token.contractId()} already in display list.") - return@ioScope - } + val address = WalletManager.selectedWalletAddress() + if (address.isBlank()) return@ioScope - // Check if token should be filtered out - val shouldShow = applyFilters(listOf(token)).isNotEmpty() - if (!shouldShow) { - logd(TAG, "Token ${token.contractId()} filtered out (dust/unverified), not adding to display list.") - return@ioScope - } + val oldItem = tokenListCache[address] ?: DisplayTokenListCache() + val newHidden = oldItem.hiddenTokenIds - token.contractId() + tokenListCache[address] = oldItem.copy(hiddenTokenIds = newHidden) - currentDisplayTokenList.add(token) - val address = WalletManager.selectedWalletAddress() - if (address.isNotBlank()) { - updateDisplayTokenListCache(address) + if (currentDisplayTokenList.none { it.isSameToken(token.contractId()) }) { + val shouldShow = applyFilters(listOf(token)).isNotEmpty() + if (shouldShow) { + currentDisplayTokenList.add(token) + } } + + updateDisplayTokenListCache(address) dispatchDisplayUpdated(token, true) - logd(TAG, "Added token ${token.contractId()} to display list. New count: ${currentDisplayTokenList.size}") + logd(TAG, "Showed token ${token.contractId()}. Hidden count: ${newHidden.size}") } } - fun removeDisplayToken(token: FungibleToken) { + fun hideToken(token: FungibleToken) { ioScope { - val removed = currentDisplayTokenList.removeAll { it.isSameToken(token.contractId()) } - if (removed) { - val address = WalletManager.selectedWalletAddress() - if (address.isNotBlank()) { - updateDisplayTokenListCache(address) - } - dispatchDisplayUpdated(token, false) - logd(TAG, "Removed token ${token.contractId()} from display list. New count: ${currentDisplayTokenList.size}") - } else { - logd(TAG, "Token ${token.contractId()} not found in display list for removal.") - } + val address = WalletManager.selectedWalletAddress() + if (address.isBlank()) return@ioScope + + val oldItem = tokenListCache[address] ?: DisplayTokenListCache() + val newHidden = oldItem.hiddenTokenIds + token.contractId() + tokenListCache[address] = oldItem.copy(hiddenTokenIds = newHidden) + + currentDisplayTokenList.removeAll { it.isSameToken(token.contractId()) } + + updateDisplayTokenListCache(address) + dispatchDisplayUpdated(token, false) + logd(TAG, "Hid token ${token.contractId()}. Hidden count: ${newHidden.size}") } } @@ -415,6 +352,6 @@ data class DisplayTokenListCache( val hideDustTokens: Boolean = false, @SerializedName("onlyShowVerifiedTokens") val onlyShowVerifiedTokens: Boolean = false, - @SerializedName("displayTokenList") - val displayTokenList: List = emptyList() + @SerializedName("hiddenTokenIds") + val hiddenTokenIds: Set = emptySet() ) 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 607a2abf8..fe858645a 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 @@ -25,6 +26,11 @@ import org.onflow.flow.ChainId import org.onflow.flow.models.SigningAlgorithm import org.onflow.flow.models.hexToBytes +data class WalletCreationResult( + val wallet: Wallet, + val canDeriveEoa: Boolean +) + /** * Helper class for creating Wallet objects from Account information * Provides reusable wallet creation logic for WalletManager and WalletDataManager @@ -41,14 +47,37 @@ object WalletCreationHelper { * 2. Prefix-based (legacy/hardware): Has prefix for hardware-backed or legacy keys * 3. Mnemonic-based: Fallback to HD wallet mnemonic via AccountWalletManager */ - suspend fun createWalletFromAccount(account: Account, isCurrentAccount: Boolean = true): - Wallet? { + suspend fun createWalletFromAccount(account: Account, isCurrentAccount: Boolean = true): WalletCreationResult? { return try { 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) + } + // 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") + val result = createWalletFromPrivateKey(privateKey) + checkAndNotifyMnemonicRestoreIfNeeded(isCurrentAccount) + return result + } + // 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) + } + } + // Create wallet based on account's key information only - val wallet = when { + val result = when { // Handle keystore-based accounts !account.keyStoreInfo.isNullOrBlank() -> { logd(TAG, "Creating keystore-based wallet for account: ${account.userInfo.username}") @@ -58,23 +87,23 @@ object WalletCreationHelper { // Handle prefix-based accounts (hardware-backed or legacy) !account.prefix.isNullOrBlank() -> { logd(TAG, "Creating prefix-based wallet for account: ${account.userInfo.username}") - createWalletFromPrefix(account.prefix!!, isCurrentAccount) + createWalletFromPrefix(account.prefix!!) } // Handle mnemonic-based accounts (including cleaner architecture RN seed phrase accounts) else -> { logd(TAG, "Creating HD wallet from mnemonic for account: ${account.userInfo.username}") - createWalletFromHDMnemonic(userId ?: "", isCurrentAccount) + createWalletFromHDMnemonic(userId ?: "") } } - if (wallet != null) { + if (result != null) { logd(TAG, "Successfully created wallet for account: ${account.userInfo.username}") } else { logd(TAG, "Failed to create wallet for account: ${account.userInfo.username}") } - wallet + result } catch (e: Exception) { logd(TAG, "Error creating wallet for account ${account.userInfo.username}: ${e.message}") null @@ -84,7 +113,7 @@ object WalletCreationHelper { /** * Create wallet from keystore information */ - private suspend fun createWalletFromKeystore(keyStoreInfo: String, userId: String?, isCurrentAccount: Boolean): Wallet { + private suspend fun createWalletFromKeystore(keyStoreInfo: String, userId: String?, isCurrentAccount: Boolean): WalletCreationResult { val ks = Gson().fromJson(keyStoreInfo, KeystoreAddress::class.java) val storage = getStorage() // Check if we have an encrypted mnemonic (HD Wallet restore) @@ -93,7 +122,6 @@ object WalletCreationHelper { val uid = if (isCurrentAccount) firebaseUid() else userId if (!uid.isNullOrBlank()) { val decryptedMnemonic = EncryptedMnemonicUtils.decrypt(ks.encryptedMnemonic, uid) - logd(TAG, "Decrypted mnemonic: $decryptedMnemonic") if (!decryptedMnemonic.isNullOrBlank()) { // Create HD Wallet using the decrypted mnemonic val seedPhraseKey = SeedPhraseKey( @@ -102,14 +130,12 @@ object WalletCreationHelper { derivationPath = DERIVATION_PATH, storage = storage ) - if (isCurrentAccount) { - WalletManager.setEoaDisabled(false) - } - return WalletFactory.createKeyWallet( + val wallet = WalletFactory.createKeyWallet( seedPhraseKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) + return WalletCreationResult(wallet, canDeriveEoa = true) } else { logd(TAG, "Failed to decrypt mnemonic, using private key mode") } @@ -118,25 +144,11 @@ object WalletCreationHelper { } } else { logd(TAG, "No encrypted mnemonic found, using private key mode") - try { - val service = retrofitApi().create(ApiService::class.java) - val response = service.checkUserMnemonicStatus() - logd(TAG, "Checked user mnemonic status: ${response.data}") - if (response.data?.isExist == true) { - logd(TAG, "Mnemonic restore required. Disabling EOA and notifying UI.") - if (isCurrentAccount) { - WalletManager.setEoaDisabled(true) - LocalBroadcastManager.getInstance(com.flowfoundation.wallet.utils.Env.getApp()) - .sendBroadcast(android.content.Intent("ACTION_RESTORE_MNEMONIC")) - } - } - } catch (e: Exception) { - logd(TAG, "Error checking mnemonic status: ${e.message}") - } + checkAndNotifyMnemonicRestoreIfNeeded(isCurrentAccount) } // Fallback to private key mode - return createWalletFromKeystorePrivateKey(ks, storage, isCurrentAccount) + return createWalletFromKeystorePrivateKey(ks, storage) } /** @@ -149,7 +161,7 @@ object WalletCreationHelper { * 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? { + private fun createWalletFromPrefix(prefix: String): WalletCreationResult? { val storage = getStorage() return try { @@ -157,14 +169,12 @@ object WalletCreationHelper { if (privateKey != null) { // Prefix-based key - cannot derive EOA (no mnemonic) logd(TAG, "Prefix-based key found for prefix: $prefix - EOA disabled") - if (isCurrentAccount) { - WalletManager.setEoaDisabled(true) - } - WalletFactory.createKeyWallet( + val wallet = WalletFactory.createKeyWallet( privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) + WalletCreationResult(wallet, canDeriveEoa = false) } else { logd(TAG, "Private key not found for prefix: $prefix") null @@ -172,16 +182,14 @@ object WalletCreationHelper { } catch (e: HardwareBackedKeyException) { // Secure Enclave (hardware-backed) key - cannot derive EOA logd(TAG, "Hardware-backed key detected for prefix: $prefix - EOA disabled") - if (isCurrentAccount) { - WalletManager.setEoaDisabled(true) - } if (e.prefix != null) { val provider = AndroidKeystoreCryptoProvider(e.prefix) - WalletFactory.createProxyWallet( + val wallet = WalletFactory.createProxyWallet( provider, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) + WalletCreationResult(wallet, canDeriveEoa = false) } else { logd(TAG, "Hardware-backed key prefix is null") null @@ -193,7 +201,7 @@ object WalletCreationHelper { * Create wallet from HD wallet mnemonic * HD wallets can derive EOA addresses. */ - private fun createWalletFromHDMnemonic(accountId: String, isCurrentAccount: Boolean): Wallet? { + private fun createWalletFromHDMnemonic(accountId: String): WalletCreationResult? { val storage = getStorage() val mnemonic = AccountWalletManager.getHDWalletMnemonicByUID(accountId) if (mnemonic != null) { @@ -203,27 +211,64 @@ object WalletCreationHelper { derivationPath = DERIVATION_PATH, storage = storage ) - // HD wallet (mnemonic-based) - can derive EOA - if (isCurrentAccount) { - WalletManager.setEoaDisabled(false) - } logd(TAG, "HD wallet from mnemonic - EOA enabled") - return WalletFactory.createKeyWallet( + val wallet = WalletFactory.createKeyWallet( seedPhraseKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) + return WalletCreationResult(wallet, canDeriveEoa = true) } else { logd(TAG, "HD wallet key not found for account ID: $accountId") return null } } + /** + * Create wallet from a [SeedPhraseKey] object loaded directly from key storage. + * Avoids reconstructing the key from a mnemonic string. + */ + private fun createWalletFromSeedPhraseKey(seedPhraseKey: SeedPhraseKey): WalletCreationResult { + val storage = getStorage() + val wallet = WalletFactory.createKeyWallet(seedPhraseKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) + return WalletCreationResult(wallet, canDeriveEoa = true) + } + + /** + * Create wallet from a [PrivateKey] object loaded directly from key storage. + * Cannot derive EOA addresses (no mnemonic available). + */ + private fun createWalletFromPrivateKey(privateKey: PrivateKey): WalletCreationResult { + val storage = getStorage() + val wallet = WalletFactory.createKeyWallet(privateKey, setOf(ChainId.Mainnet, ChainId.Testnet), storage) + return WalletCreationResult(wallet, canDeriveEoa = false) + } + + /** + * Checks the server-side mnemonic status and fires ACTION_RESTORE_MNEMONIC if the user + * has no mnemonic backup yet. Only runs for the current account to avoid spurious prompts. + */ + private suspend fun checkAndNotifyMnemonicRestoreIfNeeded(isCurrentAccount: Boolean) { + if (!isCurrentAccount) return + try { + val service = retrofitApi().create(ApiService::class.java) + val response = service.checkUserMnemonicStatus() + logd(TAG, "Checked user mnemonic status: ${response.data}") + if (response.data?.isExist == true) { + logd(TAG, "Mnemonic restore required. Notifying UI.") + LocalBroadcastManager.getInstance(com.flowfoundation.wallet.utils.Env.getApp()) + .sendBroadcast(android.content.Intent("ACTION_RESTORE_MNEMONIC")) + } + } catch (e: Exception) { + logd(TAG, "Error checking mnemonic status: ${e.message}") + } + } + /** * Create wallet from keystore private key * Private key imports cannot derive EOA addresses (no mnemonic available). */ - private fun createWalletFromKeystorePrivateKey(ks: KeystoreAddress, storage: StorageProtocol, isCurrentAccount: Boolean): Wallet { + private fun createWalletFromKeystorePrivateKey(ks: KeystoreAddress, storage: StorageProtocol): WalletCreationResult { val keyHex = ks.privateKey.removePrefix("0x") require(keyHex.length == 64) { "Private key must be 32-byte hex" } @@ -231,16 +276,13 @@ object WalletCreationHelper { importPrivateKey(keyHex.hexToBytes(), KeyFormat.RAW) } - // Keystore private key import - cannot derive EOA (no mnemonic available) - if (isCurrentAccount) { - WalletManager.setEoaDisabled(true) - } logd(TAG, "Keystore private key import - EOA disabled") - return WalletFactory.createKeyWallet( + val wallet = WalletFactory.createKeyWallet( key, setOf(ChainId.Mainnet, ChainId.Testnet), storage ) + return WalletCreationResult(wallet, canDeriveEoa = false) } } 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 903280d89..4bf3e98b0 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 @@ -33,15 +33,14 @@ object WalletManager { private const val ADDRESS_CACHE_DURATION = 100L // Cache duration in milliseconds private val initializationLock = Object() - private var _isEoaDisabled = false + private var _canDeriveEoa = false private var lastRotationCheckTime = 0L private const val ROTATION_CHECK_COOLDOWN = 2000L - fun isEoaDisabled() = _isEoaDisabled - fun setEoaDisabled(disabled: Boolean) { _isEoaDisabled = disabled } + fun canDeriveEoa() = _canDeriveEoa /** - * Get EOA address + * Get the first EOA address (for backward compatibility / single-address scenarios) */ fun getEOAAddress(): String? { val eoaWallet = AccountManager.walletNodes() @@ -56,6 +55,40 @@ object WalletManager { return null } + /** + * Get all EOA addresses + */ + fun getEOAAddresses(): List { + return AccountManager.walletNodes() + ?.filterIsInstance() + ?.map { it.address } + ?: emptyList() + } + + /** + * Get the BIP44 derivation index for the currently selected EOA wallet. + * Returns 0 if no EOA is selected or found. + */ + fun getSelectedEOAIndex(): Int { + val selectedAddress = selectedWalletAddress() + return AccountManager.walletNodes() + ?.filterIsInstance() + ?.firstOrNull { it.address.equals(selectedAddress, ignoreCase = true) } + ?.index ?: 0 + } + + /** + * Get the BIP44 derivation index for a specific EOA address. + * Use this in DApp contexts where the signing address may differ from the globally selected one. + * Returns 0 if the address is not found among EOA wallets. + */ + fun getEOAIndexForAddress(address: String): Int { + return AccountManager.walletNodes() + ?.filterIsInstance() + ?.firstOrNull { it.address.equals(address, ignoreCase = true) } + ?.index ?: 0 + } + private fun initializeWallet(): Boolean { logd(TAG, "initializeWallet() called") @@ -66,14 +99,15 @@ object WalletManager { } // Use WalletCreationHelper to create wallet from account - val newWallet = runBlocking { + val result = runBlocking { WalletCreationHelper.createWalletFromAccount(account) } ?: run { logd(TAG, "Failed to create wallet from account") return false } - currentWallet = newWallet + currentWallet = result.wallet + _canDeriveEoa = result.canDeriveEoa logd(TAG, "Wallet created successfully: ${getCurrentFlowWalletAddress()}") val address = getCurrentFlowWalletAddress() ?: run { @@ -126,15 +160,14 @@ object WalletManager { selectedWalletAddressRef.set(currentNetworkFlowWallet.address) updateSelectedWalletAddress(currentNetworkFlowWallet.address) } else { - // Check if currentSelected is a Flow Main account but NOT the one for the current network - val isSelectedFlowMain = walletNodes.filterIsInstance() - .any { it.address.equals(currentSelected, ignoreCase = true) } - - if (isSelectedFlowMain) { - if (!currentNetworkFlowWallet.address.equals(currentSelected, ignoreCase = true)) { - selectedWalletAddressRef.set(currentNetworkFlowWallet.address) - updateSelectedWalletAddress(currentNetworkFlowWallet.address) - } + // Check if currentSelected is a Flow Main account on a different network + val selectedFlowWallet = walletNodes.filterIsInstance() + .firstOrNull { it.address.equals(currentSelected, ignoreCase = true) } + + if (selectedFlowWallet != null && !selectedFlowWallet.chainIdString.equals(currentNetwork, ignoreCase = true)) { + logd(TAG, "Selected FlowWallet is on a different network (${selectedFlowWallet.chainIdString} vs $currentNetwork), switching to current network account: ${currentNetworkFlowWallet.address}") + selectedWalletAddressRef.set(currentNetworkFlowWallet.address) + updateSelectedWalletAddress(currentNetworkFlowWallet.address) } } } @@ -143,7 +176,9 @@ object WalletManager { } fun isEVMAccountSelected(): Boolean { - return selectedWalletAddress().toAddress().equals(EVMWalletManager.getEVMAddress()?.toAddress(), ignoreCase = true) || selectedWalletAddress().toAddress().equals(getEOAAddress(), ignoreCase = true) + val selected = selectedWalletAddress().toAddress() + return selected.equals(EVMWalletManager.getEVMAddress()?.toAddress(), ignoreCase = true) + || EVMWalletManager.isEOAAddress(selected) } fun isSelfFlowAddress(address: String): Boolean { @@ -463,6 +498,7 @@ object WalletManager { selectedWalletAddressRef.set("") currentWallet = null lastAddressCheck = 0 + _canDeriveEoa = false } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/Utils.kt index 569785e95..ff2564c8e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/Utils.kt @@ -26,9 +26,21 @@ fun Sign.Model.SessionProposal.approveSession() { namespaces.putAll(requiredNamespaces.map { item -> pair(item) }.toMap()) - namespaces.putAll(optionalNamespaces.map { item -> - pair(item) - }.toMap()) + // Merge optional namespaces instead of overwriting required ones + optionalNamespaces.forEach { item -> + val (key, session) = pair(item) + val existing = namespaces[key] + if (existing != null) { + namespaces[key] = Sign.Model.Namespace.Session( + chains = ((existing.chains.orEmpty()) + (session.chains.orEmpty())).distinct(), + accounts = ((existing.accounts) + (session.accounts)).distinct(), + methods = ((existing.methods) + (session.methods)).distinct(), + events = ((existing.events) + (session.events)).distinct() + ) + } else { + namespaces[key] = session + } + } logd(TAG, "approveSession: $namespaces") diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt index 509590c81..bd6e55fc5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletconnect/WalletConnectRequestDispatcher.kt @@ -188,7 +188,9 @@ suspend fun WCRequest.evmSignTypedData() { EVMSignTypedDataDialog.observe { isApprove -> ioScope { val result = if (DAppEVMConnectionManager.isCurrentEOAAccount()) { - val data = WalletManager.wallet()?.ethSignTypedData(message) + val currentAddress = DAppEVMConnectionManager.getCurrentAccount()?.address.orEmpty() + val eoaIndex = WalletManager.getEOAIndexForAddress(currentAddress) + val data = WalletManager.wallet()?.ethSignTypedData(message, index = eoaIndex) Numeric.toHexString(data) } else { signTypedData(hashData) @@ -290,7 +292,8 @@ private suspend fun WCRequest.evmSignMessage() { val result = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signEthereumMessage(message) } else { - val data = WalletManager.wallet()?.ethSignPersonalMessage(hexMessage.hexToBytes()) + val eoaIndex = WalletManager.getEOAIndexForAddress(fromAddress) + val data = WalletManager.wallet()?.ethSignPersonalMessage(hexMessage.hexToBytes(), index = eoaIndex) Numeric.toHexString(data) } if (isApprove) approve(result) else reject() diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt index 4b86455b9..c1c64025d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/MainWallet.kt @@ -33,7 +33,9 @@ data class EOAWallet( @SerializedName("name") override val name: String, @SerializedName("emojiId") - override val emojiId: Int + override val emojiId: Int, + @SerializedName("index") + val index: Int = 0 ) : MainWallet() // Linked Wallets (Children / COA) diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/walletdata/WalletDataManager.kt index 5242527e2..80f711732 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 @@ -20,6 +20,8 @@ import com.flowfoundation.wallet.wallet.toAddress import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeout import org.onflow.flow.infrastructure.Cadence @@ -145,17 +147,23 @@ object WalletDataManager { logd(TAG, "Updating current account data: ${currentAccount.userInfo.username}") // Try to reuse the singleton Wallet instance from WalletManager - var wallet = WalletManager.wallet() + val existingWallet = WalletManager.wallet() + val wallet: Wallet? + val canDeriveEoa: Boolean - if (wallet == null) { + if (existingWallet == null) { logd(TAG, "WalletManager.wallet() is null, attempting to create temporary instance") - wallet = WalletCreationHelper.createWalletFromAccount(currentAccount) + val result = WalletCreationHelper.createWalletFromAccount(currentAccount) + wallet = result?.wallet + canDeriveEoa = result?.canDeriveEoa ?: false } else { logd(TAG, "Reusing WalletManager instance: ${WalletManager.getCurrentFlowWalletAddress()}") + wallet = existingWallet + canDeriveEoa = WalletManager.canDeriveEoa() } if (wallet != null) { - updateCurrentAccountData(currentAccount, wallet) + updateCurrentAccountData(currentAccount, wallet, canDeriveEoa) } else { logd(TAG, "Failed to obtain wallet instance for current account") } @@ -232,7 +240,7 @@ object WalletDataManager { /** * Update data for the current account using provided Wallet */ - private suspend fun updateCurrentAccountData(account: Account, wallet: Wallet) { + private suspend fun updateCurrentAccountData(account: Account, wallet: Wallet, canDeriveEoa: Boolean) { try { logd(TAG, "Refreshing wallet accounts for ${account.userInfo.username}...") wallet.refreshAccounts() @@ -252,7 +260,7 @@ object WalletDataManager { // 3. Merge both sources to avoid losing networks (like Testnet) when indexer is slow val allBlockchainData = (indexerBlockchainData + backendBlockchainData) - .distinctBy { "${it.address}-${it.chainId}" } + .distinctBy { "${it.address.lowercase()}-${it.chainId}" } .filter { it.address.isNotBlank() } logd(TAG, "Merged blockchain data: ${allBlockchainData.map { "${it.address} (${it.chainId})" }}") @@ -261,87 +269,114 @@ object WalletDataManager { val nodes = mutableListOf() logd(TAG, "Fetching data for ${allBlockchainData.size} BlockchainData entries for node construction") + + // Collect all valid addresses before emoji assignment to clean stale entries + val validAddresses = mutableSetOf() + allBlockchainData.forEach { validAddresses.add(it.address) } + // EOA addresses will be added below; existing EOAs are included now + account.walletNodes.filterIsInstance().forEach { validAddresses.add(it.address) } + AccountEmojiManager.cleanStaleEntries(validAddresses) + fun getEmojiInfo(address: String) = AccountEmojiManager.getEmojiByAddress(address) - // EOA Wallet - WalletCreationHelper.createWalletFromAccount() sets isEoaDisabled - // based on key type (Secure Enclave = disabled, others = enabled) - if (!WalletManager.isEoaDisabled()) { - val eoa = deriveEoaAddress(wallet) - logd(TAG, "Generated EOA address: $eoa") - if (eoa.isNotEmpty()) { - logd(TAG, "Adding EOA for account: $eoa") - val eoaEmojiInfo = getEmojiInfo(eoa) - nodes.add(EOAWallet( - address = eoa, - name = eoaEmojiInfo.emojiName, - emojiId = eoaEmojiInfo.emojiId - )) + // EOA Wallet - canDeriveEoa is determined per-wallet by WalletCreationHelper + // based on key type (Secure Enclave / private key = false, seed phrase = true) + if (canDeriveEoa) { + val existingEOAs = account.walletNodes.filterIsInstance() + if (existingEOAs.isNotEmpty()) { + // Preserve all existing EOA wallets with their indices + existingEOAs.forEach { existingEoa -> + val eoaEmojiInfo = getEmojiInfo(existingEoa.address) + nodes.add(EOAWallet( + address = existingEoa.address, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = existingEoa.index + )) + } + logd(TAG, "Preserved ${existingEOAs.size} existing EOA wallets") + } else { + val eoa = deriveEoaAddress(wallet) + logd(TAG, "Generated EOA address: $eoa") + if (eoa.isNotEmpty()) { + logd(TAG, "Adding EOA for account: $eoa") + val eoaEmojiInfo = getEmojiInfo(eoa) + nodes.add(EOAWallet( + address = eoa, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = 0 + )) + } } } + val semaphore = Semaphore(3) kotlinx.coroutines.supervisorScope { val deferredFlowNodes = allBlockchainData.map { blockchainData -> val address = blockchainData.address val chainId = blockchainData.chainId async { - // Find existing node if any to preserve existing linked wallets - val existingNode = account.walletNodes.filterIsInstance() - .firstOrNull { it.address == address && it.chainIdString == chainId } - - 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) - // 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 - ) + semaphore.withPermit { + // Find existing node if any to preserve existing linked wallets + val existingNode = account.walletNodes.filterIsInstance() + .firstOrNull { it.address.equals(address, ignoreCase = true) && it.chainIdString == chainId } + + 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) + // 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) } - linkedWallets.clear() - linkedWallets.addAll(nonChildLinks + newChildren) - } - // Update COA - val coa = fetchEVMAddressForAddress(address) - if (coa != null) { - val nonCoaLinks = linkedWallets.filter { it !is COAWallet } - val coaEmojiInfo = getEmojiInfo(coa) - linkedWallets.clear() - linkedWallets.addAll(nonCoaLinks + COAWallet( - address = coa, - name = coaEmojiInfo.emojiName, - emojiId = coaEmojiInfo.emojiId - )) - logd(TAG, "Updated COA for $address: $coa") + // Update COA + val coa = fetchEVMAddressForAddress(address) + if (coa != null) { + val nonCoaLinks = linkedWallets.filter { it !is COAWallet } + val coaEmojiInfo = getEmojiInfo(coa) + linkedWallets.clear() + linkedWallets.addAll(nonCoaLinks + COAWallet( + address = coa, + name = coaEmojiInfo.emojiName, + emojiId = coaEmojiInfo.emojiId + )) + logd(TAG, "Updated COA for $address: $coa") + } + } catch (e: Exception) { + logd(TAG, "Error updating linked data for $address: ${e.message}") + // Keep existing linkedWallets on error } - } catch (e: Exception) { - logd(TAG, "Error updating linked data for $address: ${e.message}") - // Keep existing linkedWallets on error - } - val emojiInfo = getEmojiInfo(address) - FlowWallet( - address = address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - chainIdString = chainId, - linkedWallets = linkedWallets - ) + val emojiInfo = getEmojiInfo(address) + FlowWallet( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + chainIdString = chainId, + linkedWallets = linkedWallets + ) + } } } nodes.addAll(deferredFlowNodes.awaitAll()) @@ -366,7 +401,9 @@ object WalletDataManager { return try { logd(TAG, "Updating non-current account: ${account.userInfo.username}") - val wallet = WalletCreationHelper.createWalletFromAccount(account, false) + val result = WalletCreationHelper.createWalletFromAccount(account, false) + val wallet = result?.wallet + val canDeriveEoa = result?.canDeriveEoa ?: false if (wallet != null) { logd(TAG, "Refreshing wallet accounts for non-current account ${account.userInfo.username}...") wallet.refreshAccounts() @@ -375,23 +412,49 @@ 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()) { - val eoa = deriveEoaAddress(wallet) - if (eoa.isNotEmpty()) { - logd(TAG, "Adding EOA for non-current account: $eoa") - val eoaEmojiInfo = getEmojiInfo(eoa) - nodes.add(EOAWallet( - address = eoa, - name = eoaEmojiInfo.emojiName, - emojiId = eoaEmojiInfo.emojiId - )) + val accountUsername = account.userInfo.username + val accountEmojiList = (account.walletEmojiList ?: emptyList()).toMutableList() + + // Collect valid addresses and clean stale emoji entries before assignment + val validAddresses = mutableSetOf() + walletList.forEach { validAddresses.add(it.address) } + account.walletNodes.filterIsInstance().forEach { validAddresses.add(it.address) } + AccountEmojiManager.cleanStaleEntriesForAccount(validAddresses, accountUsername, accountEmojiList) + + fun getEmojiInfo(address: String) = AccountEmojiManager.getEmojiByAddressForAccount( + address, accountUsername, accountEmojiList + ) + + // EOA - canDeriveEoa is determined per-wallet by WalletCreationHelper + // based on key type (Secure Enclave / private key = false, seed phrase = true) + if (canDeriveEoa) { + val existingEOAs = account.walletNodes.filterIsInstance() + if (existingEOAs.isNotEmpty()) { + existingEOAs.forEach { existingEoa -> + val eoaEmojiInfo = getEmojiInfo(existingEoa.address) + nodes.add(EOAWallet( + address = existingEoa.address, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = existingEoa.index + )) + } + logd(TAG, "Preserved ${existingEOAs.size} existing EOA wallets for non-current account") + } else { + val eoa = deriveEoaAddress(wallet) + if (eoa.isNotEmpty()) { + logd(TAG, "Adding EOA for non-current account: $eoa") + val eoaEmojiInfo = getEmojiInfo(eoa) + nodes.add(EOAWallet( + address = eoa, + name = eoaEmojiInfo.emojiName, + emojiId = eoaEmojiInfo.emojiId, + index = 0 + )) + } } } else { - logd(TAG, "Skipping EOA for non-current account (isEoaDisabled=${WalletManager.isEoaDisabled()})") + logd(TAG, "Skipping EOA for non-current account (canDeriveEoa=false)") } kotlinx.coroutines.supervisorScope { diff --git a/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt b/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt new file mode 100644 index 000000000..8f9a024ea --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt @@ -0,0 +1,141 @@ +package com.flowfoundation.wallet.network + +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.app.chainNetWorkString +import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.key.CryptoProviderManager +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.manager.walletdata.EOAWallet +import com.flowfoundation.wallet.manager.walletdata.FlowWallet +import com.flowfoundation.wallet.network.model.ManualAddressRequest +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.loge +import org.onflow.flow.ChainId + +private const val TAG = "AddAccountUtils" + +/** + * Add a new Flow address to the current account by calling POST /v2/user/manualaddress. + * Waits for blockchain confirmation via fetchAccountByCreationTxId. + * + * @return the new formatted address (with 0x prefix), or null on failure + */ +suspend fun addNewFlowAccount(): String? { + return try { + val cryptoProvider = CryptoProviderManager.getCurrentCryptoProvider() + if (cryptoProvider == null) { + loge(TAG, "Cannot get current crypto provider") + return null + } + + val publicKey = cryptoProvider.getPublicKey() + val hashAlgorithm = cryptoProvider.getHashAlgorithm().cadenceIndex + val signatureAlgorithm = cryptoProvider.getSignatureAlgorithm().cadenceIndex + + logd(TAG, "Adding new account with publicKey: $publicKey, hashAlgo: $hashAlgorithm, signAlgo: $signatureAlgorithm") + + val service = retrofit().create(ApiService::class.java) + val request = ManualAddressRequest( + hashAlgorithm = hashAlgorithm, + publicKey = publicKey, + signatureAlgorithm = signatureAlgorithm, + weight = 1000 + ) + + val response = service.createManualAddress(request) + val txId = response.data?.txid + if (txId.isNullOrBlank()) { + loge(TAG, "No txId returned from createManualAddress") + return null + } + + logd(TAG, "Got txId: $txId, waiting for blockchain confirmation...") + + val chainId = when (chainNetWorkString()) { + "mainnet" -> ChainId.Mainnet + "testnet" -> ChainId.Testnet + else -> ChainId.Mainnet + } + + val walletForSDK = WalletManager.wallet() + if (walletForSDK == null) { + loge(TAG, "No wallet available from WalletManager") + return null + } + + logd(TAG, "Calling fetchAccountByCreationTxId with txId: $txId on chainId: $chainId") + val fetchedAccount = walletForSDK.fetchAccountByCreationTxId(txId, chainId) + val createdAddress = fetchedAccount.address + + logd(TAG, "New account fetched at address: $createdAddress") + + val formattedAddress = if (createdAddress.startsWith("0x")) createdAddress else "0x$createdAddress" + val emojiInfo = AccountEmojiManager.getEmojiByAddress(formattedAddress) + + val flowWallet = FlowWallet( + address = formattedAddress, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + chainIdString = chainNetWorkString(), + linkedWallets = emptyList() + ) + + AccountManager.updateCurrentAccount { account -> + account.copy(walletNodes = account.walletNodes + flowWallet) + } + + logd(TAG, "New FlowWallet added to account: $formattedAddress") + formattedAddress + } catch (e: Exception) { + loge(TAG, "Failed to add new Flow account: ${e.message}") + e.printStackTrace() + null + } +} + +/** + * Add a new EOA (Externally Owned Account) wallet by deriving the next BIP44 index. + * This is a purely local operation (no network request needed). + * + * @return the new EOA address, or null on failure + */ +suspend fun addNewEOAAccount(): String? { + return try { + val wallet = WalletManager.wallet() + if (wallet == null) { + loge(TAG, "No wallet available from WalletManager") + return null + } + + val existingEOAs = AccountManager.walletNodes() + ?.filterIsInstance() ?: emptyList() + val nextIndex = if (existingEOAs.isEmpty()) 0 else existingEOAs.maxOf { it.index } + 1 + + logd(TAG, "Deriving EOA address at index $nextIndex") + val address = wallet.ethAddress(nextIndex) + if (address.isBlank()) { + loge(TAG, "Derived empty EOA address at index $nextIndex") + return null + } + + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + + val eoaWallet = EOAWallet( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + index = nextIndex + ) + + AccountManager.updateCurrentAccount { account -> + account.copy(walletNodes = account.walletNodes + eoaWallet) + } + + logd(TAG, "New EOAWallet added to account: $address (index=$nextIndex)") + address + } catch (e: Exception) { + loge(TAG, "Failed to add new EOA account: ${e.message}") + e.printStackTrace() + null + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/network/ApiService.kt b/app/src/main/java/com/flowfoundation/wallet/network/ApiService.kt index 9a17abd49..15524f10f 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/ApiService.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/ApiService.kt @@ -17,6 +17,9 @@ interface ApiService { @POST("/v2/user/address") suspend fun createWalletV2(): CreateWalletV2Response + @POST("/v2/user/manualaddress") + suspend fun createManualAddress(@Body param: ManualAddressRequest): CreateWalletV2Response + @GET("/v1/user/check") suspend fun checkUsername(@Query("username") username: String): UsernameCheckResponse diff --git a/app/src/main/java/com/flowfoundation/wallet/network/ExplorerUtils.kt b/app/src/main/java/com/flowfoundation/wallet/network/ExplorerUtils.kt new file mode 100644 index 000000000..3c4866ca4 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/network/ExplorerUtils.kt @@ -0,0 +1,22 @@ +package com.flowfoundation.wallet.network + +import com.flowfoundation.wallet.manager.app.isTestnet + +/** + * Build explorer redirect URL via /api/v4/explorer endpoint. + * The server returns a 302 redirect to the appropriate block explorer. + * + * @param id Transaction hash, address, or contract identifier + * @param type "tx", "address", "account", "contract", "token" + * @param chain "flow" or "evm" + * @param network Optional, defaults to current network environment + */ +fun explorerUrl( + id: String, + type: String = "tx", + chain: String = "flow", + network: String? = null +): String { + val net = network ?: if (isTestnet()) "testnet" else "mainnet" + return "https://web.api.wallet.flow.com/api/v4/explorer?chain=$chain&network=$net&type=$type&id=$id" +} diff --git a/app/src/main/java/com/flowfoundation/wallet/network/NetworkConst.kt b/app/src/main/java/com/flowfoundation/wallet/network/NetworkConst.kt index 72bd7b4b0..731c59fa9 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/NetworkConst.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/NetworkConst.kt @@ -6,7 +6,8 @@ import com.flowfoundation.wallet.network.interceptor.HeaderInterceptor import com.flowfoundation.wallet.network.interceptor.PayerServiceInterceptor import com.flowfoundation.wallet.utils.isDev import com.flowfoundation.wallet.utils.isTesting -import com.instabug.library.okhttplogger.InstabugOkhttpInterceptor +import ai.luciq.library.okhttp.v2.LuciqOkHttpInterceptor +import ai.luciq.library.okhttp.v2.LuciqOkHttpEventListener import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -24,7 +25,8 @@ fun retrofit( ): Retrofit { val client = OkHttpClient.Builder().apply { addInterceptor(HeaderInterceptor(network = network)) - addInterceptor(InstabugOkhttpInterceptor()) + addInterceptor(LuciqOkHttpInterceptor()) + eventListenerFactory(LuciqOkHttpEventListener.Factory()) callTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS) @@ -52,8 +54,9 @@ fun retrofitApi(): Retrofit { fun cadenceScriptApi(): Retrofit { val client = OkHttpClient.Builder().apply { addInterceptor(HeaderInterceptor(false)) - addInterceptor(PayerServiceInterceptor()) // Add payer service interceptor - addInterceptor(InstabugOkhttpInterceptor()) + addInterceptor(PayerServiceInterceptor()) + addInterceptor(LuciqOkHttpInterceptor()) + eventListenerFactory(LuciqOkHttpEventListener.Factory()) addInterceptor(GzipRequestInterceptor()) addInterceptor(GzipResponseInterceptor()) callTimeout(20, TimeUnit.SECONDS) @@ -74,7 +77,8 @@ fun cadenceScriptApi(): Retrofit { fun retrofitWithHost(host: String, disableConverter: Boolean = false, ignoreAuthorization: Boolean = true): Retrofit { val client = OkHttpClient.Builder().apply { addInterceptor(HeaderInterceptor(ignoreAuthorization)) - addInterceptor(InstabugOkhttpInterceptor()) + addInterceptor(LuciqOkHttpInterceptor()) + eventListenerFactory(LuciqOkHttpEventListener.Factory()) callTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS) readTimeout(20, TimeUnit.SECONDS) 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 e9cc1ec7e..f645ee1d5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/UserRegisterUtils.kt @@ -11,8 +11,7 @@ import com.flowfoundation.wallet.R import com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.firebase.auth.getFirebaseJwt -import com.flowfoundation.wallet.firebase.auth.isAnonymousSignIn -import com.flowfoundation.wallet.firebase.auth.signInAnonymously +import com.flowfoundation.wallet.firebase.auth.setToAnonymous import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.DeviceInfoManager @@ -20,6 +19,7 @@ import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.app.isMainnet import com.flowfoundation.wallet.manager.app.refreshChainNetworkSync import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.evm.COAVisibilityCache import com.flowfoundation.wallet.manager.evm.DAppEVMConnectionManager import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider import com.flowfoundation.wallet.manager.key.CryptoProviderManager @@ -58,9 +58,6 @@ import com.flowfoundation.wallet.utils.storeWalletPassword import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.utils.updateChainNetworkPreference import com.flowfoundation.wallet.wallet.Wallet -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase -import com.google.firebase.messaging.FirebaseMessaging import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.nftco.flow.sdk.HashAlgorithm @@ -527,16 +524,13 @@ private suspend fun registerOutblockUserInternal( } private fun registerFirebase(user: RegisterResponse, callback: (isSuccess: Boolean) -> Unit) { - FirebaseMessaging.getInstance().deleteToken() - Firebase.auth.currentUser?.delete()?.addOnCompleteListener { - logd(TAG, "delete user finish exception:${it.exception}") - if (it.isSuccessful) { - firebaseCustomLogin(user.data.customToken) { isSuccessful, _ -> - if (isSuccessful) { - MixpanelManager.identifyUserProfile() - callback(true) - } else callback(false) - } + // signInWithCustomToken atomically replaces the current anonymous user without going through + // null. The prior delete() call created a null window that raced with background + // getFirebaseJwt() → signInAnonymously() calls. FCM token is refreshed by firebaseCustomLogin. + firebaseCustomLogin(user.data.customToken) { isSuccessful, _ -> + if (isSuccessful) { + MixpanelManager.identifyUserProfile() + callback(true) } else callback(false) } } @@ -560,63 +554,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 ) @@ -651,14 +592,6 @@ fun generatePrefix(text: String): String { return bytes.joinToString("") { "%02x".format(it) } } -private suspend fun setToAnonymous(): Boolean { - if (!isAnonymousSignIn()) { - Firebase.auth.signOut() - return signInAnonymously() - } - return true -} - // create user failed, resume account private suspend fun resumeAccount() { if (!setToAnonymous()) { @@ -720,6 +653,7 @@ suspend fun clearUserCache() { StakingManager.clear() CryptoProviderManager.clear() cleanBackupMnemonicPreference() + COAVisibilityCache.clear() delay(1000) } diff --git a/app/src/main/java/com/flowfoundation/wallet/network/functions/Functions.kt b/app/src/main/java/com/flowfoundation/wallet/network/functions/Functions.kt index 1fb2e09ff..1970d43f8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/network/functions/Functions.kt +++ b/app/src/main/java/com/flowfoundation/wallet/network/functions/Functions.kt @@ -5,7 +5,8 @@ import com.flowfoundation.wallet.firebase.analytics.reportEvent import com.flowfoundation.wallet.network.interceptor.HeaderInterceptor import com.flowfoundation.wallet.network.interceptor.PayerServiceInterceptor import com.flowfoundation.wallet.utils.* -import com.instabug.library.okhttplogger.InstabugOkhttpInterceptor +import ai.luciq.library.okhttp.v2.LuciqOkHttpInterceptor +import ai.luciq.library.okhttp.v2.LuciqOkHttpEventListener import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -44,8 +45,9 @@ private suspend fun executeHttp(host: String, functionName: String, data: Any? = writeTimeout(10, TimeUnit.SECONDS) addInterceptor(HeaderInterceptor()) - addInterceptor(PayerServiceInterceptor()) // Add payer service interceptor - addInterceptor(InstabugOkhttpInterceptor()) + addInterceptor(PayerServiceInterceptor()) + addInterceptor(LuciqOkHttpInterceptor()) + eventListenerFactory(LuciqOkHttpEventListener.Factory()) if (isTesting()) { addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) } diff --git a/app/src/main/java/com/flowfoundation/wallet/network/model/ManualAddressRequest.kt b/app/src/main/java/com/flowfoundation/wallet/network/model/ManualAddressRequest.kt new file mode 100644 index 000000000..7d8cf6c22 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/network/model/ManualAddressRequest.kt @@ -0,0 +1,17 @@ +package com.flowfoundation.wallet.network.model + +import com.google.gson.annotations.SerializedName + +data class ManualAddressRequest( + @SerializedName("hashAlgorithm") + val hashAlgorithm: Int, + + @SerializedName("publicKey") + val publicKey: String, + + @SerializedName("signatureAlgorithm") + val signatureAlgorithm: Int, + + @SerializedName("weight") + val weight: Int +) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt index bf2514516..4e6adbf9d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt @@ -7,16 +7,24 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,9 +38,20 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.DisposableEffect +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.colorResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -46,8 +65,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity +import com.flowfoundation.wallet.page.dialog.common.AddNewAccountDialog import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog import com.flowfoundation.wallet.page.wallet.view.AccountItemSection import com.flowfoundation.wallet.page.profile.subpage.wallet.WalletSettingActivity import com.flowfoundation.wallet.page.profile.subpage.wallet.childaccountdetail.ChildAccountDetailActivity @@ -66,10 +85,7 @@ class AccountListActivity : BaseActivity() { setContent { AccountListScreen( - onBackPressed = { finish() }, - onAddPressed = { - ProfileSwitchDialog().show(supportFragmentManager, "profile_switch") - } + onBackPressed = { finish() } ) } } @@ -85,12 +101,14 @@ class AccountListActivity : BaseActivity() { @Composable fun AccountListScreen( onBackPressed: () -> Unit = {}, - onAddPressed: () -> Unit = {}, viewModel: AccountListViewModel = viewModel() ) { val accounts by viewModel.accounts.collectAsState() val balanceMap by viewModel.balanceMap.collectAsState() val hiddenAccounts by viewModel.hiddenAccounts.collectAsState() + val isAddingAccount by viewModel.isAddingAccount.collectAsState() + val canAddAccount by viewModel.canAddAccount.collectAsState() + val canAddEOAAccount by viewModel.canAddEOAAccount.collectAsState() val context = LocalContext.current val activity = remember { getActivityFromContext(context) as FragmentActivity } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current @@ -138,10 +156,23 @@ fun AccountListScreen( } }, actions = { - IconButton(onClick = onAddPressed) { + val canAdd = canAddAccount || canAddEOAAccount + IconButton( + onClick = { + AddNewAccountDialog.show( + activity.supportFragmentManager, + canAddCadence = canAddAccount, + canAddEOA = canAddEOAAccount, + onCadence = { viewModel.addCadenceAccount() }, + onEOA = { viewModel.addEOAAccount() } + ) + }, + enabled = canAdd, + modifier = Modifier.alpha(if (canAdd) 1f else 0f) + ) { Icon( imageVector = Icons.Default.Add, - contentDescription = "Add Profile", + contentDescription = "Add Account", tint = colorResource(id = R.color.text) ) } @@ -186,7 +217,75 @@ fun AccountListScreen( } ) } + if (isAddingAccount) { + item { LoadingAccountListRow() } + } } } } } + +@Composable +private fun LoadingAccountListRow() { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "loading_rotation" + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Box( + modifier = Modifier.size(45.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + progress = { 0.25f }, + modifier = Modifier.size(45.dp).rotate(rotation), + color = colorResource(id = R.color.accent_green), + trackColor = colorResource(id = R.color.accent_green_8), + strokeWidth = 7.dp + ) + Icon( + painter = painterResource(id = R.drawable.ic_coin_flow), + contentDescription = null, + modifier = Modifier.size(30.dp), + tint = Color.Unspecified + ) + } + Spacer(modifier = Modifier.width(9.dp)) + Column { + Box( + modifier = Modifier + .width(60.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(colorResource(id = R.color.bg_card)) + ) + Spacer(modifier = Modifier.height(2.dp)) + Box( + modifier = Modifier + .width(100.dp) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(colorResource(id = R.color.bg_card)) + ) + Spacer(modifier = Modifier.height(2.dp)) + Box( + modifier = Modifier + .width(80.dp) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(colorResource(id = R.color.bg_card)) + ) + } + } +} 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 1dcb5ad7b..99e5e4829 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 @@ -1,24 +1,35 @@ package com.flowfoundation.wallet.page.account import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.R 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.chainNetWorkString import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.emoji.OnEmojiUpdate +import com.flowfoundation.wallet.manager.evm.COAVisibilityCache import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.manager.wallet.WalletManager 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.manager.config.AppConfig +import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider +import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flowfoundation.wallet.network.addNewEOAAccount +import com.flowfoundation.wallet.network.addNewFlowAccount import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.page.main.model.WalletAccountData import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.toast import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,10 +46,16 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { private val _hiddenAccounts = MutableStateFlow>(emptySet()) val hiddenAccounts: StateFlow> = _hiddenAccounts.asStateFlow() - private val service by lazy { retrofitApi().create(ApiService::class.java) } + private val _isAddingAccount = MutableStateFlow(false) + val isAddingAccount: StateFlow = _isAddingAccount.asStateFlow() + + private val _canAddAccount = MutableStateFlow(false) + val canAddAccount: StateFlow = _canAddAccount.asStateFlow() - // Cache for verified EVM addresses that should be included in linkedAccounts - private val verifiedEvmAddresses = mutableSetOf() + private val _canAddEOAAccount = MutableStateFlow(false) + val canAddEOAAccount: StateFlow = _canAddEOAAccount.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } init { AccountEmojiManager.addListener(this) @@ -56,25 +73,25 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { val addressList = mutableListOf() val accounts = mutableListOf() val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping + val hideCOAWithZeroBalance = isHideCOAWithZeroBalanceEnable() + val selectedAddress = WalletManager.selectedWalletAddress() 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), + name = mainNode.name, + emojiId = mainNode.emojiId, + isSelected = selectedAddress.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 -> @@ -86,8 +103,8 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { address = linkedWallet.address, name = linkedWallet.name, icon = linkedWallet.icon, - emojiId = AccountEmojiManager.getEmojiByAddress(linkedWallet.address).emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(linkedWallet.address, ignoreCase = true), + emojiId = linkedWallet.emojiId, + isSelected = selectedAddress.equals(linkedWallet.address, ignoreCase = true), isCOAAccount = false ) ) @@ -95,21 +112,21 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { is COAWallet -> { val evmAddress = linkedWallet.address addressList.add(evmAddress) - // 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) + val isSelected = selectedAddress.equals(evmAddress, ignoreCase = true) + // Show immediately if: currently selected, preference disabled, or cache-verified + if (isSelected || !hideCOAWithZeroBalance || COAVisibilityCache.isVerified(evmAddress)) { linkedAccounts.add( LinkedAccountData( address = evmAddress, - name = linkedEmojiInfo.emojiName, + name = linkedWallet.name, icon = null, - emojiId = linkedEmojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + emojiId = linkedWallet.emojiId, + isSelected = isSelected, isCOAAccount = true ) ) + } else { + pendingEvmAddresses.add(Pair(evmAddress, mainNode.address)) } } } @@ -118,9 +135,9 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { accounts.add( WalletAccountData( address = mainNode.address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), + name = mainNode.name, + emojiId = mainNode.emojiId, + isSelected = selectedAddress.equals(mainNode.address, ignoreCase = true), linkedAccounts = linkedAccounts, isEOAAccount = false ) @@ -144,12 +161,64 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { _hiddenAccounts.value = hiddenAccountsSet } + val flowWalletCount = walletNodes.filterIsInstance() + .count { it.chainIdString == currentNetwork } + val cryptoProvider = CryptoProviderManager.getCurrentCryptoProvider() + _canAddAccount.value = AppConfig.canCreateNewAccount() + && flowWalletCount < 5 + && cryptoProvider != null + && cryptoProvider !is AndroidKeystoreCryptoProvider + + val eoaWalletCount = walletNodes.filterIsInstance().size + val canCreate = AppConfig.canCreateNewAccount() + val canDerive = WalletManager.canDeriveEoa() + val eoaUnderLimit = eoaWalletCount < 5 + val isHDProvider = cryptoProvider is HDWalletCryptoProvider + _canAddEOAAccount.value = canCreate && canDerive && eoaUnderLimit && isHDProvider + logd("AccountListViewModel", "canAddEOA: canCreate=$canCreate, canDerive=$canDerive, eoaCount=$eoaWalletCount(<5=$eoaUnderLimit), isHDProvider=$isHDProvider => ${_canAddEOAAccount.value}") + if (refreshBalance) { fetchAllBalances(addressList, pendingEvmAddresses) } } } + fun addCadenceAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewFlowAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } + + fun addEOAAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewEOAAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } + private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { ioScope { val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope @@ -158,29 +227,50 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { } _balanceMap.value = formattedBalanceMap + val selectedAddress = WalletManager.selectedWalletAddress() + // Check each pending EVM address pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> - val evmBalance = balanceMap[evmAddress] - val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO - var hasNFTs = false + // Skip balance check for the currently selected COA — it's always shown + if (selectedAddress.equals(evmAddress, ignoreCase = true)) { + COAVisibilityCache.markVerified(evmAddress) + return@forEach + } + + val shouldShow = run { + // 1. Check FLOW balance + val evmBalance = balanceMap[evmAddress] + if (evmBalance != null && evmBalance > BigDecimal.ZERO) return@run true - if (!hasBalance) { + // 2. Check EVM token balances (USDC, WETH, etc.) + try { + val tokenResponse = service.getEVMTokenList(evmAddress, null, null) + val hasEvmTokens = tokenResponse.data?.any { token -> + token.balance?.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true + } == true + if (hasEvmTokens) return@run true + } catch (_: Exception) { + // Ignore EVM token API errors + } + + // 3. Check NFT collections 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) { + if (nftResponse.data?.isNotEmpty() == true && totalNftCount > 0) return@run true + } catch (_: Exception) { // Ignore NFT API errors } + + false } - if (hasBalance || hasNFTs) { + if (shouldShow) { // Add EVM address to linked accounts val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } + val walletAccount = currentAccounts.find { it.address.equals(walletAddress, ignoreCase = true) } walletAccount?.let { account -> - // Check if EVM address already exists in linked accounts - val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + val alreadyExists = account.linkedAccounts.any { it.address.equals(evmAddress, ignoreCase = true) } if (!alreadyExists) { val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) val updatedLinkedAccounts = account.linkedAccounts.toMutableList() @@ -190,41 +280,37 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate { name = emojiInfo.emojiName, icon = null, emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + isSelected = selectedAddress.equals(evmAddress, ignoreCase = true), isCOAAccount = true ) ) val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + val accountIndex = currentAccounts.indexOfFirst { it.address.equals(walletAddress, ignoreCase = true) } if (accountIndex >= 0) { currentAccounts[accountIndex] = updatedAccount _accounts.value = currentAccounts } } - // Add to verified cache for future refreshWalletList calls - verifiedEvmAddresses.add(evmAddress) + COAVisibilityCache.markVerified(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 } + val walletAccount = currentAccounts.find { it.address.equals(walletAddress, ignoreCase = true) } walletAccount?.let { account -> - val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + val existingLinkedAccount = account.linkedAccounts.find { it.address.equals(evmAddress, ignoreCase = true) } if (existingLinkedAccount != null) { val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.removeAll { it.address == evmAddress } + updatedLinkedAccounts.removeAll { it.address.equals(evmAddress, ignoreCase = true) } val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + val accountIndex = currentAccounts.indexOfFirst { it.address.equals(walletAddress, ignoreCase = true) } if (accountIndex >= 0) { currentAccounts[accountIndex] = updatedAccount _accounts.value = currentAccounts } } } - // Remove from verified cache - verifiedEvmAddresses.remove(evmAddress) + COAVisibilityCache.markUnverified(evmAddress) } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/BackupSeedPhraseInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/BackupSeedPhraseInfoFragment.kt index 3195b6a1e..5d6ac1161 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/BackupSeedPhraseInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/BackupSeedPhraseInfoFragment.kt @@ -14,7 +14,7 @@ import com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic.MnemonicAd import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.utils.saveBackupMnemonicToPreference import com.flowfoundation.wallet.widgets.itemdecoration.GridSpaceItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq class BackupSeedPhraseInfoFragment: Fragment() { @@ -47,7 +47,7 @@ class BackupSeedPhraseInfoFragment: Fragment() { adapter = this@BackupSeedPhraseInfoFragment.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) visible() } with(binding.copyButton) { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/ViewRecoveryPhraseFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/ViewRecoveryPhraseFragment.kt index 1e855af46..066bdd5c8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/ViewRecoveryPhraseFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/backup/fragment/ViewRecoveryPhraseFragment.kt @@ -12,7 +12,7 @@ import com.flowfoundation.wallet.page.backup.viewmodel.BackupViewMnemonicViewMod import com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic.MnemonicAdapter import com.flowfoundation.wallet.utils.extensions.setVisible import com.flowfoundation.wallet.widgets.itemdecoration.GridSpaceItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq class ViewRecoveryPhraseFragment: Fragment() { private lateinit var binding: FragmentViewRecoveryPhraseBinding @@ -45,7 +45,7 @@ class ViewRecoveryPhraseFragment: Fragment() { adapter = this@ViewRecoveryPhraseFragment.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) } binding.copyButton.setOnClickListener { viewModel.copyMnemonic() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/fragment/BackupRecoveryPhraseInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/fragment/BackupRecoveryPhraseInfoFragment.kt index 4a0deb4f9..1ff3392d1 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/fragment/BackupRecoveryPhraseInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/fragment/BackupRecoveryPhraseInfoFragment.kt @@ -16,7 +16,7 @@ import com.flowfoundation.wallet.page.backup.multibackup.viewmodel.MultiBackupVi import com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic.MnemonicAdapter import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.widgets.itemdecoration.GridSpaceItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq class BackupRecoveryPhraseInfoFragment : Fragment() { @@ -53,7 +53,7 @@ class BackupRecoveryPhraseInfoFragment : Fragment() { adapter = this@BackupRecoveryPhraseInfoFragment.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) visible() } with(binding.copyButton) { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/view/BackupCompletedItemView.kt b/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/view/BackupCompletedItemView.kt index d72e78357..6f674594b 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/view/BackupCompletedItemView.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/backup/multibackup/view/BackupCompletedItemView.kt @@ -17,7 +17,7 @@ import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.utils.textToClipboard import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.widgets.itemdecoration.GridSpaceItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq class BackupCompletedItemView @JvmOverloads constructor( @@ -62,7 +62,7 @@ class BackupCompletedItemView @JvmOverloads constructor( adapter = this@BackupCompletedItemView.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) } binding.clMnemonic.visible() binding.mnemonicContainer.visible() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/browser/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/page/browser/Utils.kt index 880012cd4..8fa385956 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/browser/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/browser/Utils.kt @@ -12,8 +12,8 @@ import com.bumptech.glide.load.model.LazyHeaders import com.flowfoundation.wallet.R import com.flowfoundation.wallet.database.AppDataBase import com.flowfoundation.wallet.database.WebviewRecord -import com.flowfoundation.wallet.manager.app.isTestnet import com.flowfoundation.wallet.manager.config.AppConfig +import com.flowfoundation.wallet.network.explorerUrl import com.flowfoundation.wallet.page.browser.tools.browserTabLast import com.flowfoundation.wallet.page.browser.tools.expandWebView import com.flowfoundation.wallet.page.browser.tools.shrinkWebView @@ -159,11 +159,11 @@ fun String.toFavIcon(size: Int = 256): String { } fun openInFlowScan(activity: Activity, transactionId: String) { - openBrowser(activity, "https://${if (isTestnet()) "testnet." else ""}flowscan" + ".io/tx/$transactionId") + openBrowser(activity, explorerUrl(id = transactionId, type = "tx", chain = "flow")) } fun openInFlowEVMScan(activity: Activity, transactionId: String) { - openBrowser(activity, "https://${if (isTestnet()) "evm-testnet" else "evm"}.flowscan.io/tx/$transactionId") + openBrowser(activity, explorerUrl(id = transactionId, type = "tx", chain = "evm")) } class BrowserParams( diff --git a/app/src/main/java/com/flowfoundation/wallet/page/browser/presenter/BrowserPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/browser/presenter/BrowserPresenter.kt index 819d3b4de..3b51cd5cd 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/browser/presenter/BrowserPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/browser/presenter/BrowserPresenter.kt @@ -1,8 +1,10 @@ package com.flowfoundation.wallet.page.browser.presenter +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.manager.app.chainNetWorkString -import com.zackratos.ultimatebarx.ultimatebarx.navigationBarHeight import com.zackratos.ultimatebarx.ultimatebarx.statusBarHeight import com.flowfoundation.wallet.base.presenter.BasePresenter import com.flowfoundation.wallet.databinding.LayoutBrowserBinding @@ -35,10 +37,14 @@ class BrowserPresenter( with(binding) { contentWrapper.post { statusBarHolder.layoutParams.height = statusBarHeight - with(root) { - val navBarHeight = if (navigationBarHeight < 50) 0 else navigationBarHeight - setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom + navBarHeight) - } + } + // Use dynamic insets instead of static navigationBarHeight with a threshold. + // The FloatWindow is added directly to activity.window.decorView, so it participates + // in the normal insets dispatch chain and this listener will fire correctly. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val navBar = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + view.updatePadding(bottom = navBar.bottom) + insets } with(binding) { refreshButton.setOnClickListener { webview()?.reload() } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/common/NotificationDispatchActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/common/NotificationDispatchActivity.kt index 5123c1320..5df6c4246 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/common/NotificationDispatchActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/common/NotificationDispatchActivity.kt @@ -6,7 +6,7 @@ import com.flowfoundation.wallet.base.activity.BaseActivity import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.zackratos.ultimatebarx.ultimatebarx.UltimateBarX -import com.flowfoundation.wallet.manager.app.isTestnet +import com.flowfoundation.wallet.network.explorerUrl import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.safeRun @@ -28,7 +28,6 @@ class NotificationDispatchActivity : BaseActivity() { } finish() - logd("xxx", "data: $data") } override fun finish() { @@ -44,8 +43,6 @@ class NotificationDispatchActivity : BaseActivity() { } private fun String.toFlowScanTransactionUrl(): String { - return if (isTestnet()) { - "https://testnet.flowscan.io/tx/$this" - } else "https://flowscan.io/tx/$this" + return explorerUrl(id = this, type = "tx", chain = "flow") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/common/WebViewActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/common/WebViewActivity.kt index d0379cc91..137bb25b1 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/common/WebViewActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/common/WebViewActivity.kt @@ -15,6 +15,7 @@ class WebViewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_webview) + findViewById(R.id.webview).apply { loadUrl(this@WebViewActivity.url!!) } @@ -37,4 +38,4 @@ class WebViewActivity : BaseActivity() { }) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt new file mode 100644 index 000000000..70bb0f083 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/common/AddNewAccountDialog.kt @@ -0,0 +1,166 @@ +package com.flowfoundation.wallet.page.dialog.common + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentManager +import com.flowfoundation.wallet.R +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class AddNewAccountDialog : BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + AddNewAccountContent( + canAddCadence = canAddCadence, + canAddEOA = canAddEOA, + onCadenceClick = { + dismiss() + onCadenceClick?.invoke() + }, + onEOAClick = { + dismiss() + onEOAClick?.invoke() + } + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + onCadenceClick = null + onEOAClick = null + } + + companion object { + private var onCadenceClick: (() -> Unit)? = null + private var onEOAClick: (() -> Unit)? = null + private var canAddCadence: Boolean = true + private var canAddEOA: Boolean = true + + fun show( + fragmentManager: FragmentManager, + canAddCadence: Boolean = true, + canAddEOA: Boolean = true, + onCadence: () -> Unit, + onEOA: () -> Unit + ) { + this.onCadenceClick = onCadence + this.onEOAClick = onEOA + this.canAddCadence = canAddCadence + this.canAddEOA = canAddEOA + AddNewAccountDialog().showNow(fragmentManager, "AddNewAccountDialog") + } + } +} + +@Composable +private fun AddNewAccountContent( + canAddCadence: Boolean, + canAddEOA: Boolean, + onCadenceClick: () -> Unit, + onEOAClick: () -> Unit +) { + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.deep_bg_dialog), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(18.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (canAddCadence) 1f else 0.4f) + .clickable(enabled = canAddCadence) { onCadenceClick() } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_cadence_account), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.create_new_cadence_account), + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (canAddEOA) 1f else 0.4f) + .clickable(enabled = canAddEOA) { onEOAClick() } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_eoa_account), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.create_new_eoa_account), + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt index d06966580..a5d97464b 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt @@ -107,7 +107,7 @@ private fun ProfileSwitchContent( modifier = Modifier .fillMaxWidth() .background( - color = colorResource(id = R.color.deep_bg), + color = colorResource(id = R.color.deep_bg_dialog), shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) .padding(18.dp) @@ -262,14 +262,10 @@ private fun LocalSwitchAccountItem( ConstraintLayout( modifier = Modifier .fillMaxWidth() - .background( - color = colorResource(id = R.color.bg_card), - shape = RoundedCornerShape(16.dp) - ) - .padding(18.dp) + .padding(vertical = 20.dp) .clickable(onClick = onClick) ) { - val (icon, name, address) = createRefs() + val (icon, name) = createRefs() // Placeholder icon Icon( @@ -278,7 +274,7 @@ private fun LocalSwitchAccountItem( tint = colorResource(id = R.color.icon), modifier = Modifier .constrainAs(icon) { - top.linkTo(parent.top, 8.dp) + top.linkTo(parent.top) start.linkTo(parent.start) } .size(40.dp) @@ -289,27 +285,16 @@ private fun LocalSwitchAccountItem( text = account.username, color = colorResource(id = R.color.text_1), fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, modifier = Modifier .constrainAs(name) { top.linkTo(icon.top) + bottom.linkTo(icon.bottom) start.linkTo(icon.end, 12.dp) - end.linkTo(parent.end, 12.dp) + end.linkTo(parent.end) width = Dimension.fillToConstraints } ) - - // Address - Text( - text = account.address, - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(address) { - top.linkTo(name.bottom, 4.dp) - start.linkTo(name.start) - } - ) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt index f1688a32e..d520c3524 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt @@ -5,7 +5,6 @@ import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.model.LocalSwitchAccount import com.flowfoundation.wallet.manager.app.chainNetWorkString -import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.retrofitApi @@ -102,8 +101,7 @@ class ProfileSwitchViewModel : ViewModel() { flowWallets.forEach { flowWallet -> // Main account - val emojiInfo = AccountEmojiManager.getEmojiByAddress(flowWallet.address) - avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) + avatars.add(AvatarData.Emoji(flowWallet.emojiId)) flowWallet.linkedWallets.forEach { linked -> when (linked) { @@ -111,8 +109,7 @@ class ProfileSwitchViewModel : ViewModel() { if (linked.icon.isNotEmpty()) { avatars.add(AvatarData.Icon(linked.icon)) } else { - val childEmojiInfo = AccountEmojiManager.getEmojiByAddress(linked.address) - avatars.add(AvatarData.Emoji(childEmojiInfo.emojiId)) + avatars.add(AvatarData.Emoji(linked.emojiId)) } } is COAWallet -> { @@ -128,8 +125,7 @@ class ProfileSwitchViewModel : ViewModel() { // Add EOA address avatar profile.walletNodes.filterIsInstance().forEach { eoa -> - val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoa.address) - avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) + avatars.add(AvatarData.Emoji(eoa.emojiId)) } return ProfileItemData(profile, avatars, emptyMap()) @@ -177,27 +173,39 @@ class ProfileSwitchViewModel : ViewModel() { flowWallet.linkedWallets.filterIsInstance().forEach { coaWallet -> val coaAddress = coaWallet.address - // Only show COA if it has balance or NFTs - val coaBalance = balanceMap[coaAddress] - val hasBalance = coaBalance != null && coaBalance > BigDecimal.ZERO - var hasNFTs = false + // Only show COA if it has balance, EVM tokens, or NFTs + val shouldShowCoa = run { + // 1. Check FLOW balance + val coaBalance = balanceMap[coaAddress] + if (coaBalance != null && coaBalance > BigDecimal.ZERO) return@run true - if (!hasBalance) { + // 2. Check EVM token balances (USDC, WETH, etc.) + try { + val tokenResponse = service.getEVMTokenList(coaAddress, null, null) + val hasEvmTokens = tokenResponse.data?.any { token -> + token.balance?.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true + } == true + if (hasEvmTokens) return@run true + } catch (_: Exception) { + // Ignore EVM token API errors + } + + // 3. Check NFT collections try { val nftResponse = service.getEVMNFTCollections(coaAddress) val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 - hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 - } catch (e: Exception) { + if (nftResponse.data?.isNotEmpty() == true && totalNftCount > 0) return@run true + } catch (_: Exception) { // Ignore NFT API errors } + + false } - val shouldShowCoa = hasBalance || hasNFTs if (shouldShowCoa) { // Add if not present if (!verifiedCoaAvatars.containsKey(coaAddress)) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(coaAddress) - val avatarData = AvatarData.Emoji(emojiInfo.emojiId) + val avatarData = AvatarData.Emoji(coaWallet.emojiId) verifiedCoaAvatars[coaAddress] = avatarData currentAvatars.add(avatarData) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt index 479c654e8..dec146757 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt @@ -7,10 +7,6 @@ import android.content.BroadcastReceiver import android.content.IntentFilter import android.os.Bundle import androidx.core.view.GravityCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding import androidx.lifecycle.ViewModelProvider import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.flowfoundation.wallet.base.activity.BaseActivity @@ -33,11 +29,15 @@ import com.flowfoundation.wallet.utils.isNotificationPermissionChecked import com.flowfoundation.wallet.utils.isNotificationPermissionGrand import com.flowfoundation.wallet.utils.isRegistered import com.flowfoundation.wallet.utils.uiScope -import com.instabug.bug.BugReporting -import com.instabug.library.Instabug +import ai.luciq.bug.BugReporting +import ai.luciq.library.Luciq +import com.flowfoundation.wallet.manager.inbox.InboxManager +import com.flowfoundation.wallet.manager.transaction.OnTransactionStateChange +import com.flowfoundation.wallet.manager.transaction.TransactionState +import com.flowfoundation.wallet.manager.transaction.TransactionStateManager import com.flowfoundation.wallet.manager.wallet.WalletManager -class MainActivity : BaseActivity() { +class MainActivity : BaseActivity(), OnTransactionStateChange { private lateinit var contentPresenter: MainContentPresenter @@ -61,13 +61,6 @@ class MainActivity : BaseActivity() { UltimateBarX.with(this).fitWindow(false).light(!isNightMode(this)).applyStatusBar() - WindowCompat.setDecorFitsSystemWindows(window, false) - - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> - val systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - binding.navigationView.updatePadding(bottom = systemBarsInsets.bottom) - windowInsets - } contentPresenter = MainContentPresenter(this, binding) setupDrawerLayoutCompose(binding.drawerLayout) binding.drawerLayout.close() @@ -84,7 +77,7 @@ class MainActivity : BaseActivity() { // Navigate to target tab if specified if (targetTabIndex >= 0) { - val targetTab = HomeTab.values().find { it.index == targetTabIndex } + val targetTab = HomeTab.entries.find { it.index == targetTabIndex } targetTab?.let { viewModel.changeTab(it) } } } @@ -95,6 +88,7 @@ class MainActivity : BaseActivity() { } configurationInstabugBugReport() LocalBroadcastManager.getInstance(this).registerReceiver(restoreMnemonicReceiver, IntentFilter("ACTION_RESTORE_MNEMONIC")) + TransactionStateManager.addOnTransactionStateChange(this) WalletManager.checkKeyRotation(this) WalletManager.checkKeystoreMigration(this) } @@ -102,10 +96,10 @@ class MainActivity : BaseActivity() { private fun configurationInstabugBugReport() { BugReporting.setOnInvokeCallback { DebugViewerDataSource.generateDebugZipFile(this)?.let { - Instabug.addFileAttachment(it, "log.zip") + Luciq.addFileAttachment(it, "log.zip") } BugReporting.setOnDismissCallback { _, _ -> - Instabug.clearFileAttachment() + Luciq.clearFileAttachment() BugReporting.setOnDismissCallback(null) } } @@ -143,6 +137,15 @@ class MainActivity : BaseActivity() { RootDetectedDialog.show(supportFragmentManager) super.onResume() checkPendingAction() + InboxManager.refresh() + } + + override fun onTransactionStateChange() { + val transactionList = TransactionStateManager.getTransactionStateList() + val transaction = transactionList.lastOrNull { it.type == TransactionState.TYPE_SEND } + if (transaction?.isSuccess() == true) { + InboxManager.refresh() + } } private fun checkPendingAction() { 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 3d8b11794..89fc7aab3 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 @@ -1,12 +1,13 @@ package com.flowfoundation.wallet.page.main.drawer import androidx.lifecycle.ViewModel -import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.R import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager 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.COAVisibilityCache import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.manager.walletdata.FlowWallet @@ -14,15 +15,24 @@ import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.walletdata.ChildWallet import com.flowfoundation.wallet.manager.walletdata.COAWallet import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.manager.config.AppConfig +import com.flowfoundation.wallet.manager.key.AndroidKeystoreCryptoProvider +import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flowfoundation.wallet.network.addNewEOAAccount +import com.flowfoundation.wallet.network.addNewFlowAccount import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.network.model.UserInfoData import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.manager.account.OnAccountUpdate import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.utils.toast import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,10 +52,16 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { private val _balanceMap = MutableStateFlow>(emptyMap()) val balanceMap: StateFlow> = _balanceMap.asStateFlow() - private val service by lazy { retrofitApi().create(ApiService::class.java) } + private val _isAddingAccount = MutableStateFlow(false) + val isAddingAccount: StateFlow = _isAddingAccount.asStateFlow() + + private val _canAddAccount = MutableStateFlow(false) + val canAddAccount: StateFlow = _canAddAccount.asStateFlow() - // Cache for verified EVM addresses that should be included in linkedAccounts - private val verifiedEvmAddresses = mutableSetOf() + private val _canAddEOAAccount = MutableStateFlow(false) + val canAddEOAAccount: StateFlow = _canAddEOAAccount.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } init { AccountManager.addListener(this) @@ -53,7 +69,6 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } fun loadData(refreshBalance: Boolean = false) { - loadEvmStatus() refreshWalletList(refreshBalance) } @@ -70,25 +85,25 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { val addressList = mutableListOf() val accounts = mutableListOf() val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping + val hideCOAWithZeroBalance = isHideCOAWithZeroBalanceEnable() + val selectedAddress = WalletManager.selectedWalletAddress() 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), + name = mainNode.name, + emojiId = mainNode.emojiId, + isSelected = selectedAddress.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 -> @@ -100,8 +115,8 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { address = linkedWallet.address, name = linkedWallet.name, icon = linkedWallet.icon, - emojiId = AccountEmojiManager.getEmojiByAddress(linkedWallet.address).emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(linkedWallet.address, ignoreCase = true), + emojiId = linkedWallet.emojiId, + isSelected = selectedAddress.equals(linkedWallet.address, ignoreCase = true), isCOAAccount = false ) ) @@ -109,20 +124,21 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { is COAWallet -> { val evmAddress = linkedWallet.address addressList.add(evmAddress) - if (evmAddress !in verifiedEvmAddresses) { - pendingEvmAddresses.add(Pair(evmAddress, mainNode.address)) - } else { - val linkedEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + val isSelected = selectedAddress.equals(evmAddress, ignoreCase = true) + // Show immediately if: currently selected, preference disabled, or cache-verified + if (isSelected || !hideCOAWithZeroBalance || COAVisibilityCache.isVerified(evmAddress)) { linkedAccounts.add( LinkedAccountData( address = evmAddress, - name = linkedEmojiInfo.emojiName, + name = linkedWallet.name, icon = null, - emojiId = linkedEmojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true), + emojiId = linkedWallet.emojiId, + isSelected = isSelected, isCOAAccount = true ) ) + } else { + pendingEvmAddresses.add(Pair(evmAddress, mainNode.address)) } } } @@ -131,9 +147,9 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { accounts.add( WalletAccountData( address = mainNode.address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true), + name = mainNode.name, + emojiId = mainNode.emojiId, + isSelected = selectedAddress.equals(mainNode.address, ignoreCase = true), linkedAccounts = linkedAccounts ) ) @@ -143,24 +159,89 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } } - // Filter out hidden accounts for the current user - val userId = firebaseUid() + // Filter out hidden accounts for the current user. + // Use AccountManager (already updated before listeners fire) rather than + // firebaseUid() (reads Firebase.auth.currentUser which can still be anonymous + // during the auth transition window of an account switch). + // Always keep the currently selected account visible to avoid broken UI state. + val userId = AccountManager.get()?.wallet?.id val filteredAccounts = if (userId != null) { AccountVisibilityManager.filterVisibleAccounts( userId, accounts - ) { it.address } + ) { it.address }.let { visible -> + // Ensure the currently selected account is always present + if (visible.none { it.address.equals(selectedAddress, ignoreCase = true) }) { + val selected = accounts.find { it.address.equals(selectedAddress, ignoreCase = true) } + if (selected != null) visible + selected else visible + } else { + visible + } + } } else { accounts } _accounts.value = filteredAccounts + loadEvmStatus() + + val flowWalletCount = walletNodes.filterIsInstance() + .count { it.chainIdString == currentNetwork } + val cryptoProvider = CryptoProviderManager.getCurrentCryptoProvider() + _canAddAccount.value = AppConfig.canCreateNewAccount() + && flowWalletCount < 5 + && cryptoProvider != null + && cryptoProvider !is AndroidKeystoreCryptoProvider + + val eoaWalletCount = walletNodes.filterIsInstance().size + val canCreate = AppConfig.canCreateNewAccount() + val canDerive = WalletManager.canDeriveEoa() + val eoaUnderLimit = eoaWalletCount < 5 + val isHDProvider = cryptoProvider is HDWalletCryptoProvider + _canAddEOAAccount.value = canCreate && canDerive && eoaUnderLimit && isHDProvider + logd("DrawerLayoutViewModel", "canAddEOA: canCreate=$canCreate, canDerive=$canDerive, eoaCount=$eoaWalletCount(<5=$eoaUnderLimit), isHDProvider=$isHDProvider => ${_canAddEOAAccount.value}") + if (refreshBalance) { fetchAllBalances(addressList, pendingEvmAddresses) } } } + fun addCadenceAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewFlowAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } + + fun addEOAAccount() { + ioScope { + _isAddingAccount.value = true + try { + val result = addNewEOAAccount() + if (result == null) { + _isAddingAccount.value = false + toast(msgRes = R.string.common_error_hint) + } else { + _balanceMap.value += (result to "0 FLOW") + refreshWalletList(false) + } + } finally { + _isAddingAccount.value = false + } + } + } private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { ioScope { val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope @@ -169,29 +250,50 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } _balanceMap.value = formattedBalanceMap + val selectedAddress = WalletManager.selectedWalletAddress() + // Check each pending EVM address pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> - val evmBalance = balanceMap[evmAddress] - val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO - var hasNFTs = false + // Skip balance check for the currently selected COA — it's always shown + if (selectedAddress.equals(evmAddress, ignoreCase = true)) { + COAVisibilityCache.markVerified(evmAddress) + return@forEach + } - if (!hasBalance) { + val shouldShow = run { + // 1. Check FLOW balance + val evmBalance = balanceMap[evmAddress] + if (evmBalance != null && evmBalance > BigDecimal.ZERO) return@run true + + // 2. Check EVM token balances (USDC, WETH, etc.) + try { + val tokenResponse = service.getEVMTokenList(evmAddress, null, null) + val hasEvmTokens = tokenResponse.data?.any { token -> + token.balance?.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true + } == true + if (hasEvmTokens) return@run true + } catch (_: Exception) { + // Ignore EVM token API errors + } + + // 3. Check NFT collections try { val nftResponse = service.getEVMNFTCollections(evmAddress) val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 - hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + if (nftResponse.data?.isNotEmpty() == true && totalNftCount > 0) return@run true } catch (_: Exception) { // Ignore NFT API errors } + + false } - if (hasBalance || hasNFTs) { + if (shouldShow) { // Add EVM address to linked accounts val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } + val walletAccount = currentAccounts.find { it.address.equals(walletAddress, true) } walletAccount?.let { account -> - // Check if EVM address already exists in linked accounts - val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + val alreadyExists = account.linkedAccounts.any { it.address.equals(evmAddress, true) } if (!alreadyExists) { val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) val updatedLinkedAccounts = account.linkedAccounts.toMutableList() @@ -201,39 +303,37 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { name = emojiInfo.emojiName, icon = null, emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isSelected = selectedAddress.equals(evmAddress, ignoreCase = true), isCOAAccount = true ) ) val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + val accountIndex = currentAccounts.indexOfFirst { it.address.equals(walletAddress, ignoreCase = true) } if (accountIndex >= 0) { currentAccounts[accountIndex] = updatedAccount _accounts.value = currentAccounts } } - // Add to verified cache for future refreshWalletList calls - verifiedEvmAddresses.add(evmAddress) + COAVisibilityCache.markVerified(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 } + val walletAccount = currentAccounts.find { it.address.equals(walletAddress, ignoreCase = true) } walletAccount?.let { account -> - val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + val existingLinkedAccount = account.linkedAccounts.find { it.address.equals(evmAddress, ignoreCase = true) } if (existingLinkedAccount != null) { val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.removeAll { it.address == evmAddress } + updatedLinkedAccounts.removeAll { it.address.equals(evmAddress, ignoreCase = true) } val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + val accountIndex = currentAccounts.indexOfFirst { it.address.equals(walletAddress, ignoreCase = true) } if (accountIndex >= 0) { currentAccounts[accountIndex] = updatedAccount _accounts.value = currentAccounts } } } - // Remove from verified cache - verifiedEvmAddresses.remove(evmAddress) + COAVisibilityCache.markUnverified(evmAddress) } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt index e76cd3a3c..d7c405d8f 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt @@ -21,10 +21,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.draw.rotate import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -32,8 +40,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext @@ -67,6 +77,7 @@ import com.flowfoundation.wallet.manager.transaction.TransactionStateManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.network.clearWebViewCache import com.flowfoundation.wallet.network.model.UserInfoData +import com.flowfoundation.wallet.page.dialog.common.AddNewAccountDialog import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog import com.flowfoundation.wallet.page.evm.EnableEVMActivity import com.flowfoundation.wallet.page.main.MainActivity @@ -107,6 +118,9 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { val showEvmLayout by viewModel.showEvmLayout.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() + val isAddingAccount by viewModel.isAddingAccount.collectAsStateWithLifecycle() + val canAddAccount by viewModel.canAddAccount.collectAsStateWithLifecycle() + val canAddEOAAccount by viewModel.canAddEOAAccount.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.loadData() @@ -134,18 +148,16 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { .background(colorResource(id = R.color.deep_bg)) .padding(horizontal = 18.dp, vertical = 24.dp) ) { - userInfo?.let { - HeaderSection( - userInfo = it, - onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } - ) - HorizontalDivider( - color = colorResource(id = R.color.border_line_stroke), - modifier = Modifier - .fillMaxWidth() - .padding(top = 14.dp) - ) - } + HeaderSection( + userInfo = userInfo, + onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } + ) + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + ) if (showEvmLayout) { Spacer(modifier = Modifier.height(24.dp)) @@ -163,6 +175,7 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { AccountListSection( accounts = accounts, balanceMap = balanceMap, + isAddingAccount = isAddingAccount, onCopyClick = onCopyClick@{ address -> if (EVMWalletManager.isEVMWalletAddress(address)) { CopyCOAAddressDialog(context, address).show() @@ -172,7 +185,8 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { toast(msgRes = R.string.copy_address_toast) }, onAccountClick = { address -> - FlowLoadingDialog(context).show() + val loadingDialog = FlowLoadingDialog(context) + loadingDialog.show() WalletManager.selectWalletAddress(address) ioScope { delay(200) @@ -187,6 +201,7 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { CryptoProviderManager.clear() delay(1000) uiScope { + loadingDialog.dismiss() MainActivity.relaunch(Env.getApp()) } } @@ -202,65 +217,93 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { onImportWalletClick = { WalletRestoreActivity.launch(activity) }, - onAddProfileClick = { - if (isTestnet()) { - SwitchNetworkDialog(context, DialogType.CREATE).show() - } else { - ReactNativeActivity.launchWithRoute(context, RNBridge.ScreenType.ONBOARDING, RNBridge.InitialRoute.PROFILE_TYPE_SELECTION) - } - } + onAddAccountClick = { + AddNewAccountDialog.show( + activity.supportFragmentManager, + canAddCadence = canAddAccount, + canAddEOA = canAddEOAAccount, + onCadence = { viewModel.addCadenceAccount() }, + onEOA = { viewModel.addEOAAccount() } + ) + }, + canAddAccount = canAddAccount || canAddEOAAccount ) } } @Composable fun HeaderSection( - userInfo: UserInfoData, + userInfo: UserInfoData?, onAccountSwitchClick: () -> Unit ) { - val avatarUrl = userInfo.avatar.parseAvatarUrl() - val avatar = if (avatarUrl.contains("flovatar.com")) { - avatarUrl.svgToPng() - } else { - avatarUrl - } ConstraintLayout( modifier = Modifier .fillMaxWidth() .padding(top = 40.dp) ) { val (icon, name, switch) = createRefs() - AsyncImage( - model = avatar, - contentDescription = "User Avatar", - contentScale = ContentScale.Crop, - placeholder = painterResource(id = R.drawable.ic_placeholder), - error = painterResource(id = R.drawable.ic_placeholder), - modifier = Modifier - .constrainAs(icon) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(name.start) - } - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - ) - Text( - text = userInfo.nickname, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(icon.end, 16.dp) - end.linkTo(switch.start, 16.dp) - width = Dimension.fillToConstraints - } - ) + if (userInfo == null) { + ShimmerBox( + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + } + .size(40.dp), + shape = RoundedCornerShape(8.dp) + ) + ShimmerBox( + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + .height(16.dp) + .width(120.dp) + ) + } else { + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(name.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + } IconButton( onClick = onAccountSwitchClick, @@ -664,6 +707,7 @@ fun AccountListSection( balanceMap: Map, onCopyClick: (String) -> Unit, onAccountClick: (String) -> Unit, + isAddingAccount: Boolean = false, modifier: Modifier = Modifier ) { val scrollState = rememberScrollState() @@ -679,13 +723,16 @@ fun AccountListSection( color = colorResource(id = R.color.text_2), fontSize = 14.sp ) - activeAccount?.let { + if (activeAccount != null) { Spacer(modifier = Modifier.height(16.dp)) ActiveAccountSection( - item = it, + item = activeAccount, balanceMap = balanceMap, onCopyClick = onCopyClick ) + } else if (accounts.isEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ShimmerWalletAccountRow() } Spacer(modifier = Modifier.height(16.dp)) Text( @@ -694,20 +741,30 @@ fun AccountListSection( fontSize = 14.sp, ) - accounts.forEach { account -> - WalletAccountSection( - item = account, - balance = balanceMap[account.address.toAddress()] ?: "", - onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(account.address) } - ) - account.linkedAccounts.forEach { linkedAccount -> - LinkedAccountSection( - item = linkedAccount, - balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + if (isAddingAccount) { + LoadingWalletAccountRow() + } + + if (accounts.isEmpty()) { + repeat(2) { + ShimmerWalletAccountRow() + } + } else { + accounts.forEach { account -> + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "", onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(linkedAccount.address) } + onAccountClick = { onAccountClick(account.address) } ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(linkedAccount.address) } + ) + } } } } @@ -716,37 +773,164 @@ fun AccountListSection( @Composable fun BottomSection( onImportWalletClick: () -> Unit, - onAddProfileClick: () -> Unit + onAddAccountClick: () -> Unit = {}, + canAddAccount: Boolean = false ) { + Column { + if (canAddAccount) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onAddAccountClick), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_add_24), + contentDescription = "Add Account", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.add_account), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onImportWalletClick), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_import_wallet), + contentDescription = "Recover Profile", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.recover_profile), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + } +} + +@Composable +private fun LoadingWalletAccountRow() { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "loading_rotation" + ) Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 12.dp) ) { - Box(modifier = Modifier - .size(40.dp) - .background( - color = colorResource(id = R.color.bg_card), - shape = CircleShape - ) - .clickable(onClick = onAddProfileClick), + Box( + modifier = Modifier.size(45.dp), contentAlignment = Alignment.Center ) { + CircularProgressIndicator( + progress = { 0.25f }, + modifier = Modifier.size(45.dp).rotate(rotation), + color = colorResource(id = R.color.accent_green), + trackColor = colorResource(id = R.color.accent_green_8), + strokeWidth = 7.dp + ) Icon( - painter = painterResource(id = R.drawable.ic_baseline_add_24), - contentDescription = "Add Account", - tint = colorResource(id = R.color.text_1) + painter = painterResource(id = R.drawable.ic_coin_flow), + contentDescription = null, + modifier = Modifier.size(30.dp), + tint = Color.Unspecified ) } - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource(id = R.string.recover_profile), - color = colorResource(id = R.color.text_2), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable(onClick = onImportWalletClick) + Spacer(modifier = Modifier.width(9.dp)) + Column { + ShimmerBox(modifier = Modifier.width(60.dp).height(14.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(100.dp).height(12.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(80.dp).height(12.dp)) + } + } +} + +@Composable +private fun ShimmerBox( + modifier: Modifier, + shape: Shape = RoundedCornerShape(4.dp) +) { + val alpha by rememberInfiniteTransition(label = "shimmer").animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer_alpha" + ) + Box( + modifier = modifier + .alpha(alpha) + .background(colorResource(id = R.color.bg_card), shape) + ) +} + +@Composable +private fun ShimmerWalletAccountRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + ShimmerBox( + modifier = Modifier.size(42.dp), + shape = CircleShape ) + Spacer(modifier = Modifier.width(9.dp)) + Column { + ShimmerBox(modifier = Modifier.width(60.dp).height(14.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(100.dp).height(12.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(80.dp).height(12.dp)) + } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/nft/nftlist/presenter/NFTFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/nft/nftlist/presenter/NFTFragmentPresenter.kt index 75d3d7248..9434220ef 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/nft/nftlist/presenter/NFTFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/nft/nftlist/presenter/NFTFragmentPresenter.kt @@ -9,7 +9,10 @@ import com.zackratos.ultimatebarx.ultimatebarx.statusBarHeight import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.presenter.BasePresenter import com.flowfoundation.wallet.databinding.FragmentNftBinding +import com.flowfoundation.wallet.manager.inbox.InboxManager +import com.flowfoundation.wallet.manager.inbox.OnInboxCountUpdate import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.reactnative.ReactNativeActivity import com.flowfoundation.wallet.page.nft.collectionlist.NftCollectionListActivity import com.flowfoundation.wallet.page.nft.nftlist.NFTFragment import com.flowfoundation.wallet.page.nft.nftlist.NftViewModel @@ -17,7 +20,9 @@ import com.flowfoundation.wallet.page.nft.nftlist.adapter.NftListPageAdapter import com.flowfoundation.wallet.page.nft.nftlist.model.NFTFragmentModel import com.flowfoundation.wallet.utils.ScreenUtils import com.flowfoundation.wallet.utils.extensions.res2color +import com.flowfoundation.wallet.utils.extensions.gone import com.flowfoundation.wallet.utils.extensions.setVisible +import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.utils.startShimmer import java.lang.Float.min import android.view.Gravity @@ -26,7 +31,7 @@ import android.view.Gravity class NFTFragmentPresenter( private val fragment: NFTFragment, private val binding: FragmentNftBinding, -) : BasePresenter { +) : BasePresenter, OnInboxCountUpdate { private val viewModel by lazy { ViewModelProvider(fragment.requireActivity())[NftViewModel::class.java] } @@ -47,6 +52,12 @@ class NFTFragmentPresenter( binding.viewToggleButton.setOnClickListener { view -> showCustomPopupMenu(view) } + + binding.flInboxNft.setOnClickListener { + ReactNativeActivity.launchClaimTokens(fragment.requireContext()) + } + InboxManager.addListener(this@NFTFragmentPresenter) + updateInboxBadge(InboxManager.unclaimedCount) } startShimmer(binding.shimmerLayout.shimmerLayout) @@ -127,4 +138,19 @@ class NFTFragmentPresenter( } private fun isGridTabSelected() = true + + override fun onInboxCountUpdate(count: Int) { + updateInboxBadge(count) + } + + private fun updateInboxBadge(count: Int) { + val show = count > 0 && !WalletManager.isEVMAccountSelected() && !WalletManager.isChildAccountSelected() + binding.flInboxNft.setVisible(show) + if (show) { + binding.tvInboxBadgeNft.visible() + binding.tvInboxBadgeNft.text = InboxManager.formatBadgeCount(count) + } else { + binding.tvInboxBadgeNft.gone() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/flowfoundation/wallet/page/notification/model/WalletNotification.kt b/app/src/main/java/com/flowfoundation/wallet/page/notification/model/WalletNotification.kt index 54738d3c4..0bbd23d18 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/notification/model/WalletNotification.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/notification/model/WalletNotification.kt @@ -75,7 +75,10 @@ enum class Type { IMAGE, @SerializedName("pending_request") - PENDING_REQUEST + PENDING_REQUEST, + + @SerializedName("inbox") + INBOX } enum class Priority { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/notification/presenter/NotificationItemPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/notification/presenter/NotificationItemPresenter.kt index 2a266b415..d4ffcac76 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/notification/presenter/NotificationItemPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/notification/presenter/NotificationItemPresenter.kt @@ -9,16 +9,19 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.base.presenter.BasePresenter import com.flowfoundation.wallet.base.recyclerview.BaseViewHolder import com.flowfoundation.wallet.databinding.ItemWalletNotificationBinding +import com.flowfoundation.wallet.manager.inbox.InboxManager import com.flowfoundation.wallet.manager.notification.WalletNotificationManager import com.flowfoundation.wallet.manager.walletconnect.dispatch import com.flowfoundation.wallet.manager.walletconnect.getWalletConnectPendingRequests import com.flowfoundation.wallet.manager.walletconnect.model.toWcRequest import com.flowfoundation.wallet.page.browser.openBrowser import com.flowfoundation.wallet.page.notification.model.DisplayType +import com.flowfoundation.wallet.reactnative.ReactNativeActivity import com.flowfoundation.wallet.page.notification.model.Type import com.flowfoundation.wallet.page.notification.model.WalletNotification import com.flowfoundation.wallet.page.profile.subpage.walletconnect.session.WalletConnectSessionActivity @@ -43,8 +46,11 @@ class NotificationItemPresenter( if (model.icon.isNullOrEmpty()) { ivIcon.gone() } else { - Glide.with(ivIcon).load(model.icon()).transform(CenterCrop(), CircleCrop()) - .into(ivIcon) + if (model.type == Type.INBOX) { + ivIcon.setImageResource(R.drawable.ic_inbox) + } else { + Glide.with(ivIcon).load(model.icon()).transform(CenterCrop(), CircleCrop()).into(ivIcon) + } ivIcon.visible() } tvTitle.text = model.title.orEmpty() @@ -78,6 +84,12 @@ class NotificationItemPresenter( WalletNotificationManager.removeNotification(model) } binding.root.setOnClickListener { + if (model.type == Type.INBOX) { + ReactNativeActivity.launchClaimTokens(view.context) + WalletNotificationManager.removeNotification(model) + InboxManager.clearNotificationRef() + return@setOnClickListener + } if (model.type == Type.PENDING_REQUEST) { logd("NotificationItemPresenter", "click pending request") diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt index 5639718c9..ebe74b3e8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt @@ -138,7 +138,7 @@ fun SettingSwitchItem( modifier = Modifier .fillMaxWidth() .clickable { onCheckedChange(!isChecked) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // Icon diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt index a4799afd7..5ee9fccb6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -44,7 +46,7 @@ import com.flowfoundation.wallet.page.account.AccountListActivity import com.flowfoundation.wallet.page.security.SecuritySettingActivity import com.flowfoundation.wallet.utils.* import com.flowfoundation.wallet.utils.extensions.openInSystemBrowser -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import kotlinx.coroutines.launch @Composable @@ -255,7 +257,7 @@ fun SettingScreen( titleRes = R.string.bug_report, showDivider = true ) { - Instabug.show() + Luciq.show() } if (isSignedIn) { @@ -302,6 +304,7 @@ fun SettingScreen( text = stringResource(R.string.free_gas_fee_desc), fontSize = 12.sp, color = colorResource(R.color.text_2), + style = TextStyle(lineBreak = LineBreak.Paragraph), modifier = Modifier .fillMaxWidth() .padding(top = 6.dp) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt index fa773c49a..e068779c3 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt @@ -2,7 +2,7 @@ package com.flowfoundation.wallet.page.profile.presenter import android.annotation.SuppressLint import androidx.lifecycle.ViewModelProvider -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.zackratos.ultimatebarx.ultimatebarx.addStatusBarTopPadding import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.presenter.BasePresenter @@ -105,7 +105,7 @@ class ProfileFragmentPresenter( ) } - binding.group3.bugReport.setOnClickListener { Instabug.show() } + binding.group3.bugReport.setOnClickListener { Luciq.show() } binding.group3.aboutPreference.setOnClickListener { AboutActivity.launch(context) } binding.group4.switchAccountPreference.setOnClickListener { logd("ProfileFragmentPresenter", "switchAccountPreference clicked") diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt index 4114f4f7b..582681d7e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt @@ -15,6 +15,8 @@ import com.flowfoundation.wallet.manager.cadence.CadenceApiManager import com.flowfoundation.wallet.page.profile.subpage.developer.DeveloperModeViewModel import com.flowfoundation.wallet.page.profile.subpage.developer.LocalAccountKeyActivity import com.flowfoundation.wallet.page.profile.subpage.developer.model.DeveloperPageModel +import com.flowfoundation.wallet.utils.isDev +import com.flowfoundation.wallet.utils.isTesting import com.flowfoundation.wallet.utils.NETWORK_MAINNET import com.flowfoundation.wallet.utils.NETWORK_TESTNET import com.flowfoundation.wallet.utils.debug.DebugLogManager @@ -31,7 +33,9 @@ import com.flowfoundation.wallet.utils.updateChainNetworkPreference import com.flowfoundation.wallet.utils.getWatchCollectibleAddress import com.flowfoundation.wallet.utils.setWatchCollectibleAddress import com.flowfoundation.wallet.utils.clearWatchCollectibleAddress +import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.isWrapEOATxWithCadenceEnable +import com.flowfoundation.wallet.utils.setHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.setWrapEOATxWithCadenceEnable import com.flowfoundation.wallet.widgets.ProgressDialog import kotlinx.coroutines.delay @@ -79,12 +83,15 @@ class DeveloperModePresenter( // Initialize Watch Collectible Address setupWatchCollectibleAddress() setupWrapEOATxWithCadence() + setupHideCOAWithZeroBalance() developerModePreference.setOnCheckedChangeListener { setDevelopContentVisible(it) setDeveloperModeEnable(it) if (!it) { changeNetwork(NETWORK_MAINNET) + } else { + ioScope { refreshChainNetworkSync() } } } @@ -128,7 +135,7 @@ class DeveloperModePresenter( private fun setDevelopContentVisible(visible: Boolean) { binding.group2.setVisible(visible) binding.group3.setVisible(visible) - binding.cvDebug.setVisible(visible) + binding.cvDebug.setVisible(visible && (isDev() || isTesting())) binding.cvAccountKey.setVisible(visible && showLocalAccountKeys) binding.cvReloadConfig.setVisible(visible) } @@ -174,6 +181,18 @@ class DeveloperModePresenter( } } + private fun setupHideCOAWithZeroBalance() { + ioScope { + val isHide = isHideCOAWithZeroBalanceEnable() + uiScope { + binding.hideCoaZeroBalance.setChecked(isHide) + binding.hideCoaZeroBalance.setOnCheckedChangeListener { + setHideCOAWithZeroBalanceEnable(it) + } + } + } + } + private fun setupWatchCollectibleAddress() { val savedAddress = getWatchCollectibleAddress() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/wallet/WalletSettingActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/wallet/WalletSettingActivity.kt index 4603b83a8..27dea2d1e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/wallet/WalletSettingActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/wallet/WalletSettingActivity.kt @@ -34,6 +34,7 @@ import com.flowfoundation.wallet.utils.extensions.setVisible import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.manager.wallet.WalletManager class WalletSettingActivity : BaseActivity(), OnEmojiUpdate { @@ -97,17 +98,21 @@ class WalletSettingActivity : BaseActivity(), OnEmojiUpdate { } // Initialize account visibility preference - val userId = firebaseUid() ?: "" - val isAccountVisible = !AccountVisibilityManager.isAccountHidden(userId, walletAddress) - showInAccountListPreference.setChecked(isAccountVisible) - showInAccountListPreference.setOnCheckedChangeListener { checked -> - val currentUserId = firebaseUid() ?: "" - if (checked) { - // User wants to show the account in the list (make it visible) - AccountVisibilityManager.showAccount(currentUserId, walletAddress) - } else { - // User wants to hide the account from the list - AccountVisibilityManager.hideAccount(currentUserId, walletAddress) + // Hide the toggle for the currently selected account — it must remain visible + val isCurrentlySelected = WalletManager.selectedWalletAddress() + .equals(walletAddress, ignoreCase = true) + showInAccountListPreference.setVisible(!isCurrentlySelected) + if (!isCurrentlySelected) { + val userId = firebaseUid() ?: "" + val isAccountVisible = !AccountVisibilityManager.isAccountHidden(userId, walletAddress) + showInAccountListPreference.setChecked(isAccountVisible) + showInAccountListPreference.setOnCheckedChangeListener { checked -> + val currentUserId = firebaseUid() ?: "" + if (checked) { + AccountVisibilityManager.showAccount(currentUserId, walletAddress) + } else { + AccountVisibilityManager.hideAccount(currentUserId, walletAddress) + } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt index 25d37960e..d3d855431 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt @@ -20,7 +20,7 @@ import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -121,7 +121,7 @@ class PrivateKeyInfoFragment: Fragment() { } updateImportButtonState() - Instabug.addPrivateViews(etPrivateKey) + Luciq.addPrivateViews(etPrivateKey) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt index 92d756ac3..9458c2bc2 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt @@ -31,7 +31,7 @@ import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import org.json.JSONObject @@ -122,8 +122,8 @@ class PrivateKeyStoreInfoFragment: Fragment() { // Set up clickable error message setupErrorMessage() - Instabug.addPrivateViews(etJson) - Instabug.addPrivateViews(etPassword) + Luciq.addPrivateViews(etJson) + Luciq.addPrivateViews(etPassword) } // Observe keystore format errors from ViewModel diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt index 13fdc98e9..3f93769f8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt @@ -34,7 +34,7 @@ import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.widgets.itemdecoration.ColorDividerItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import wallet.core.jni.HDWallet @@ -124,7 +124,7 @@ class SeedPhraseInfoFragment: Fragment() { ) } btnImport.isEnabled = false - Instabug.addPrivateViews(etSeedPhrase) + Luciq.addPrivateViews(etSeedPhrase) } } 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 5ac7231a6..588e4c295 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 @@ -52,6 +52,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 @@ -288,8 +289,8 @@ class KeyStoreRestoreViewModel : ViewModel() { fun importPrivateKey(privateKey: String, address: String) { loadingLiveData.postValue(true) restoreType = RestoreType.PRIVATE_KEY - try { - ioScope { + ioScope { + try { val storage = getStorage() val key = PrivateKey.create(storage).apply { logd("KeyStoreRestoreViewModel", "Created new PrivateKey instance") @@ -320,12 +321,12 @@ class KeyStoreRestoreViewModel : ViewModel() { p1PublicKey ?: "" ) } + } catch (e: Exception) { + e.printStackTrace() + ErrorReporter.reportWithMixpanel(BackupError.PRIVATE_KEY_RESTORE_FAILED, e) + loadingLiveData.postValue(false) + toast(msgRes = R.string.restore_failed) } - } catch (e: Exception) { - e.printStackTrace() - ErrorReporter.reportWithMixpanel(BackupError.PRIVATE_KEY_RESTORE_FAILED, e) - loadingLiveData.postValue(false) - toast(msgRes = R.string.restore_failed) } } @@ -334,8 +335,8 @@ class KeyStoreRestoreViewModel : ViewModel() { loadingLiveData.postValue(true) restoreType = RestoreType.SEED_PHRASE currentMnemonic = mnemonic // Store mnemonic for use in KeystoreAddress creation - try { - ioScope { + ioScope { + try { val storage = getStorage() val seedPhraseKey = SeedPhraseKey( mnemonicString = mnemonic, @@ -363,12 +364,12 @@ class KeyStoreRestoreViewModel : ViewModel() { p1PublicKey ?: "" ) } + } catch (e: Exception) { + e.printStackTrace() + ErrorReporter.reportWithMixpanel(BackupError.SEED_PHRASE_RESTORE_FAILED, e) + loadingLiveData.postValue(false) + toast(msgRes = R.string.restore_failed) } - } catch (e: Exception) { - e.printStackTrace() - ErrorReporter.reportWithMixpanel(BackupError.SEED_PHRASE_RESTORE_FAILED, e) - loadingLiveData.postValue(false) - toast(msgRes = R.string.restore_failed) } } @@ -646,6 +647,7 @@ class KeyStoreRestoreViewModel : ViewModel() { return } if (WalletManager.getCurrentFlowWalletAddress() == currentKeyStoreAddress?.address) { + loadingLiveData.postValue(false) toast(msgRes = R.string.wallet_already_logged_in, duration = Toast.LENGTH_LONG) val activity = BaseActivity.getCurrentActivity() ?: return activity.finish() @@ -654,6 +656,7 @@ class KeyStoreRestoreViewModel : ViewModel() { val account = AccountManager.list() .firstOrNull { it.containsFlowWalletAddress(currentKeyStoreAddress?.address ?: "") } if (account != null) { + loadingLiveData.postValue(false) AccountManager.switch(account) {} return } @@ -666,6 +669,7 @@ class KeyStoreRestoreViewModel : ViewModel() { val activity = BaseActivity.getCurrentActivity() ?: run { logd("KeyStoreRestoreViewModel", "ERROR: No current activity found") + loadingLiveData.postValue(false) return@ioScope } @@ -695,6 +699,7 @@ class KeyStoreRestoreViewModel : ViewModel() { } } ?: run { logd("KeyStoreRestoreViewModel", "ERROR: Could not find matching key on-chain for public key: ${currentKeyStoreAddress?.publicKey}") + loadingLiveData.postValue(false) toast(msgRes = R.string.login_failure) activity.finish() return@ioScope @@ -704,12 +709,14 @@ class KeyStoreRestoreViewModel : ViewModel() { if (currentKey.weight.toInt() < 1000) { logd("KeyStoreRestoreViewModel", "ERROR: Key weight insufficient: ${currentKey.weight}") + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_insufficient_weight) activity.finish() return@ioScope } if (currentKey.revoked) { logd("KeyStoreRestoreViewModel", "ERROR: Key is revoked") + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_key_revoked) activity.finish() return@ioScope @@ -818,6 +825,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) @@ -859,6 +868,7 @@ class KeyStoreRestoreViewModel : ViewModel() { getFirebaseUid { uid -> if (uid.isNullOrBlank()) { logd("KeyStoreRestoreViewModel", "No Firebase UID found") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@getFirebaseUid } @@ -885,6 +895,7 @@ class KeyStoreRestoreViewModel : ViewModel() { if (resp.data?.customToken.isNullOrBlank()) { logd("KeyStoreRestoreViewModel", "No custom token in response") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@runCatching } @@ -925,6 +936,7 @@ class KeyStoreRestoreViewModel : ViewModel() { logd("KeyStoreRestoreViewModel", "ERROR: No wallet address found in key indexer after login") logd("KeyStoreRestoreViewModel", "Public key used for lookup: $publicKey") logd("KeyStoreRestoreViewModel", "Chain ID used: $chainId") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@ioScope } @@ -983,6 +995,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.") @@ -1030,6 +1044,7 @@ class KeyStoreRestoreViewModel : ViewModel() { private fun loginWithKeyStoreAddress(flowAccountKey: AccountPublicKey, keystoreAddress: KeystoreAddress) { if (WalletManager.getCurrentFlowWalletAddress() == keystoreAddress.address) { + loadingLiveData.postValue(false) toast(msgRes = R.string.wallet_already_logged_in, duration = Toast.LENGTH_LONG) val activity = BaseActivity.getCurrentActivity() ?: return activity.finish() @@ -1038,18 +1053,24 @@ class KeyStoreRestoreViewModel : ViewModel() { val account = AccountManager.list() .firstOrNull { it.containsFlowWalletAddress(keystoreAddress.address) } if (account != null) { + loadingLiveData.postValue(false) AccountManager.switch(account) {} return } ioScope { val cryptoProvider = PrivateKeyStoreCryptoProvider(Gson().toJson(keystoreAddress)) - val activity = BaseActivity.getCurrentActivity() ?: return@ioScope + val activity = BaseActivity.getCurrentActivity() ?: run { + loadingLiveData.postValue(false) + return@ioScope + } if (flowAccountKey.weight.toInt() < 1000) { + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_insufficient_weight) activity.finish() return@ioScope } if (flowAccountKey.revoked) { + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_key_revoked) activity.finish() return@ioScope @@ -1123,6 +1144,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(userId, keyStoreInfo) MixpanelManager.accountRestore( cryptoProvider.getAddress(), restoreType @@ -1204,7 +1227,10 @@ class KeyStoreRestoreViewModel : ViewModel() { logd("KeyStoreRestoreViewModel", "Starting create account with username: $username") ioScope { val cryptoProvider = PrivateKeyStoreCryptoProvider(Gson().toJson(currentKeyStoreAddress)) - val activity = BaseActivity.getCurrentActivity() ?: return@ioScope + val activity = BaseActivity.getCurrentActivity() ?: run { + loadingLiveData.postValue(false) + return@ioScope + } createAccount(username, cryptoProvider) { isSuccess -> uiScope { loadingLiveData.postValue(false) @@ -1251,39 +1277,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 @@ -1329,6 +1368,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(firebaseUid().orEmpty(), keyStoreInfo) MixpanelManager.accountCreated( cryptoProvider.getPublicKey(), AccountCreateKeyType.RESTORE_KEYSTORE, @@ -1341,11 +1382,15 @@ class KeyStoreRestoreViewModel : ViewModel() { } } } - } catch (e: HttpException) { - val errorBody = e.response()?.errorBody()?.string() - logd("KeyStoreRestoreViewModel", "HTTP Error: ${e.code()}, Response: $errorBody") + } catch (e: Exception) { + if (e is HttpException) { + val errorBody = e.response()?.errorBody()?.string() + logd("KeyStoreRestoreViewModel", "HTTP Error: ${e.code()}, Response: $errorBody") + } else { + logd("KeyStoreRestoreViewModel", "Error creating account: ${e.message}") + } callback.invoke(false) - throw e + // Don't rethrow to avoid crashing the coroutine if not handled } } } @@ -1365,6 +1410,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/page/restore/mnemonic/RestoreMnemonicViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt index 766c54704..db9893f1c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt @@ -1,27 +1,44 @@ package com.flowfoundation.wallet.page.restore.mnemonic import androidx.lifecycle.ViewModel +import com.flow.wallet.keys.SeedPhraseKey import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.AccountManager +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.manager.walletdata.WalletDataManager import com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress +import com.flowfoundation.wallet.utils.Env.getStorage import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.secret.EncryptedMnemonicUtils +import com.flowfoundation.wallet.wallet.DERIVATION_PATH import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.onflow.flow.models.SigningAlgorithm import wallet.core.jni.HDWallet class RestoreMnemonicViewModel : ViewModel() { + private val TAG = "RestoreMnemonicViewModel" + private val _isRestoring = MutableStateFlow(false) val isRestoring: StateFlow = _isRestoring.asStateFlow() private val _restoreSuccess = MutableStateFlow(false) val restoreSuccess: StateFlow = _restoreSuccess.asStateFlow() + private val _mnemonicMismatch = MutableStateFlow(false) + val mnemonicMismatch: StateFlow = _mnemonicMismatch.asStateFlow() + + fun resetMnemonicMismatch() { + _mnemonicMismatch.value = false + } + + @OptIn(ExperimentalStdlibApi::class) fun restoreMnemonic(mnemonic: String) { if (!validateMnemonic(mnemonic)) { // Should handle validation error in UI @@ -37,11 +54,41 @@ class RestoreMnemonicViewModel : ViewModel() { return@ioScope } + val currentKeyStoreInfo = currentAccount.keyStoreInfo + + // Validate that the mnemonic derives the same public key as stored in keyStoreInfo + if (!currentKeyStoreInfo.isNullOrBlank()) { + try { + val ks = Gson().fromJson(currentKeyStoreInfo, KeystoreAddress::class.java) + val seedPhraseKey = SeedPhraseKey( + mnemonicString = mnemonic, + passphrase = "", + derivationPath = DERIVATION_PATH, + storage = getStorage() + ) + val derivedPubKey = when (ks.signAlgo) { + SigningAlgorithm.ECDSA_secp256k1.cadenceIndex -> + seedPhraseKey.publicKey(SigningAlgorithm.ECDSA_secp256k1)?.toHexString()?.removePrefix("04") + else -> // ECDSA_P256 + seedPhraseKey.publicKey(SigningAlgorithm.ECDSA_P256)?.toHexString()?.removePrefix("04") + } + val storedPubKey = ks.publicKey.removePrefix("0x").lowercase() + if (derivedPubKey == null || !derivedPubKey.equals(storedPubKey, ignoreCase = true)) { + _mnemonicMismatch.value = true + _isRestoring.value = false + return@ioScope + } + } catch (e: Exception) { + loge(TAG, "Failed to validate mnemonic public key: ${e.message}") + _isRestoring.value = false + return@ioScope + } + } + // Encrypt mnemonic val encryptedMnemonic = EncryptedMnemonicUtils.encrypt(mnemonic, uid) // Update Account keystore info - val currentKeyStoreInfo = currentAccount.keyStoreInfo if (!currentKeyStoreInfo.isNullOrBlank()) { try { // Use atomic update to ensure keystoreInfo is updated safely @@ -52,12 +99,28 @@ class RestoreMnemonicViewModel : ViewModel() { val newKeystoreAddress = keystoreAddress.copy(encryptedMnemonic = encryptedMnemonic) account.copy(keyStoreInfo = Gson().toJson(newKeystoreAddress)) } else { - // This case should ideally not happen if keyStoreInfo was set initially. - // But if it does, we return the original account or handle it as an error. account } } - // Clear wallet cache and re-initialize + + // Sync to KeyStorageManager BEFORE clearing wallet cache. + // This ensures createWalletFromAccount() finds the seed phrase (not the + // old private key) when updateCurrentAccount() triggers wallet re-creation, + // preventing a spurious ACTION_RESTORE_MNEMONIC broadcast and ensuring + // setEoaDisabled(false) is called correctly. + try { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + KeyStorageManager.deletePrivateKey(uid) + val address = AccountManager.get()?.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } catch (e: Exception) { + loge(TAG, "KeyStorageManager sync failed: ${e.message}") + } + + // Clear wallet cache and re-initialize — now finds seed phrase path, + // sets EOA enabled and does NOT re-trigger the restore broadcast. WalletManager.clear() WalletDataManager.updateCurrentAccount() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt index 2c36eb8f4..cc972c26e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -53,6 +54,7 @@ fun RestoreMnemonicScreen( ) { val isRestoring by viewModel.isRestoring.collectAsState() val restoreSuccess by viewModel.restoreSuccess.collectAsState() + val mnemonicMismatch by viewModel.mnemonicMismatch.collectAsState() var mnemonic by remember { mutableStateOf("") } var isValid by remember { mutableStateOf(false) } @@ -64,6 +66,7 @@ fun RestoreMnemonicScreen( LaunchedEffect(mnemonic) { isValid = viewModel.validateMnemonic(mnemonic.trim()) + if (mnemonicMismatch) viewModel.resetMnemonicMismatch() } Scaffold( @@ -116,20 +119,29 @@ fun RestoreMnemonicScreen( OutlinedTextField( value = mnemonic, - onValueChange = { mnemonic = it }, + onValueChange = { + mnemonic = it + if (mnemonicMismatch) viewModel.resetMnemonicMismatch() + }, + isError = mnemonicMismatch, + supportingText = if (mnemonicMismatch) { + { Text(stringResource(R.string.restore_mnemonic_mismatch), color = colorResource(id = R.color.error)) } + } else null, modifier = Modifier .fillMaxWidth() .height(200.dp), placeholder = { Text("Enter recovery phrase", color = colorResource(id = R.color.text_3)) }, shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = colorResource(id = R.color.bg_2), // Use bg_2 for input background + focusedContainerColor = colorResource(id = R.color.bg_2), unfocusedContainerColor = colorResource(id = R.color.bg_2), + errorContainerColor = colorResource(id = R.color.bg_2), focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, cursorColor = colorResource(id = R.color.colorSecondary), focusedTextColor = colorResource(id = R.color.text), - unfocusedTextColor = colorResource(id = R.color.text) + unfocusedTextColor = colorResource(id = R.color.text), + errorTextColor = colorResource(id = R.color.text), ) ) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt index 55f260c94..4d7bdf6ca 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt @@ -51,7 +51,7 @@ import com.flowfoundation.wallet.utils.setMultiBackupCreated import com.flowfoundation.wallet.utils.setRegistered import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.utils.uiScope -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flow.wallet.keys.SeedPhraseKey import com.flow.wallet.storage.FileSystemStorage import com.flowfoundation.wallet.manager.flowjvm.transaction.sendTransactionWithMultiSignature @@ -527,7 +527,7 @@ class MultiRestoreViewModel : ViewModel(), OnTransactionStateChange { reportCadenceErrorToDebugView(scriptId, e) if (e is InvalidKeyException) { ErrorReporter.reportCriticalWithMixpanel(WalletError.QUERY_ACCOUNT_KEY_FAILED, e) - Instabug.show() + Luciq.show() } return null } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt index 2a5676055..92f1d1419 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.databinding.ActivitySecurityPrivateKeyBinding @@ -57,7 +57,7 @@ class SecurityPrivateKeyActivity : BaseActivity() { publicKeyCopyButton.setOnClickListener { copyToClipboard(cryptoProvider.getPublicKey()) } hashAlgorithm.text = getString(R.string.hash_algorithm, cryptoProvider.getHashAlgorithm().algorithm) signAlgorithm.text = getString(R.string.sign_algorithm, cryptoProvider.getSignatureAlgorithm().value) - Instabug.addPrivateViews(privateKeyView) + Luciq.addPrivateViews(privateKeyView) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt index 8c94bcb39..c1d60a9ba 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt @@ -13,7 +13,7 @@ import com.flowfoundation.wallet.utils.extensions.res2String import com.flowfoundation.wallet.utils.isNightMode import com.flowfoundation.wallet.utils.textToClipboard import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.zackratos.ultimatebarx.ultimatebarx.UltimateBarX class SecurityPublicKeyActivity : BaseActivity() { @@ -51,7 +51,7 @@ class SecurityPublicKeyActivity : BaseActivity() { hashAlgorithm.text = getString(R.string.hash_algorithm, cryptoProvider.getHashAlgorithm().algorithm) signAlgorithm.text = getString(R.string.sign_algorithm, cryptoProvider.getSignatureAlgorithm().value) - Instabug.addPrivateViews(privateKeyView) + Luciq.addPrivateViews(privateKeyView) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt index 9d07c5672..f6ae68ffa 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Bundle import android.view.MenuItem import androidx.recyclerview.widget.GridLayoutManager -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.databinding.ActivitySecurityRecoveryBinding @@ -63,7 +63,7 @@ class SecurityRecoveryActivity : BaseActivity() { adapter = this@SecurityRecoveryActivity.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) } loadMnemonic() } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/token/detail/presenter/TokenDetailPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/token/detail/presenter/TokenDetailPresenter.kt index bc536a3fc..231e9e849 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/token/detail/presenter/TokenDetailPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/token/detail/presenter/TokenDetailPresenter.kt @@ -22,6 +22,7 @@ import com.flowfoundation.wallet.page.profile.subpage.wallet.ChildAccountCollect import com.flowfoundation.wallet.page.receive.ReceiveActivity import com.flowfoundation.wallet.reactnative.ReactNativeActivity import com.flowfoundation.wallet.manager.app.isTestnet +import com.flowfoundation.wallet.network.explorerUrl import com.flowfoundation.wallet.reactnative.bridge.RNBridge import com.flowfoundation.wallet.reactnative.bridge.toRNBridgeTokenModel import com.flowfoundation.wallet.page.staking.openStakingPage @@ -135,9 +136,9 @@ class TokenDetailPresenter( binding.tvContractAddress.paintFlags = binding.tvContractAddress.paintFlags or Paint.UNDERLINE_TEXT_FLAG binding.tvContractAddress.setOnClickListener { val url = if (token.tokenType == FungibleTokenType.FLOW || token.isFlowToken()) { - "https://www.flowscan.io/ft/token/${token.tokenIdentifier()}" + explorerUrl(id = token.tokenIdentifier(), type = "token", chain = "flow") } else { - "https://evm.flowscan.io/token/${token.tokenAddress()}" + explorerUrl(id = token.tokenAddress(), type = "token", chain = "evm") } openBrowser(activity, url) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/token/manage/presenter/ManageTokenItemPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/token/manage/presenter/ManageTokenItemPresenter.kt index ceeff200d..9e808b94e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/token/manage/presenter/ManageTokenItemPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/token/manage/presenter/ManageTokenItemPresenter.kt @@ -35,9 +35,9 @@ class ManageTokenItemPresenter( switchDisplay.isChecked = FungibleTokenListManager.isDisplayToken(contractId = model.contractId()) switchDisplay.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { - FungibleTokenListManager.addDisplayToken(model) + FungibleTokenListManager.showToken(model) } else { - FungibleTokenListManager.removeDisplayToken(model) + FungibleTokenListManager.hideToken(model) } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/transaction/record/presenter/TransactionViewMoreItemPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/transaction/record/presenter/TransactionViewMoreItemPresenter.kt index 283842e73..62a9d0f9b 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/transaction/record/presenter/TransactionViewMoreItemPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/transaction/record/presenter/TransactionViewMoreItemPresenter.kt @@ -3,8 +3,8 @@ package com.flowfoundation.wallet.page.transaction.record.presenter import android.view.View import com.flowfoundation.wallet.base.presenter.BasePresenter import com.flowfoundation.wallet.base.recyclerview.BaseViewHolder -import com.flowfoundation.wallet.manager.app.isTestnet import com.flowfoundation.wallet.manager.evm.EVMWalletManager +import com.flowfoundation.wallet.network.explorerUrl import com.flowfoundation.wallet.page.browser.openBrowser import com.flowfoundation.wallet.page.transaction.record.model.TransactionViewMoreModel import com.flowfoundation.wallet.utils.findActivity @@ -18,16 +18,8 @@ class TransactionViewMoreItemPresenter( } private fun TransactionViewMoreModel.url(): String { - return if (EVMWalletManager.isEVMWalletAddress(address)) { - if (isTestnet()) { - "https://evm-testnet.flowscan.io/address/$address" - } else { - "https://evm.flowscan.io/address/$address" - } - } else { - if (isTestnet()) { - "https://testnet.flowscan.io/account/$address" - } else "https://flowscan.io/account/$address" - } + val chain = if (EVMWalletManager.isEVMWalletAddress(address)) "evm" else "flow" + val type = if (chain == "evm") "address" else "account" + return explorerUrl(id = address, type = type, chain = chain) } } \ No newline at end of file diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt index f56f5ab0e..289c933e0 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt @@ -84,6 +84,8 @@ class WalletFragmentPresenter( model.data?.let { reportEvent("wallet_coin_list_loaded", mapOf("count" to it.size.toString())) adapter.setNewDiffData(it) + binding.shimmerCoinList.stopShimmer() + binding.shimmerCoinList.gone() binding.refreshLayout.isRefreshing = false bindUserInfo() } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt index 1124d9833..f8c170a66 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt @@ -3,7 +3,6 @@ package com.flowfoundation.wallet.page.wallet.presenter import android.annotation.SuppressLint import android.view.View import android.widget.TextView -import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider @@ -15,6 +14,8 @@ import com.flowfoundation.wallet.base.recyclerview.BaseViewHolder import com.flowfoundation.wallet.databinding.LayoutWalletCoordinatorHeaderBinding import com.flowfoundation.wallet.manager.app.isTestnet import com.flowfoundation.wallet.manager.config.AppConfig +import com.flowfoundation.wallet.manager.inbox.InboxManager +import com.flowfoundation.wallet.manager.inbox.OnInboxCountUpdate import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.manager.notification.WalletNotificationManager import com.flowfoundation.wallet.manager.token.FungibleTokenListManager @@ -46,7 +47,7 @@ import java.util.Date class WalletHeaderPresenter( private val fragment: Fragment, private val view: View, -) : BaseViewHolder(view), BasePresenter { +) : BaseViewHolder(view), BasePresenter, OnInboxCountUpdate { private val binding by lazy { LayoutWalletCoordinatorHeaderBinding.bind(view) } @@ -68,6 +69,8 @@ class WalletHeaderPresenter( ).ifEmpty { "0" } ) ivHide.setImageResource(if (isHideBalance) R.drawable.ic_eye_off else R.drawable.ic_eye_on) + shimmerBalance.stopShimmer() + shimmerBalance.gone() } val count = if (model.coinCount > 0 ) model.coinCount else FungibleTokenListManager.getCurrentDisplayTokenListSnapshot().size @@ -80,17 +83,24 @@ class WalletHeaderPresenter( cvReceive.setOnClickListener { ReactNativeActivity.launch(view.context, RNBridge.ScreenType.RECEIVE) } val address = shortenEVMString(WalletManager.selectedWalletAddress().toAddress()) tvAddress.text = address + shimmerAddress.stopShimmer() + shimmerAddress.gone() ivCopy.setVisible(address.isNotBlank()) ivCopy.setOnClickListener { copyAddress( WalletManager.selectedWalletAddress().toAddress() ) } + InboxManager.addListener(this@WalletHeaderPresenter) + flInbox.setOnClickListener { ReactNativeActivity.launchClaimTokens(view.context) } + updateInboxBadge(InboxManager.unclaimedCount) + if (WalletManager.isChildAccountSelected()) { cvSwap.gone() cvBuy.gone() flManageToken.gone() flAddToken.gone() + flInbox.gone() } else { flAddToken.setOnClickListener { if (WalletManager.isEVMAccountSelected()) { @@ -192,6 +202,23 @@ class WalletHeaderPresenter( } } + override fun onInboxCountUpdate(count: Int) { + updateInboxBadge(count) + } + + private fun updateInboxBadge(count: Int) { + with(binding) { + val show = count > 0 && !WalletManager.isChildAccountSelected() + flInbox.setVisible(show) + if (show) { + tvInboxBadge.visible() + tvInboxBadge.text = InboxManager.formatBadgeCount(count) + } else { + tvInboxBadge.gone() + } + } + } + private fun TextView.diffSetText(text: String?) { if (text != this.text.toString()) { this.text = text diff --git a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt deleted file mode 100644 index 875b96ba8..000000000 --- a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/mnemonic/WalletCreateMnemonicViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import com.flowfoundation.wallet.utils.viewModelIOScope -import com.flow.wallet.crypto.BIP39 -import com.flow.wallet.keys.SeedPhraseKey -import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.flowfoundation.wallet.utils.Env.getStorage -import com.flowfoundation.wallet.wallet.DERIVATION_PATH -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - - -class WalletCreateMnemonicViewModel : ViewModel() { - - val mnemonicList = MutableLiveData>() - private var currentMnemonic: String? = null - - fun loadMnemonic() { - viewModelIOScope(this) { - // Generate a new mnemonic using BIP39 - val mnemonic = BIP39.generate(BIP39.SeedPhraseLength.TWELVE) - currentMnemonic = mnemonic - - withContext(Dispatchers.Main) { - val list = mnemonic.split(" ").mapIndexed { index, s -> MnemonicModel(index + 1, s) } - val result = mutableListOf() - (0 until list.size / 2).forEach { i -> - result.add(list[i]) - result.add(list[i + list.size / 2]) - } - mnemonicList.value = result - } - } - } - - fun getMnemonic(): String { - return currentMnemonic ?: throw IllegalStateException("Mnemonic not generated yet") - } - - @OptIn(ExperimentalStdlibApi::class) - fun createWallet() { - viewModelIOScope(this) { - val mnemonic = getMnemonic() - val seedPhraseKey = SeedPhraseKey( - mnemonicString = mnemonic, - passphrase = "", - derivationPath = DERIVATION_PATH, - storage = getStorage() - ) - - // Create keystore info for the account - val privateKeyHex = seedPhraseKey.privateKey(org.onflow.flow.models.SigningAlgorithm.ECDSA_P256)?.toHexString() ?: "" - val publicKeyHex = seedPhraseKey.publicKey(org.onflow.flow.models.SigningAlgorithm.ECDSA_P256)?.toHexString()?.removePrefix("04") ?: "" - val walletAddress = WalletManager.getCurrentFlowWalletAddress() ?: "" - - // Create keystore address info - val keystoreAddress = com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress( - address = walletAddress, - publicKey = publicKeyHex, - privateKey = privateKeyHex, - keyId = 0, - weight = 1000, - hashAlgo = org.onflow.flow.models.HashingAlgorithm.SHA2_256.cadenceIndex, - signAlgo = org.onflow.flow.models.SigningAlgorithm.ECDSA_P256.cadenceIndex - ) - - // Create user info for local wallet - val userInfo = com.flowfoundation.wallet.network.model.UserInfoData( - nickname = "Local User", - username = "local_${System.currentTimeMillis()}", - avatar = "", - address = walletAddress, - isPrivate = 0, - created = java.time.Instant.now().toString() - ) - - // Create and add account to AccountManager with keystore info - val account = com.flowfoundation.wallet.manager.account.Account( - userInfo = userInfo, - keyStoreInfo = com.google.gson.Gson().toJson(keystoreAddress) - ) - - com.flowfoundation.wallet.manager.account.AccountManager.add(account) - - // Mark as registered immediately after wallet creation (like server registration) - // This allows users to proceed even if they exit backup early - com.flowfoundation.wallet.utils.setRegistered() - } - } -} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt index 390beabf7..ce22ca6a0 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt @@ -3,10 +3,9 @@ package com.flowfoundation.wallet.page.walletrestore import androidx.annotation.WorkerThread import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase -import com.flowfoundation.wallet.firebase.auth.deleteAnonymousUser import com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin import com.flowfoundation.wallet.firebase.auth.getFirebaseJwt -import com.flowfoundation.wallet.firebase.auth.isAnonymousSignIn +import com.flowfoundation.wallet.firebase.auth.setToAnonymous import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.DeviceInfoManager @@ -17,7 +16,6 @@ import com.flowfoundation.wallet.network.clearUserCache import com.flowfoundation.wallet.network.model.AccountKey import com.flowfoundation.wallet.network.model.WalletListData import com.flowfoundation.wallet.network.model.FlowAccountInfo -import com.flowfoundation.wallet.network.model.LoginRequest import com.flowfoundation.wallet.network.model.LoginV4Request import com.flowfoundation.wallet.network.retrofit import com.flowfoundation.wallet.utils.ioScope @@ -30,7 +28,6 @@ import com.flow.wallet.storage.FileSystemStorage import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.utils.Env import com.flowfoundation.wallet.wallet.DERIVATION_PATH -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.io.File @@ -137,6 +134,14 @@ fun requestWalletRestoreLogin( logd(TAG, "HDWalletCryptoProvider created successfully with public key: ${publicKey.take(20)}...") + // Ensure we are in anonymous state before fetching the UID, mirroring iOS behaviour. + // signInWithCustomToken will atomically replace the anonymous user later. + if (!setToAnonymous()) { + loge(TAG, "setToAnonymous failed before wallet restore login") + callback.invoke(false, ERROR_UID) + return@ioScope + } + getFirebaseUid { uid -> if (uid.isNullOrBlank()) { callback.invoke(false, ERROR_UID) @@ -147,16 +152,17 @@ fun requestWalletRestoreLogin( val deviceInfoRequest = DeviceInfoManager.getDeviceInfoRequest() val service = retrofit().create(ApiService::class.java) - // Test signature creation before making the request - val testSignature = try { - val jwt = getFirebaseJwt() + // Fetch JWT once; reuse for both Flow and EVM signatures. + val jwt = getFirebaseJwt() + + val flowSignature = try { cryptoProvider.getUserSignature(jwt) } catch (e: Exception) { loge(TAG, "Failed to create test signature: ${e.message}") throw RuntimeException("Crypto provider signature creation failed: ${e.message}") } - if (testSignature.isBlank()) { + if (flowSignature.isBlank()) { throw RuntimeException("Crypto provider returned empty signature") } @@ -167,9 +173,10 @@ fun requestWalletRestoreLogin( hashAlgo = cryptoProvider.getHashAlgorithm().cadenceIndex, signAlgo = cryptoProvider.getSignatureAlgorithm().cadenceIndex ) + val evmAccountInfo = cryptoProvider.getEvmAccountInfo(jwt) val loginRequest = LoginV4Request( - flowAccountInfo = FlowAccountInfo(accountKey = accountKey, signature = testSignature), - evmAccountInfo = null, // Wallet restore doesn't have mnemonic for EVM + flowAccountInfo = FlowAccountInfo(accountKey = accountKey, signature = flowSignature), + evmAccountInfo = evmAccountInfo, deviceInfo = deviceInfoRequest ) val resp = service.loginV4(loginRequest) @@ -227,43 +234,21 @@ fun requestWalletRestoreLogin( suspend fun firebaseLogin(customToken: String, callback: (isSuccess: Boolean) -> Unit) { logd(TAG, "=== firebaseLogin START ===") logd(TAG, "Custom token received, length: ${customToken.length}") - - val isAnonymous = isAnonymousSignIn() - logd(TAG, "Current Firebase auth state - isAnonymous: $isAnonymous") logd(TAG, "Current user UID: ${Firebase.auth.currentUser?.uid}") - val isSuccess = if (isAnonymous) { - logd(TAG, "Attempting to delete anonymous user") - val deleteResult = deleteAnonymousUser() - logd(TAG, "Delete anonymous user result: $deleteResult") - deleteResult - } else { - logd(TAG, "Signing out existing Firebase user") - Firebase.auth.signOut() - logd(TAG, "Firebase sign out completed") - true - } - - if (isSuccess) { - logd(TAG, "Auth cleanup successful, waiting 1 second before custom login") - // Add a delay to ensure Firebase auth state is cleared - delay(1000) - logd(TAG, "Starting Firebase custom login with token") - firebaseCustomLogin(customToken) { isSuccessful, errorMsg -> - logd(TAG, "Firebase custom login completed - success: $isSuccessful, error: $errorMsg") - if (isSuccessful) { - logd(TAG, "Firebase login successful, identifying user profile with Mixpanel") - MixpanelManager.identifyUserProfile() - logd(TAG, "Calling success callback") - callback(true) - } else { - logd(TAG, "ERROR: Firebase custom login failed - $errorMsg") - callback(false) - } + // signInWithCustomToken atomically replaces the current user (anonymous or otherwise) + // without going through a null state. Prior delete/signOut calls created a null window + // that raced with background getFirebaseJwt() → signInAnonymously() calls. + firebaseCustomLogin(customToken) { isSuccessful, errorMsg -> + logd(TAG, "Firebase custom login completed - success: $isSuccessful, error: $errorMsg") + if (isSuccessful) { + logd(TAG, "Firebase login successful, new UID: ${Firebase.auth.currentUser?.uid}") + MixpanelManager.identifyUserProfile() + callback(true) + } else { + logd(TAG, "ERROR: Firebase custom login failed - $errorMsg") + callback(false) } - } else { - logd(TAG, "ERROR: Auth cleanup failed, calling failure callback") - callback(false) } } 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 cc375175b..0580d871c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt @@ -2,6 +2,11 @@ package com.flowfoundation.wallet.reactnative import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate @@ -86,9 +91,21 @@ class ReactNativeActivity : ReactActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Opt into edge-to-edge. On API 35+ this is enforced by the system; on older APIs + // we set it explicitly so the behavior is consistent across all versions. + WindowCompat.setDecorFitsSystemWindows(window, false) logd(TAG, "onCreate called") super.onCreate(savedInstanceState) + // ReactActivity doesn't extend BaseActivity, so we apply nav bar insets here directly. + // This pads android.R.id.content so the React Native root view doesn't go behind the nav bar. + val contentView = window.decorView.findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> + val navBar = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + contentView?.updatePadding(bottom = navBar.bottom) + insets + } + // Log the intent extras for debugging intent?.let { logd(TAG, "Intent extras:") @@ -106,6 +123,14 @@ class ReactNativeActivity : ReactActivity() { setIntent(intent) } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // Re-trigger insets dispatch once the window is visible and has valid insets. + if (hasFocus) { + ViewCompat.requestApplyInsets(window.decorView) + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) @@ -141,6 +166,8 @@ class ReactNativeActivity : ReactActivity() { RNBridge.ScreenType.ONBOARDING -> RNBridge.InitialRoute.GET_STARTED.routeName RNBridge.ScreenType.RECEIVE -> "Receive" RNBridge.ScreenType.ACTIVITY -> RNBridge.InitialRoute.HOME.routeName + RNBridge.ScreenType.CLAIMTOKENS -> "ClaimTokens" + else -> RNBridge.InitialRoute.HOME.routeName } } @@ -281,6 +308,10 @@ class ReactNativeActivity : ReactActivity() { * @param fromAccount can be null - if null, will generate from selected wallet address * @param targetAddress can be null - if null, user will select target in RN */ + fun launchClaimTokens(context: Context) { + launch(context, RNBridge.ScreenType.CLAIMTOKENS) + } + fun launchWalletSend(context: Context, fromAccount: RNBridge.WalletAccount?, targetAddress: String?) { val address = WalletManager.selectedWalletAddress().toAddress() val network = chainNetWorkString() 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 01961b944..96de0d79d 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 @@ -24,7 +24,10 @@ class RNBridge { @SerializedName("onboarding") ONBOARDING, @SerializedName("receive") RECEIVE, @SerializedName("activity") ACTIVITY, - @SerializedName("keystore-migration") KEYSTORE_MIGRATION + @SerializedName("keystore-migration") KEYSTORE_MIGRATION, + @SerializedName("migration") MIGRATION, + @SerializedName("whatsNew") WHATSNEW, + @SerializedName("claimTokens") CLAIMTOKENS } enum class AccountTypeType { @@ -169,7 +172,9 @@ class RNBridge { @SerializedName("INSTABUG_TOKEN") val INSTABUG_TOKEN: String, @SerializedName("MIXPANEL_TOKEN") - val MIXPANEL_TOKEN: String? + val MIXPANEL_TOKEN: String?, + @SerializedName("CADENCE_INBOX") + val CADENCE_INBOX: Boolean ) data class Currency( 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 092e6fa6f..9d460a088 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 @@ -22,6 +22,7 @@ import org.json.JSONObject import org.json.JSONArray import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.firstFlowWalletAddress import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.logw @@ -51,6 +52,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 @@ -85,8 +87,6 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp } init { - logd(TAG, "NativeFRWBridge initialized with context: ${reactContext != null}") - logd(TAG, "React context is active: ${reactContext.hasActiveCatalystInstance()}") ActivityManager.setReactContext(reactContext) System.loadLibrary("TrustWalletCore") } @@ -160,6 +160,16 @@ 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) + val address = AccountManager.get()?.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } + logd(TAG, "saveNewKey() - Seed phrase saved successfully") uiScope { promise.resolve(null) @@ -202,6 +212,11 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // AccountManager.add(account) will update the list and cache it AccountManager.add(account) + // Delete any pkStorage entry that KeyStorageMigration (Case 2) may have created + if (!uid.isNullOrBlank()) { + KeyStorageManager.deletePrivateKey(uid) + } + // 3. Clear CryptoProvider CryptoProviderManager.clear() @@ -368,9 +383,30 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // 4. Update local account state with new prefix logd(TAG, "keystoreMigration() - updating local account state") + val uid = firebaseUid() ?: account.wallet?.id + val oldPrefix = account.prefix account.prefix = newPrefix AccountManager.add(account) + // Sync new prefix to independent AKP storage + if (!uid.isNullOrBlank()) { + KeyStorageManager.saveAndroidKeystorePrefix(uid, newPrefix) + val address = account.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } + + // Remove the stale file-private-key entry that KeyCompatibilityManager may find + if (!oldPrefix.isNullOrBlank()) { + try { + getStorage().remove("prefix_key_$oldPrefix") + logd(TAG, "Removed old prefix key from shared storage: prefix_key_$oldPrefix") + } catch (e: Exception) { + loge(TAG, "Failed to remove old prefix key: ${e.message}") + } + } + // 5. Reload CryptoProvider logd(TAG, "keystoreMigration() - reloading crypto provider") CryptoProviderManager.clear() @@ -395,7 +431,7 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp } } - override fun ethSign(hexData: String?, promise: Promise?) = walletHandler.ethSign(hexData, promise) + override fun ethSign(hexData: String?, address: String?, promise: Promise?) = walletHandler.ethSign(hexData, address, promise) override fun listenTransaction(txid: String) = walletHandler.listenTransaction(txid) @@ -407,6 +443,14 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp override fun closeRN(id: String?) = uiHandler.closeRN(id) + override fun onUpdateDialogActionPress( + actionType: String?, + actionUrl: String?, + actionText: String? + ) {} + + override fun closeRNWithNFT(id: String?) {} + override fun getSignKeyIndex(): Double = accountHandler.getSignKeyIndex() override fun isFreeGasEnabled(promise: Promise) = utilsHandler.isFreeGasEnabled(promise) @@ -415,6 +459,13 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp override fun getSelectedAccount(promise: Promise) = accountHandler.getSelectedAccount(promise, ::bridgeModelToWritableMap) + override fun getMigrationAssets( + sourceAddress: String?, + promise: Promise? + ) {} + + override fun refreshCoaAfterMigration(promise: Promise?) {} + override fun getCurrency(): WritableMap = utilsHandler.getCurrency(::bridgeModelToWritableMap) override fun getTokenRate(token: String): String = utilsHandler.getTokenRate(token) @@ -439,8 +490,7 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp try { val jsonObject = JSONObject(jsonString) jsonObject.keys().forEach { key -> - val value = jsonObject.get(key) - when (value) { + when (val value = jsonObject.get(key)) { is String -> map.putString(key, value) is Boolean -> map.putBoolean(key, value) is Int -> map.putInt(key, value) @@ -468,8 +518,7 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp val array = WritableNativeArray() try { for (i in 0 until jsonArray.length()) { - val value = jsonArray.get(i) - when (value) { + when (val value = jsonArray.get(i)) { is String -> array.pushString(value) is Boolean -> array.pushBoolean(value) is Int -> array.pushInt(value) diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt index cba853f59..6aa80af04 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt @@ -7,14 +7,13 @@ import com.flowfoundation.wallet.cache.recentTransactionCache import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.evm.EVMWalletManager -import com.flowfoundation.wallet.manager.evm.EVMWalletManager.isValidEVMAddress -import com.flowfoundation.wallet.manager.evm.EVMWalletManager.toChecksumEVMAddress import com.flowfoundation.wallet.manager.flowjvm.currentKeyId import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.walletdata.FlowWallet import com.flowfoundation.wallet.manager.walletdata.COAWallet import com.flowfoundation.wallet.manager.walletdata.ChildWallet +import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.reactnative.bridge.RNBridge import com.flowfoundation.wallet.reactnative.bridge.createEmojiInfo import com.flowfoundation.wallet.reactnative.bridge.isSelectedWalletAddress @@ -100,33 +99,24 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { ioScope { try { val bridgeAccounts = mutableListOf() - - // Get main wallet address - for hardware-backed keys, wallet() returns null, - // so we need to use selectedWalletAddress() as fallback - var mainAddress = WalletManager.getCurrentFlowWalletAddress() - if (mainAddress.isNullOrEmpty()) { - // Hardware-backed key fallback: use the selected address - mainAddress = WalletManager.selectedWalletAddress() + val currentAccount = AccountManager.get() ?: run { + val emptyResponse = RNBridge.WalletAccountsResponse(accounts = emptyList()) + uiScope { promise.resolve(bridgeModelToWritableMap(emptyResponse)) } + return@ioScope } - val mainEmojiInfo = createEmojiInfo(mainAddress) - if (mainAddress.isNotEmpty()) { - // For secure enclave COA accounts, check if main address has EVM capabilities - // If it does, set type to EVM instead of MAIN - // Check both evmAddressMap and getEVMAddress() since evmAddressMap might not be populated yet - val mainAccountType = try { - val isEVM = EVMWalletManager.isEVMWalletAddress(mainAddress) || - mainAddress.equals(EVMWalletManager.getEVMAddress(), ignoreCase = true) || - isValidEVMAddress(mainAddress) - if (isEVM) { - RNBridge.AccountType.EVM - } else { - RNBridge.AccountType.MAIN - } - } catch (e: Exception) { - RNBridge.AccountType.MAIN - } - val mainAccount = RNBridge.WalletAccount( + val currentNetwork = com.flowfoundation.wallet.manager.app.chainNetWorkString() + + // 1. Process FlowWallets (main account + linked wallets) + val flowWallets = currentAccount.walletNodes.filterIsInstance() + .filter { it.chainIdString == currentNetwork } + + flowWallets.forEach { flowWallet -> + val mainAddress = flowWallet.address + val mainEmojiInfo = createEmojiInfo(mainAddress) + + // Main account + bridgeAccounts.add(RNBridge.WalletAccount( id = "main", name = mainEmojiInfo?.name ?: "Main Account", address = mainAddress, @@ -135,132 +125,75 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { parentAddress = null, avatar = null, isActive = isSelectedWalletAddress(mainAddress), - type = mainAccountType, + type = RNBridge.AccountType.MAIN, balance = null, nfts = null, - ) - bridgeAccounts.add(mainAccount) - } - - // Get child accounts - try { - val childAccounts = WalletManager.childAccountList(mainAddress) - childAccounts.forEach { childAccount -> - val childAccountBridge = RNBridge.WalletAccount( - id = "child_${childAccount.address}", - name = childAccount.name, - address = childAccount.address, - emojiInfo = null, - parentEmoji = mainEmojiInfo, - parentAddress = mainAddress, - avatar = childAccount.icon, - isActive = isSelectedWalletAddress(childAccount.address), - type = RNBridge.AccountType.CHILD, - balance = null, - nfts = null, - ) - bridgeAccounts.add(childAccountBridge) - } - } catch (e: Exception) { - // Child accounts might not be available, continue without them - println("Child accounts not available: ${e.message}") - } - - // Get EVM address if available - // For Secure Type (hardware-backed keys), skip adding separate EVM account entry - // because the COA child account already represents the EVM account - var evmAddress: String? = null - val currentAccount = AccountManager.get() - val isSecureType = !currentAccount?.prefix.isNullOrBlank() - - try { - evmAddress = EVMWalletManager.getEVMAddress() - if (!evmAddress.isNullOrEmpty()) { - // Check if EVM address matches any child account address - // If it does, don't add a separate EVM account entry (it's already represented as a child account) - val childAccounts = WalletManager.childAccountList(mainAddress) - val evmMatchesChildAccount = childAccounts.any { - it.address.equals(evmAddress, ignoreCase = true) - } - - // Only add EVM account entry if: - // 1. Not Secure Type (Recovery Phrase flow), OR - // 2. EVM address doesn't match any child account (shouldn't happen, but safety check) - if (!isSecureType || !evmMatchesChildAccount) { - val evmEmojiInfo = createEmojiInfo(evmAddress) - - val evmAccount = RNBridge.WalletAccount( - id = "evm", - name = evmEmojiInfo?.name ?: "EVM Account", - address = evmAddress, - parentAddress = mainAddress, - emojiInfo = evmEmojiInfo, - parentEmoji = mainEmojiInfo, - avatar = null, - isActive = isSelectedWalletAddress(evmAddress), - type = RNBridge.AccountType.EVM, - balance = null, - nfts = null, - ) - bridgeAccounts.add(evmAccount) + )) + + // Linked wallets (COA/EVM + Child) + flowWallet.linkedWallets.forEach { linkedWallet -> + when (linkedWallet) { + is COAWallet -> { + val evmEmojiInfo = createEmojiInfo(linkedWallet.address) + bridgeAccounts.add(RNBridge.WalletAccount( + id = "evm", + name = evmEmojiInfo?.name ?: linkedWallet.name, + address = linkedWallet.address, + parentAddress = mainAddress, + emojiInfo = evmEmojiInfo, + parentEmoji = mainEmojiInfo, + avatar = null, + isActive = isSelectedWalletAddress(linkedWallet.address), + type = RNBridge.AccountType.EVM, + balance = null, + nfts = null, + )) + } + is ChildWallet -> { + bridgeAccounts.add(RNBridge.WalletAccount( + id = "child_${linkedWallet.address}", + name = linkedWallet.name, + address = linkedWallet.address, + emojiInfo = null, + parentEmoji = mainEmojiInfo, + parentAddress = mainAddress, + avatar = linkedWallet.icon, + isActive = isSelectedWalletAddress(linkedWallet.address), + type = RNBridge.AccountType.CHILD, + balance = null, + nfts = null, + )) + } } } - } catch (e: Exception) { - // EVM account might not be available, continue without it - println("EVM account not available: ${e.message}") } - // Get EOA address only if it's different from EVM address - // For Secure Type (COA) accounts, EOA and EVM addresses are the same, - // so we should only show the EVM account to avoid duplicate "EOA" chip - try { - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { - // Only add EOA account if it's different from EVM address - // Secure Type accounts have a prefix field, Recovery Phrase accounts don't - val isSecureTypeAccount = !currentAccount?.prefix.isNullOrBlank() - - // Only add EOA if: - // 1. EOA address is different from EVM address, AND - // 2. Account is NOT Secure Type (Recovery Phrase account) - val isDifferentFromEVM = evmAddress == null || - !eoaAddress.equals(evmAddress, ignoreCase = true) - - if (isDifferentFromEVM && !isSecureTypeAccount) { - val eoaEmojiInfo = createEmojiInfo(eoaAddress) - val eoaAccount = RNBridge.WalletAccount( - id = "eoa", - name = eoaEmojiInfo?.name ?: "EOA Account", - address = eoaAddress, - parentAddress = mainAddress, - emojiInfo = eoaEmojiInfo, - parentEmoji = mainEmojiInfo, - avatar = null, - isActive = isSelectedWalletAddress(eoaAddress), - type = RNBridge.AccountType.EOA, - balance = null, - nfts = null, - ) - bridgeAccounts.add(eoaAccount) - } - } - } catch (e: Exception) { - // EOA account might not be available, continue without it - println("EOA account not available: ${e.message}") + // 2. Process EOAWallets (already correctly populated by WalletDataManager based on canDeriveEoa) + currentAccount.walletNodes.filterIsInstance().forEach { eoaWallet -> + val eoaAddress = eoaWallet.address + if (eoaAddress.isEmpty()) return@forEach + + val eoaEmojiInfo = createEmojiInfo(eoaAddress) + bridgeAccounts.add(RNBridge.WalletAccount( + id = "eoa_${eoaAddress}", + name = eoaEmojiInfo?.name ?: "EOA Account", + address = eoaAddress, + parentAddress = null, + emojiInfo = eoaEmojiInfo, + parentEmoji = null, + avatar = null, + isActive = isSelectedWalletAddress(eoaAddress), + type = RNBridge.AccountType.EOA, + balance = null, + nfts = null, + )) } val response = RNBridge.WalletAccountsResponse(accounts = bridgeAccounts) - val result = bridgeModelToWritableMap(response) - - uiScope { - promise.resolve(result) - } + uiScope { promise.resolve(bridgeModelToWritableMap(response)) } } catch (e: Exception) { val emptyResponse = RNBridge.WalletAccountsResponse(accounts = emptyList()) - val result = bridgeModelToWritableMap(emptyResponse) - uiScope { - promise.resolve(result) - } + uiScope { promise.resolve(bridgeModelToWritableMap(emptyResponse)) } } } } @@ -475,31 +408,33 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { } } - // Add EOA address only for currently selected wallet - val mainAddress = flowWallets.firstOrNull()?.address - if (mainAddress != null && isSelectedWalletAddress(mainAddress)) { - try { - val eoaAddress = WalletManager.getEOAAddress() - if (!eoaAddress.isNullOrEmpty()) { - val eoaEmojiInfo = createEmojiInfo(eoaAddress) - val eoaAccount = RNBridge.WalletAccount( - id = "eoa", - name = eoaEmojiInfo?.name ?: "EVM Account (EOA)", - address = eoaAddress, - parentAddress = mainAddress, - emojiInfo = eoaEmojiInfo, - parentEmoji = null, - avatar = null, - isActive = isSelectedWalletAddress(eoaAddress), - type = RNBridge.AccountType.EOA, - balance = null, - nfts = null, - ) - bridgeAccounts.add(eoaAccount) - } - } catch (e: Exception) { - logw(TAG, "createWalletProfileFromAccount() - EOA account not available: ${e.message}") + // Add EOA addresses from account's walletNodes + // EOAWallet is only present when canDeriveEoa=true (populated by WalletDataManager) + try { + val eoaWallets = account.walletNodes.filterIsInstance() + + for (eoaWallet in eoaWallets) { + val eoaAddress = eoaWallet.address + if (eoaAddress.isEmpty()) continue + + val eoaEmojiInfo = createEmojiInfo(eoaAddress) + val eoaAccount = RNBridge.WalletAccount( + id = "eoa_${eoaAddress}", + name = eoaEmojiInfo?.name ?: "EVM Account (EOA)", + address = eoaAddress, + parentAddress = null, + emojiInfo = eoaEmojiInfo, + parentEmoji = null, + avatar = null, + isActive = isSelectedWalletAddress(eoaAddress), + type = RNBridge.AccountType.EOA, + balance = null, + nfts = null, + ) + bridgeAccounts.add(eoaAccount) } + } catch (e: Exception) { + logw(TAG, "createWalletProfileFromAccount() - EOA accounts not available: ${e.message}") } // Create wallet profile @@ -533,11 +468,12 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { switchList.filterIsInstance().forEach { localAccount -> logd(TAG, "getRecoverableProfiles() - processing LocalSwitchAccount: ${localAccount.username}") - val mainEmojiInfo = createEmojiInfo(localAccount.address) + val displayAddress = localAccount.address.ifBlank { null } + val mainEmojiInfo = if (displayAddress != null) createEmojiInfo(displayAddress) else null val mainAccount = RNBridge.WalletAccount( - id = "main_${localAccount.address}", + id = "main_${displayAddress ?: localAccount.userId ?: localAccount.username}", name = mainEmojiInfo?.name ?: localAccount.username, - address = localAccount.address, + address = displayAddress ?: "", emojiInfo = mainEmojiInfo, parentEmoji = null, parentAddress = null, @@ -581,26 +517,35 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { logd(TAG, "switchToProfile() called with userId: $userId") ioScope { try { - // Find the account with the matching userId (wallet id) - val accounts = AccountManager.list() - val targetAccount = accounts.find { it.wallet?.id == userId } - - if (targetAccount == null) { - logw(TAG, "switchToProfile() - account not found for userId: $userId") - uiScope { - promise.reject("PROFILE_NOT_FOUND", "Account not found for userId: $userId") + // 1. Check normal logged-in accounts first + val targetAccount = AccountManager.list().find { it.wallet?.id == userId } + if (targetAccount != null) { + logd(TAG, "switchToProfile() - found normal account: ${targetAccount.userInfo.username}") + AccountManager.switch(targetAccount) { + logd(TAG, "switchToProfile() - switch completed for userId: $userId") + uiScope { promise.resolve(null) } } return@ioScope } - logd(TAG, "switchToProfile() - found account: ${targetAccount.userInfo.username}") - - // Switch to the account - AccountManager.switch(targetAccount) { - logd(TAG, "switchToProfile() - switch completed for userId: $userId") - uiScope { - promise.resolve(null) // Success - resolve with no value + // 2. Not a normal account — check LocalSwitchAccount list (orphan keys) + // These are accounts with key material but no account cache entry. + val localAccount = AccountManager.getSwitchAccountList() + .filterIsInstance() + .find { it.userId == userId } + + if (localAccount != null) { + logd(TAG, "switchToProfile() - found LocalSwitchAccount for userId: $userId") + AccountManager.switch(localAccount) { + logd(TAG, "switchToProfile() - LocalSwitchAccount switch completed for userId: $userId") + uiScope { promise.resolve(null) } } + return@ioScope + } + + logw(TAG, "switchToProfile() - account not found for userId: $userId") + uiScope { + promise.reject("PROFILE_NOT_FOUND", "Account not found for userId: $userId") } } catch (e: Exception) { loge(TAG, "switchToProfile() - error: ${e.message}") 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 5f025e7a0..1ef11d806 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 @@ -17,6 +17,7 @@ import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +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 @@ -354,53 +355,35 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { try { com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> if (isSuccessful) { - logd(TAG, "signInWithCustomToken() - Custom token authentication successful, waiting for JWT...") - // Wait for JWT to be available after sign-in - // This prevents race conditions where API calls happen before token propagates + // firebaseCustomLogin already calls getIdToken(true) internally before invoking this callback, + // so the Firebase ID token is already refreshed at this point. + // Calling getFirebaseJwt(forceRefresh=true) repeatedly here would trigger App Check rate limiting. + logd(TAG, "signInWithCustomToken() - Custom token authentication successful, validating with backend...") ioScope { - var tokenReady = false var backendValidated = false var attempts = 0 - val maxAttempts = 15 + val maxAttempts = 5 - while (!tokenReady && attempts < maxAttempts) { + while (!backendValidated && attempts < maxAttempts) { attempts++ try { - val jwt = getFirebaseJwt(forceRefresh = true) - val firebaseUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - - 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 - try { - val service = com.flowfoundation.wallet.network.retrofit() - .create(com.flowfoundation.wallet.network.ApiService::class.java) - val userInfo = service.userInfo().data - logd(TAG, "signInWithCustomToken() - Backend validated, username: ${userInfo.username}") - tokenReady = true - backendValidated = true - } catch (apiError: Exception) { - logd(TAG, "signInWithCustomToken() - Backend validation failed on attempt $attempts: ${apiError.message}") - // Backend might not be ready yet, continue waiting - kotlinx.coroutines.delay(500) - } - } else { - logd(TAG, "signInWithCustomToken() - JWT not ready, attempt $attempts/$maxAttempts") - kotlinx.coroutines.delay(300) - } - } catch (e: Exception) { - logd(TAG, "signInWithCustomToken() - JWT check error on attempt $attempts: ${e.message}") - kotlinx.coroutines.delay(300) + val service = com.flowfoundation.wallet.network.retrofit() + .create(com.flowfoundation.wallet.network.ApiService::class.java) + val userInfo = service.userInfo().data + logd(TAG, "signInWithCustomToken() - Backend validated on attempt $attempts, username: ${userInfo.username}") + backendValidated = true + } catch (apiError: Exception) { + logd(TAG, "signInWithCustomToken() - Backend validation failed on attempt $attempts: ${apiError.message}") + kotlinx.coroutines.delay(1000) } } - if (tokenReady && backendValidated) { + if (backendValidated) { uiScope { promise.resolve(null) } } else { - loge(TAG, "signInWithCustomToken() - Auth not ready after $maxAttempts attempts (tokenReady=$tokenReady, backendValidated=$backendValidated)") + loge(TAG, "signInWithCustomToken() - Backend validation failed after $maxAttempts attempts") uiScope { promise.reject("CUSTOM_TOKEN_AUTH_ERROR", "Authentication succeeded but backend validation failed", null) } @@ -446,57 +429,15 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { onSuccess = { ioScope { try { - // Force Firebase ID token refresh to get the new account's JWT - // This ensures API requests use the new account's credentials - logd(TAG, "saveMnemonic() - Forcing Firebase ID token refresh...") - var tokenRefreshed = false - var refreshAttempts = 0 - val maxRefreshAttempts = 10 - - while (!tokenRefreshed && refreshAttempts < maxRefreshAttempts) { - kotlinx.coroutines.delay(500) // Wait 500ms between checks - refreshAttempts++ - try { - // Force refresh the token - val jwt = getFirebaseJwt(forceRefresh = true) - val currentUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - - if (jwt.isNotBlank() && currentUid != null) { - tokenRefreshed = true - logd(TAG, "saveMnemonic() - Firebase ID token refreshed after $refreshAttempts attempt(s), UID: $currentUid") - - // Verify the username matches by making a test API call - try { - val testService = com.flowfoundation.wallet.network.retrofit() - .create(com.flowfoundation.wallet.network.ApiService::class.java) - val testUserInfo = testService.userInfo().data - logd(TAG, "saveMnemonic() - Token validated, backend returned username: ${testUserInfo.username}") - - // Check if username matches (case-insensitive, ignoring numeric suffix) - // Backend normalizes to lowercase and adds suffix: "FancyRiverVolcano" -> "fancyrivervolcano_476" - val backendUsernameBase = testUserInfo.username.substringBefore("_").lowercase() - val expectedUsernameBase = username.lowercase() - - if (backendUsernameBase != expectedUsernameBase) { - logw(TAG, "saveMnemonic() - Username mismatch! Expected: $expectedUsernameBase, Got: $backendUsernameBase. Retrying...") - tokenRefreshed = false // Retry - } else { - logd(TAG, "saveMnemonic() - Username validated: $expectedUsernameBase matches $backendUsernameBase") - } - } catch (e: Exception) { - logw(TAG, "saveMnemonic() - Could not validate token with backend, continuing: ${e.message}") - } - } else { - logd(TAG, "saveMnemonic() - Waiting for token refresh (attempt $refreshAttempts/$maxRefreshAttempts)") - } - } catch (e: Exception) { - logd(TAG, "saveMnemonic() - Error during token refresh (attempt $refreshAttempts): ${e.message}") - } - } - - if (!tokenRefreshed) { - logw(TAG, "saveMnemonic() - Warning: Token may not be for correct user, proceeding anyway") + // authenticateWithFirebase -> firebaseCustomLogin already calls getIdToken(true) before + // invoking this callback, so the token is already refreshed. Calling + // getFirebaseJwt(forceRefresh=true) in a loop here would trigger App Check rate limiting. + val currentFirebaseUser = Firebase.auth.currentUser + val currentUid = currentFirebaseUser?.uid + if (currentFirebaseUser == null || currentFirebaseUser.isAnonymous) { + throw IllegalStateException("Firebase auth failed: user is still anonymous after custom token login") } + logd(TAG, "saveMnemonic() - Firebase authenticated, UID: $currentUid") // Fetch user info from backend val service = com.flowfoundation.wallet.network.retrofit() @@ -550,6 +491,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 @@ -674,45 +617,45 @@ private fun authenticateWithFirebase( ) { logd(TAG, "authenticateWithFirebase() - Checking current Firebase auth state...") + // getV4RegistrationSignatures() ensures anonymous sign-in before saveMnemonic() is called, + // so currentUser is always non-null here. Mirror the registerFirebase() pattern in UserRegisterUtils. val currentUser = Firebase.auth.currentUser - val currentUid = currentUser?.uid - val isAnonymous = currentUser?.isAnonymous ?: true - - if (currentUser != null) { - logd(TAG, "authenticateWithFirebase() - Current user: UID=$currentUid, isAnonymous=$isAnonymous") + logd(TAG, "authenticateWithFirebase() - Current user: UID=${currentUser?.uid}, isAnonymous=${currentUser?.isAnonymous}") + + fun doLogin() { + logd(TAG, "authenticateWithFirebase() - Signing in with new custom token...") + com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> + if (isSuccessful) { + logd(TAG, "authenticateWithFirebase() - Firebase authentication successful, new UID: ${Firebase.auth.currentUser?.uid}") + onSuccess() + } else { + val errorMessage = exception?.message ?: "Firebase authentication failed" + loge(TAG, "authenticateWithFirebase() - Failed: $errorMessage") + onFailure(errorMessage) + } + } } - // If already authenticated with a non-anonymous user, we MUST sign out first - // to switch to the new account. Firebase won't switch users without signing out. - if (currentUser != null && !isAnonymous) { - logd(TAG, "authenticateWithFirebase() - Signing out current user to switch accounts...") - - // Sign out the current user - Firebase.auth.signOut() - logd(TAG, "authenticateWithFirebase() - User signed out successfully") - - // Delete Firebase messaging token for the old user + if (currentUser == null || !currentUser.isAnonymous) { + // No user, or non-anonymous user: signOut() is synchronous, safe to call directly before login + if (currentUser != null) { + logd(TAG, "authenticateWithFirebase() - Signing out non-anonymous user before login...") + Firebase.auth.signOut() + } com.google.firebase.messaging.FirebaseMessaging.getInstance().deleteToken() - } else if (isAnonymous) { - // Delete anonymous user + doLogin() + } else { + // Anonymous user: await delete() before signing in - same pattern as registerFirebase() + // delete() is async; calling signInWithCustomToken without waiting would race with it + logd(TAG, "authenticateWithFirebase() - Deleting anonymous user before login...") com.google.firebase.messaging.FirebaseMessaging.getInstance().deleteToken() - currentUser?.delete()?.addOnCompleteListener { - logd(TAG, "authenticateWithFirebase() - Previous anonymous user deleted") - } - } - - logd(TAG, "authenticateWithFirebase() - Signing in with new custom token...") - - // Sign in with the new account's custom token - com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> - if (isSuccessful) { - val newUid = Firebase.auth.currentUser?.uid - logd(TAG, "authenticateWithFirebase() - Firebase authentication successful, new UID: $newUid") - onSuccess() - } else { - val errorMessage = exception?.message ?: "Firebase authentication failed" - loge(TAG, "authenticateWithFirebase() - Failed: $errorMessage") - onFailure(errorMessage) + currentUser.delete().addOnCompleteListener { task -> + logd(TAG, "authenticateWithFirebase() - Anonymous user delete finished, exception: ${task.exception}") + if (task.isSuccessful) { + doLogin() + } else { + onFailure("Failed to delete anonymous user: ${task.exception?.message}") + } } } } 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 059c93622..a9e9ce427 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 @@ -23,6 +23,7 @@ import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.logw import com.flowfoundation.wallet.utils.uiScope import com.flowfoundation.wallet.manager.account.DeviceInfoManager +import com.flowfoundation.wallet.manager.config.AppConfig import java.util.Locale /** @@ -93,7 +94,8 @@ class UtilsBridgeHandler(private val reactContext: ReactApplicationContext) { BuildConfig.MIXPANEL_TOKEN_DEV } else { BuildConfig.MIXPANEL_TOKEN_PROD - } + }, + CADENCE_INBOX = AppConfig.canClaimInbox() ) return bridgeModelToWritableMap(environmentVariables) @@ -210,7 +212,7 @@ class UtilsBridgeHandler(private val reactContext: ReactApplicationContext) { args.getString(i) ?: "" } - // Delegate to the centralized Instabug logging system in Log.kt + // Delegate to the centralized Luciq logging system in Log.kt logToInstabug(level, message, *stringArgs) } catch (e: Exception) { // Fallback with just the message if args conversion fails diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt index 14ee004e8..e2b2a89a9 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/WalletBridgeHandler.kt @@ -45,11 +45,12 @@ class WalletBridgeHandler(private val reactContext: ReactApplicationContext) { } } - fun ethSign(hexData: String?, promise: Promise?) { + fun ethSign(hexData: String?, address: String?, promise: Promise?) { ioScope { try { - logd(TAG, "ethSign() called with hexData: $hexData") - val signature = WalletManager.wallet()?.ethSignDigest(hexData?.hexToBytes() ?: throw IllegalArgumentException("hexData is null")) + logd(TAG, "ethSign() called with hexData: $hexData, address: $address") + val eoaIndex = WalletManager.getEOAIndexForAddress(address ?: "") + val signature = WalletManager.wallet()?.ethSignDigest(hexData?.hexToBytes() ?: throw IllegalArgumentException("hexData is null"), index = eoaIndex) if (signature != null && signature.isNotEmpty()) { val result = Numeric.toHexString(signature) logd(TAG, "ethSign() - signature $result") diff --git a/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt b/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt index 664ef1dbb..9c6ab6cf5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt +++ b/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt @@ -8,7 +8,7 @@ import com.flowfoundation.wallet.firebase.messaging.uploadPushToken import com.flowfoundation.wallet.utils.ioScope import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.updatePushToken -import com.instabug.chat.Replies +import ai.luciq.chat.Replies class MessagingService : FirebaseMessagingService() { @@ -29,7 +29,7 @@ class MessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { logd(TAG, "receive new firebase message:$message") parseFirebaseMessaging(message) - if (Replies.isInstabugNotification(message.getData())) { + if (Replies.isLuciqNotification(message.getData())) { Replies.showNotification(message.getData()) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt b/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt index ff09101fd..bba3038c4 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt @@ -5,7 +5,7 @@ import com.flowfoundation.wallet.BuildConfig import com.flowfoundation.wallet.firebase.analytics.reportErrorToDebugView import com.flowfoundation.wallet.firebase.analytics.reportException import com.flowfoundation.wallet.utils.debug.fragments.debugViewer.DebugViewerDataSource -import com.instabug.library.logging.InstabugLog +import ai.luciq.library.logging.LuciqLog import com.nftco.flow.sdk.FlowException import retrofit2.HttpException @@ -13,19 +13,19 @@ private const val MAX_LOG_LENGTH = 3500 private const val LOG_PREFIX = "[" private const val LOG_SUFFIX = "]" -fun logv(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.VERBOSE, InstabugLog::v) -fun logd(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.DEBUG, InstabugLog::d) -fun logi(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.INFO, InstabugLog::i) -fun logw(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.WARN, InstabugLog::w) +fun logv(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.VERBOSE, LuciqLog::v) +fun logd(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.DEBUG, LuciqLog::d) +fun logi(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.INFO, LuciqLog::i) +fun logw(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.WARN, LuciqLog::w) fun loge(tag: String?, msg: Any?) { - logWithLevel(tag, msg, Log.ERROR, InstabugLog::e) + logWithLevel(tag, msg, Log.ERROR, LuciqLog::e) reportErrorToDebugView(tag, mapOf("errorInfo" to (msg?.toString() ?: ""))) } fun loge(throwable: Throwable?, printStackTrace: Boolean = true, report: Boolean = true) { val message = throwable?.message ?: "" log("Exception", message, Log.ERROR) - InstabugLog.e("Exception: $message : ${throwable?.cause ?: ""}") + LuciqLog.e("Exception: $message : ${throwable?.cause ?: ""}") if (printLog() && printStackTrace) { throwable?.printStackTrace() @@ -102,7 +102,7 @@ private fun printLog() = BuildConfig.DEBUG || isDev() /** * Native logging method for React Native bridge callback - * Directly reports to Instabug for reliable logging + * Directly reports to Luciq for reliable logging */ fun logToInstabug(level: String, message: String, vararg args: String) { try { @@ -119,23 +119,23 @@ fun logToInstabug(level: String, message: String, vararg args: String) { // Convert string level to Android Log level constant for DebugViewerDataSource val logLevel = when (level.lowercase()) { "debug" -> { - InstabugLog.d(taggedMessage) + LuciqLog.d(taggedMessage) Log.DEBUG } "info" -> { - InstabugLog.i(taggedMessage) + LuciqLog.i(taggedMessage) Log.INFO } "warn" -> { - InstabugLog.w(taggedMessage) + LuciqLog.w(taggedMessage) Log.WARN } "error" -> { - InstabugLog.e(taggedMessage) + LuciqLog.e(taggedMessage) Log.ERROR } else -> { - InstabugLog.i(taggedMessage) + LuciqLog.i(taggedMessage) Log.INFO } } @@ -143,7 +143,7 @@ fun logToInstabug(level: String, message: String, vararg args: String) { // Also log to DebugViewer with correct level type DebugViewerDataSource.log(logLevel, "FRW-Native", taggedMessage) } catch (e: Exception) { - // Fallback to direct Instabug error report to avoid recursion - InstabugLog.e("[FRW-Native] Error in logToNative: ${e.message}") + // Fallback to direct Luciq error report to avoid recursion + LuciqLog.e("[FRW-Native] Error in logToNative: ${e.message}") } } diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt b/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt index 528a94865..0ea831e19 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt @@ -37,6 +37,7 @@ private val KEY_HIDE_WALLET_BALANCE = booleanPreferencesKey("KEY_HIDE_WALLET_BAL private val KEY_FREE_GAS_ENABLE = booleanPreferencesKey("KEY_FREE_GAS_ENABLE") private val KEY_WRAP_EOA_TX_WITH_CADENCE = booleanPreferencesKey("KEY_WRAP_EOA_TX_WITH_CADENCE") +private val KEY_HIDE_COA_WITH_ZERO_BALANCE = booleanPreferencesKey("KEY_HIDE_COA_WITH_ZERO_BALANCE") private const val KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED = "KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED" private val KEY_IS_MEOW_DOMAIN_CLAIMED = booleanPreferencesKey("KEY_IS_MEOW_DOMAIN_CLAIMED") @@ -163,7 +164,12 @@ suspend fun isWrapEOATxWithCadenceEnable(): Boolean = dataStore.data.map { it[KE fun setWrapEOATxWithCadenceEnable(isWrap: Boolean) { edit { dataStore.edit { it[KEY_WRAP_EOA_TX_WITH_CADENCE] = isWrap } } } +suspend fun isHideCOAWithZeroBalanceEnable(): Boolean = + dataStore.data.map { it[KEY_HIDE_COA_WITH_ZERO_BALANCE] ?: true }.first() +fun setHideCOAWithZeroBalanceEnable(isHide: Boolean) { + edit { dataStore.edit { it[KEY_HIDE_COA_WITH_ZERO_BALANCE] = isHide } } +} fun isStakingGuideDisplayed(): Boolean { return sharedPreferencesTraditional.getBoolean(KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED, false) diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt b/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt index d3ff766b5..66cd8bec6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt @@ -5,23 +5,23 @@ import com.flowfoundation.wallet.manager.transaction.TransactionStateManager import com.flowfoundation.wallet.mixpanel.MixpanelManager import com.flowfoundation.wallet.utils.getCurrentCodeLocation import com.flowfoundation.wallet.utils.getLocationInfo -import com.instabug.crash.CrashReporting -import com.instabug.crash.models.IBGNonFatalException -import com.instabug.library.Instabug +import ai.luciq.crash.CrashReporting +import ai.luciq.crash.models.LuciqNonFatalException +import ai.luciq.library.Luciq import org.onflow.flow.infrastructure.CadenceErrorCode object ErrorReporter { private fun report(error: BaseError, cause: Throwable? = null) { - IBGNonFatalException.Builder(CustomException(error, cause)) - .setLevel(IBGNonFatalException.Level.ERROR) + LuciqNonFatalException.Builder(CustomException(error, cause)) + .setLevel(LuciqNonFatalException.Level.ERROR) .build() .let { exception -> CrashReporting.report(exception)} } fun reportMoveAssetsError(locationInfo: String) { reportWithMixpanel(MoveError.FAILED_TO_SUBMIT_TRANSACTION, locationInfo) - Instabug.show() + Luciq.show() } fun reportWithMixpanel(error: BaseError, locationInfo: String) { @@ -40,8 +40,8 @@ object ErrorReporter { fun reportCriticalWithMixpanel(error: BaseError, cause: Throwable? = null) { val throwable = cause ?: CustomException(error) - IBGNonFatalException.Builder(throwable) - .setLevel(IBGNonFatalException.Level.CRITICAL) + LuciqNonFatalException.Builder(throwable) + .setLevel(LuciqNonFatalException.Level.CRITICAL) .build() .let { exception -> CrashReporting.report(exception)} MixpanelManager.error(error, throwable.getLocationInfo(), throwable.message) @@ -54,8 +54,8 @@ object ErrorReporter { // Map CadenceErrorCode to WalletError for consistent error reporting val walletError = WalletError(errorCode, cadenceError.name) val cause = CustomTransactionException(walletError, errorMessage) - IBGNonFatalException.Builder(cause) - .setLevel(IBGNonFatalException.Level.ERROR) + LuciqNonFatalException.Builder(cause) + .setLevel(LuciqNonFatalException.Level.ERROR) .setFingerprint("$scriptId.$errorCode") .build() .let { exception -> CrashReporting.report(exception)} diff --git a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt index 6628d54ac..057019cb3 100644 --- a/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt +++ b/app/src/main/java/com/flowfoundation/wallet/widgets/webview/evm/EvmInterface.kt @@ -73,7 +73,7 @@ class EvmInterface( return } uiScope { - if (EVMWalletManager.haveEVMAddress()) { + if (EVMWalletManager.haveEVMAddress() || WalletManager.getEOAAddresses().isNotEmpty()) { val connect = EvmRequestAccountDialog().show( activity.supportFragmentManager, EVMDialogModel( @@ -283,7 +283,8 @@ class EvmInterface( val signature = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signEthereumMessage(signMessage) } else { - val result = WalletManager.wallet()?.ethSignPersonalMessage(data) + val eoaIndex = WalletManager.getEOAIndexForAddress(fromAddress) + val result = WalletManager.wallet()?.ethSignPersonalMessage(data, index = eoaIndex) Numeric.toHexString(result) } webView.sendResult(network, signature, id) @@ -310,7 +311,8 @@ class EvmInterface( val signature = if (EVMWalletManager.isEVMWalletAddress(fromAddress)) { signTypedData(data) } else { - val result = WalletManager.wallet()?.ethSignTypedData(raw) + val eoaIndex = WalletManager.getEOAIndexForAddress(fromAddress) + val result = WalletManager.wallet()?.ethSignTypedData(raw, index = eoaIndex) Numeric.toHexString(result) } webView.sendResult(network, signature, id) diff --git a/app/src/main/res/drawable/bg_inbox_badge.xml b/app/src/main/res/drawable/bg_inbox_badge.xml new file mode 100644 index 000000000..ca391871d --- /dev/null +++ b/app/src/main/res/drawable/bg_inbox_badge.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_add_cadence_account.xml b/app/src/main/res/drawable/ic_add_cadence_account.xml new file mode 100644 index 000000000..23997f142 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_cadence_account.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_eoa_account.xml b/app/src/main/res/drawable/ic_add_eoa_account.xml new file mode 100644 index 000000000..c94851b50 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_eoa_account.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_inbox.xml b/app/src/main/res/drawable/ic_inbox.xml new file mode 100644 index 000000000..e6ba0c6a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_inbox.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/layout/activity_developer_mode_setting.xml b/app/src/main/res/layout/activity_developer_mode_setting.xml index aea133ece..8416522fd 100644 --- a/app/src/main/res/layout/activity_developer_mode_setting.xml +++ b/app/src/main/res/layout/activity_developer_mode_setting.xml @@ -196,7 +196,32 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:iconEnable="false" - app:titleId="@string/debug_view"/> + app:titleId="@string/wrap_eoa_tx_with_cadence"/> + + + + + + + + + diff --git a/app/src/main/res/layout/activity_webview.xml b/app/src/main/res/layout/activity_webview.xml index f4c09c666..96f4b4a30 100644 --- a/app/src/main/res/layout/activity_webview.xml +++ b/app/src/main/res/layout/activity_webview.xml @@ -1,10 +1,17 @@ - \ No newline at end of file + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/layout/dialog_add_collection_confirm.xml b/app/src/main/res/layout/dialog_add_collection_confirm.xml index c346176cd..e68e02e47 100644 --- a/app/src/main/res/layout/dialog_add_collection_confirm.xml +++ b/app/src/main/res/layout/dialog_add_collection_confirm.xml @@ -95,7 +95,7 @@ + android:backgroundTint="@color/deep_bg_dialog"> diff --git a/app/src/main/res/layout/dialog_fcl_wrong_network.xml b/app/src/main/res/layout/dialog_fcl_wrong_network.xml index 91c5c55a1..c4308b10d 100644 --- a/app/src/main/res/layout/dialog_fcl_wrong_network.xml +++ b/app/src/main/res/layout/dialog_fcl_wrong_network.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_top_radius_16dp" - android:backgroundTint="@color/deep_bg" + android:backgroundTint="@color/deep_bg_dialog" android:orientation="vertical" android:paddingHorizontal="18dp" android:paddingTop="18dp" diff --git a/app/src/main/res/layout/dialog_link_account.xml b/app/src/main/res/layout/dialog_link_account.xml index 1640aa4a9..6eca6ecc6 100644 --- a/app/src/main/res/layout/dialog_link_account.xml +++ b/app/src/main/res/layout/dialog_link_account.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_top_radius_16dp" - android:backgroundTint="@color/deep_bg" + android:backgroundTint="@color/deep_bg_dialog" android:paddingBottom="@dimen/wallet_create_button_margin"> @@ -112,4 +112,4 @@ app:cornerRadius="12dp" app:layout_constraintTop_toBottomOf="@id/content_wrapper" app:rippleColor="@color/primary10"/> - \ No newline at end of file + diff --git a/app/src/main/res/layout/dialog_root_detected.xml b/app/src/main/res/layout/dialog_root_detected.xml index 135583247..fdffccdb1 100644 --- a/app/src/main/res/layout/dialog_root_detected.xml +++ b/app/src/main/res/layout/dialog_root_detected.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_top_radius_16dp" - android:backgroundTint="@color/deep_bg" + android:backgroundTint="@color/deep_bg_dialog" android:paddingBottom="@dimen/wallet_create_button_margin"> @@ -153,7 +153,7 @@ android:layout_width="36dp" android:layout_height="16dp" android:src="@drawable/ic_unlink_mid" - android:background="@color/deep_bg" + android:background="@color/deep_bg_dialog" app:layout_constraintBottom_toBottomOf="@id/line" app:layout_constraintEnd_toEndOf="@id/line" app:layout_constraintStart_toStartOf="@id/line" diff --git a/app/src/main/res/layout/dialog_wallet_confirmation.xml b/app/src/main/res/layout/dialog_wallet_confirmation.xml index ecb2c5abf..68616ee58 100644 --- a/app/src/main/res/layout/dialog_wallet_confirmation.xml +++ b/app/src/main/res/layout/dialog_wallet_confirmation.xml @@ -42,7 +42,7 @@ android:layout_height="136dp" android:layout_marginHorizontal="18dp" android:layout_marginTop="10dp" - app:cardBackgroundColor="@color/deep_bg" + app:cardBackgroundColor="@color/deep_bg_dialog" app:cardCornerRadius="16dp" app:cardElevation="0dp" app:layout_constraintTop_toBottomOf="@id/title_view"> diff --git a/app/src/main/res/layout/fragment_coordinator_wallet.xml b/app/src/main/res/layout/fragment_coordinator_wallet.xml index 0102ab8f3..fc4975a30 100644 --- a/app/src/main/res/layout/fragment_coordinator_wallet.xml +++ b/app/src/main/res/layout/fragment_coordinator_wallet.xml @@ -166,6 +166,10 @@ android:id="@+id/wallet_header" layout="@layout/layout_wallet_coordinator_header"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_evm_account.xml b/app/src/main/res/layout/fragment_evm_account.xml index 9708a641b..f2490af6b 100644 --- a/app/src/main/res/layout/fragment_evm_account.xml +++ b/app/src/main/res/layout/fragment_evm_account.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_top_radius_16dp" - android:backgroundTint="@color/deep_bg" + android:backgroundTint="@color/deep_bg_dialog" android:paddingHorizontal="18dp" android:paddingTop="18dp" android:paddingBottom="@dimen/wallet_create_button_margin"> diff --git a/app/src/main/res/layout/fragment_evm_select_account.xml b/app/src/main/res/layout/fragment_evm_select_account.xml index b8b19cba9..eccf7deda 100644 --- a/app/src/main/res/layout/fragment_evm_select_account.xml +++ b/app/src/main/res/layout/fragment_evm_select_account.xml @@ -6,7 +6,7 @@ android:layout_height="wrap_content" android:minHeight="500dp" android:background="@drawable/bg_top_radius_16dp" - android:backgroundTint="@color/deep_bg" + android:backgroundTint="@color/deep_bg_dialog" android:padding="24dp"> + + + + + diff --git a/app/src/main/res/layout/layout_wallet_coordinator_header.xml b/app/src/main/res/layout/layout_wallet_coordinator_header.xml index 091cbf9ed..e387edcc4 100644 --- a/app/src/main/res/layout/layout_wallet_coordinator_header.xml +++ b/app/src/main/res/layout/layout_wallet_coordinator_header.xml @@ -73,6 +73,41 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/tv_address" app:layout_constraintBottom_toBottomOf="@id/tv_address"/> + + + + + + + + @@ -270,7 +305,7 @@ android:id="@+id/tv_token_count" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="24dp" + android:layout_marginStart="16dp" android:layout_marginTop="24dp" android:fontFamily="@font/inter" android:textColor="@color/text_3" @@ -309,15 +344,50 @@ + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 5e94ba539..26daeb0a3 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -55,6 +55,7 @@ #C44536 #261C1B #000000 + #121212 #333333 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index fd949cf9a..692a086b7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -90,6 +90,7 @@ #1a1a1a #ffffff #fafafa + #fafafa #f0f0f0 #00ffffff diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 372bebd54..02db1dd11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,7 @@ created your previous wallet. Sign in with \nRecovery Phrase Enter the Recovery Phrase + Recovery phrase does not match this account Account not Found No account found with the recovery phrase. Do you want to create a new account with your phrase? your phrase @@ -188,6 +189,8 @@ Hold to Send The transaction is processing Something went wrong, please try again later. + Create a new Flow Cadence account + Create a new Flow EVM account Pending Success Failed @@ -653,6 +656,7 @@ Please try again later. Debug View Wrap EOA TX with Cadence + Hide COA With Zero Balance Export Log View Recovery Phrase Clear diff --git a/build.gradle b/build.gradle index 7860e5c0a..0fe9f72bb 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ buildscript { classpath 'com.google.gms:google-services:4.4.2' classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' - classpath 'com.instabug.library:instabug-plugin:12.0.0' + classpath 'ai.luciq.library:luciq-plugin:19.3.0' classpath 'com.google.firebase:firebase-appdistribution-gradle:5.0.0' // Google Play Publisher plugin for uploading AAB to Play Console classpath 'com.github.triplet.gradle:play-publisher:3.10.1' diff --git a/gradle.properties b/gradle.properties index 695617573..55c24698f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -64,7 +64,7 @@ android.r8.dexing.parallel=true #android.bundle.enableUncompressedNativeLibs=false #android.experimental.enableArtProfiles=true -vCode=333 +vCode=400000549 vName=r3.1.0 # --- GitHub Packages (Flow Wallet Kit) ---