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