diff --git a/app/build.gradle b/app/build.gradle
index dd524b01a..bbc9a386d 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.5'
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..8df9aae9d 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)
}
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..9a8990092 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,9 @@ object AccountManager {
}
logd(TAG, "AccountManager initialization completed successfully")
+ // Migrate key material to independent storage now that accounts are loaded.
+ // This must run inside the ioScope block so accounts list is fully populated.
+ safeRun { KeyStorageMigration.runMigrationIfNeeded() }
// Update Accounts info with WalletDataManager
WalletDataManager.updateWalletData()
@@ -144,7 +143,6 @@ object AccountManager {
// Clear potentially corrupted state
accounts.clear()
userPrefixes.clear()
- switchAccounts.clear()
currentAccount = null
// Initialization failed - user needs to login/restore
@@ -154,36 +152,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 +215,8 @@ object AccountManager {
userPrefixes.removeAll { it.userId == uid}
userPrefixes.add(UserPrefix(uid, prefix))
UserPrefixCacheManager.cache(UserPrefixes().apply { addAll(userPrefixes) })
+ // Write to independent key storage so prefix survives account-cache loss
+ KeyStorageManager.saveAndroidKeystorePrefix(uid, prefix)
}
AccountEmojiManager.init()
@@ -243,9 +265,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 +282,6 @@ object AccountManager {
AccountCacheManager.cache(Accounts().apply { addAll(accounts) })
logd(TAG, "Cleared account cache")
-
// Clear WalletManager state
try {
WalletManager.clear()
@@ -300,14 +318,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 +439,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 +483,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 +508,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 +523,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 +566,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 +615,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 +651,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 +714,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 +757,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/config/AppConfig.kt b/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt
index d1b9a91db..9dbc6729e 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,8 @@ object AppConfig {
fun checkBloctoKeyRotation() = isDev() || isTesting() || (config().getFeatures().bloctoKeyRotation ?: false)
+ fun canCreateNewAccount() = isDev() || isTesting() || (config().getFeatures().createNewAccount ?: false)
+
fun addressRegistry(network: Int): Map {
return when (network) {
NETWORK_TESTNET -> flowAddressRegistry().testnet
@@ -228,7 +230,10 @@ 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?
)
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..4e6535958 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)
@@ -123,6 +124,31 @@ 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()) Emoji.PENGUIN else available.random()
+ val info = WalletEmojiInfo(address, emoji.id, emoji.defaultName)
+ emojiList.add(info)
+ AccountManager.updateWalletEmojiInfo(username, emojiList.toMutableList())
+ return info
+ }
+
fun clear() {
accountEmojiList.clear()
listeners.clear()
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/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/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..985980bed 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,12 +33,11 @@ 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
@@ -66,14 +65,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 {
@@ -463,6 +463,7 @@ object WalletManager {
selectedWalletAddressRef.set("")
currentWallet = null
lastAddressCheck = 0
+ _canDeriveEoa = false
}
}
}
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..cc9babed0 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
@@ -145,17 +145,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 +238,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()
@@ -263,9 +269,9 @@ object WalletDataManager {
logd(TAG, "Fetching data for ${allBlockchainData.size} BlockchainData entries for node construction")
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()) {
+ // EOA Wallet - canDeriveEoa is determined per-wallet by WalletCreationHelper
+ // based on key type (Secure Enclave / private key = false, seed phrase = true)
+ if (canDeriveEoa) {
val eoa = deriveEoaAddress(wallet)
logd(TAG, "Generated EOA address: $eoa")
if (eoa.isNotEmpty()) {
@@ -366,7 +372,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,11 +383,15 @@ 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 accountUsername = account.userInfo.username
+ val accountEmojiList = (account.walletEmojiList ?: emptyList()).toMutableList()
+ 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 eoa = deriveEoaAddress(wallet)
if (eoa.isNotEmpty()) {
logd(TAG, "Adding EOA for non-current account: $eoa")
@@ -391,7 +403,7 @@ object WalletDataManager {
))
}
} 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..cd9ffd3bd
--- /dev/null
+++ b/app/src/main/java/com/flowfoundation/wallet/network/AddAccountUtils.kt
@@ -0,0 +1,93 @@
+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.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
+ }
+}
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/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..568586d2f 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
@@ -58,9 +57,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 +523,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 +553,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 +591,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()) {
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..fc6172273 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
@@ -47,7 +66,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.flowfoundation.wallet.R
import com.flowfoundation.wallet.base.activity.BaseActivity
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 +84,7 @@ class AccountListActivity : BaseActivity() {
setContent {
AccountListScreen(
- onBackPressed = { finish() },
- onAddPressed = {
- ProfileSwitchDialog().show(supportFragmentManager, "profile_switch")
- }
+ onBackPressed = { finish() }
)
}
}
@@ -85,12 +100,13 @@ 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 context = LocalContext.current
val activity = remember { getActivityFromContext(context) as FragmentActivity }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
@@ -138,10 +154,14 @@ fun AccountListScreen(
}
},
actions = {
- IconButton(onClick = onAddPressed) {
+ IconButton(
+ onClick = { viewModel.addAccount() },
+ enabled = canAddAccount,
+ modifier = Modifier.alpha(if (canAddAccount) 1f else 0f)
+ ) {
Icon(
imageVector = Icons.Default.Add,
- contentDescription = "Add Profile",
+ contentDescription = "Add Account",
tint = colorResource(id = R.color.text)
)
}
@@ -186,7 +206,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..0755c3a7c 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,6 +1,7 @@
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
@@ -13,12 +14,18 @@ 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.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.toast
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -35,6 +42,12 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
private val _hiddenAccounts = MutableStateFlow>(emptySet())
val hiddenAccounts: StateFlow> = _hiddenAccounts.asStateFlow()
+ private val _isAddingAccount = MutableStateFlow(false)
+ val isAddingAccount: StateFlow = _isAddingAccount.asStateFlow()
+
+ private val _canAddAccount = MutableStateFlow(false)
+ val canAddAccount: StateFlow = _canAddAccount.asStateFlow()
+
private val service by lazy { retrofitApi().create(ApiService::class.java) }
// Cache for verified EVM addresses that should be included in linkedAccounts
@@ -60,13 +73,12 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
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,
+ name = mainNode.name,
+ emojiId = mainNode.emojiId,
isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true),
isEOAAccount = true
)
@@ -74,7 +86,6 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
}
is FlowWallet -> {
if (mainNode.chainIdString == currentNetwork) {
- val emojiInfo = AccountEmojiManager.getEmojiByAddress(mainNode.address)
val linkedAccounts = mutableListOf()
mainNode.linkedWallets.forEach { linkedWallet ->
@@ -86,7 +97,7 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
address = linkedWallet.address,
name = linkedWallet.name,
icon = linkedWallet.icon,
- emojiId = AccountEmojiManager.getEmojiByAddress(linkedWallet.address).emojiId,
+ emojiId = linkedWallet.emojiId,
isSelected = WalletManager.selectedWalletAddress().equals(linkedWallet.address, ignoreCase = true),
isCOAAccount = false
)
@@ -99,13 +110,12 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
if (evmAddress !in verifiedEvmAddresses) {
pendingEvmAddresses.add(Pair(evmAddress, mainNode.address))
} else {
- val linkedEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress)
linkedAccounts.add(
LinkedAccountData(
address = evmAddress,
- name = linkedEmojiInfo.emojiName,
+ name = linkedWallet.name,
icon = null,
- emojiId = linkedEmojiInfo.emojiId,
+ emojiId = linkedWallet.emojiId,
isSelected = WalletManager.selectedWalletAddress().equals(evmAddress, ignoreCase = true),
isCOAAccount = true
)
@@ -118,8 +128,8 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
accounts.add(
WalletAccountData(
address = mainNode.address,
- name = emojiInfo.emojiName,
- emojiId = emojiInfo.emojiId,
+ name = mainNode.name,
+ emojiId = mainNode.emojiId,
isSelected = WalletManager.selectedWalletAddress().equals(mainNode.address, ignoreCase = true),
linkedAccounts = linkedAccounts,
isEOAAccount = false
@@ -144,12 +154,39 @@ 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
+
if (refreshBalance) {
fetchAllBalances(addressList, pendingEvmAddresses)
}
}
}
+ fun addAccount() {
+ ioScope {
+ _isAddingAccount.value = true
+ try {
+ val result = addNewFlowAccount()
+ if (result == null) {
+ _isAddingAccount.value = false
+ toast(msgRes = R.string.common_error_hint)
+ } else {
+ // New accounts always have 0 balance; inject immediately for instant UI feedback
+ _balanceMap.value = _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,23 +195,31 @@ class AccountListViewModel : ViewModel(), OnEmojiUpdate {
}
_balanceMap.value = formattedBalanceMap
+ val hideCOAWithZeroBalance = isHideCOAWithZeroBalanceEnable()
+
// Check each pending EVM address
pendingEvmAddresses.forEach { (evmAddress, walletAddress) ->
- val evmBalance = balanceMap[evmAddress]
- val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO
- var hasNFTs = false
-
- if (!hasBalance) {
- try {
- val nftResponse = service.getEVMNFTCollections(evmAddress)
- val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0
- hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0
- } catch (e: Exception) {
- // Ignore NFT API errors
+ val shouldShow = if (!hideCOAWithZeroBalance) {
+ true
+ } else {
+ val evmBalance = balanceMap[evmAddress]
+ val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO
+ var hasNFTs = false
+
+ if (!hasBalance) {
+ try {
+ val nftResponse = service.getEVMNFTCollections(evmAddress)
+ val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0
+ hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0
+ } catch (_: Exception) {
+ // Ignore NFT API errors
+ }
}
+
+ hasBalance || hasNFTs
}
- if (hasBalance || hasNFTs) {
+ if (shouldShow) {
// Add EVM address to linked accounts
val currentAccounts = _accounts.value.toMutableList()
val walletAccount = currentAccounts.find { it.address == walletAddress }
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/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/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/profile/ProfileSwitchDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt
index d06966580..d3c9813ed 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
@@ -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..0cfcd51d4 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())
@@ -196,8 +192,7 @@ class ProfileSwitchViewModel : ViewModel() {
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..1e1ac84c3 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,8 +29,8 @@ 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.wallet.WalletManager
class MainActivity : BaseActivity() {
@@ -61,13 +57,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 +73,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) }
}
}
@@ -102,10 +91,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)
}
}
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..3e87f9192 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,7 +1,7 @@
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
@@ -14,15 +14,21 @@ 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.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.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,6 +48,12 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate {
private val _balanceMap = MutableStateFlow