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>(emptyMap()) val balanceMap: StateFlow> = _balanceMap.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 @@ -53,7 +65,6 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } fun loadData(refreshBalance: Boolean = false) { - loadEvmStatus() refreshWalletList(refreshBalance) } @@ -74,13 +85,12 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, 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 ) @@ -88,7 +98,6 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } is FlowWallet -> { if (mainNode.chainIdString == currentNetwork) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(mainNode.address) val linkedAccounts = mutableListOf() mainNode.linkedWallets.forEach { linkedWallet -> @@ -100,7 +109,7 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, 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 ) @@ -112,13 +121,12 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, 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 ) @@ -131,8 +139,8 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, 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 ) @@ -143,8 +151,11 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } } - // Filter out hidden accounts for the current user - val userId = firebaseUid() + // Filter out hidden accounts for the current user. + // Use AccountManager (already updated before listeners fire) rather than + // firebaseUid() (reads Firebase.auth.currentUser which can still be anonymous + // during the auth transition window of an account switch). + val userId = AccountManager.get()?.wallet?.id val filteredAccounts = if (userId != null) { AccountVisibilityManager.filterVisibleAccounts( userId, @@ -155,12 +166,41 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, OnEmojiUpdate { } _accounts.value = filteredAccounts + loadEvmStatus() + + val flowWalletCount = walletNodes.filterIsInstance() + .count { it.chainIdString == currentNetwork } + val cryptoProvider = CryptoProviderManager.getCurrentCryptoProvider() + _canAddAccount.value = AppConfig.canCreateNewAccount() + && flowWalletCount < 5 + && cryptoProvider != null + && cryptoProvider !is AndroidKeystoreCryptoProvider + 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 @@ -169,23 +209,31 @@ class DrawerLayoutViewModel : ViewModel(), OnAccountUpdate, 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 (_: 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/main/presenter/DrawerLayoutContent.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt index e76cd3a3c..8c1a70787 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt @@ -21,10 +21,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.draw.rotate import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -32,8 +40,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext @@ -107,6 +117,8 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { val showEvmLayout by viewModel.showEvmLayout.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() + val isAddingAccount by viewModel.isAddingAccount.collectAsStateWithLifecycle() + val canAddAccount by viewModel.canAddAccount.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.loadData() @@ -134,18 +146,16 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { .background(colorResource(id = R.color.deep_bg)) .padding(horizontal = 18.dp, vertical = 24.dp) ) { - userInfo?.let { - HeaderSection( - userInfo = it, - onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } - ) - HorizontalDivider( - color = colorResource(id = R.color.border_line_stroke), - modifier = Modifier - .fillMaxWidth() - .padding(top = 14.dp) - ) - } + HeaderSection( + userInfo = userInfo, + onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } + ) + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + ) if (showEvmLayout) { Spacer(modifier = Modifier.height(24.dp)) @@ -163,6 +173,7 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { AccountListSection( accounts = accounts, balanceMap = balanceMap, + isAddingAccount = isAddingAccount, onCopyClick = onCopyClick@{ address -> if (EVMWalletManager.isEVMWalletAddress(address)) { CopyCOAAddressDialog(context, address).show() @@ -202,65 +213,85 @@ fun DrawerLayoutCompose(drawer: DrawerLayout) { onImportWalletClick = { WalletRestoreActivity.launch(activity) }, - onAddProfileClick = { - if (isTestnet()) { - SwitchNetworkDialog(context, DialogType.CREATE).show() - } else { - ReactNativeActivity.launchWithRoute(context, RNBridge.ScreenType.ONBOARDING, RNBridge.InitialRoute.PROFILE_TYPE_SELECTION) - } - } + onAddAccountClick = { viewModel.addAccount() }, + canAddAccount = canAddAccount ) } } @Composable fun HeaderSection( - userInfo: UserInfoData, + userInfo: UserInfoData?, onAccountSwitchClick: () -> Unit ) { - val avatarUrl = userInfo.avatar.parseAvatarUrl() - val avatar = if (avatarUrl.contains("flovatar.com")) { - avatarUrl.svgToPng() - } else { - avatarUrl - } ConstraintLayout( modifier = Modifier .fillMaxWidth() .padding(top = 40.dp) ) { val (icon, name, switch) = createRefs() - AsyncImage( - model = avatar, - contentDescription = "User Avatar", - contentScale = ContentScale.Crop, - placeholder = painterResource(id = R.drawable.ic_placeholder), - error = painterResource(id = R.drawable.ic_placeholder), - modifier = Modifier - .constrainAs(icon) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(name.start) - } - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - ) - Text( - text = userInfo.nickname, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(icon.end, 16.dp) - end.linkTo(switch.start, 16.dp) - width = Dimension.fillToConstraints - } - ) + if (userInfo == null) { + ShimmerBox( + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + } + .size(40.dp), + shape = RoundedCornerShape(8.dp) + ) + ShimmerBox( + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + .height(16.dp) + .width(120.dp) + ) + } else { + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(name.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + } IconButton( onClick = onAccountSwitchClick, @@ -664,6 +695,7 @@ fun AccountListSection( balanceMap: Map, onCopyClick: (String) -> Unit, onAccountClick: (String) -> Unit, + isAddingAccount: Boolean = false, modifier: Modifier = Modifier ) { val scrollState = rememberScrollState() @@ -679,13 +711,16 @@ fun AccountListSection( color = colorResource(id = R.color.text_2), fontSize = 14.sp ) - activeAccount?.let { + if (activeAccount != null) { Spacer(modifier = Modifier.height(16.dp)) ActiveAccountSection( - item = it, + item = activeAccount, balanceMap = balanceMap, onCopyClick = onCopyClick ) + } else if (accounts.isEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ShimmerWalletAccountRow() } Spacer(modifier = Modifier.height(16.dp)) Text( @@ -694,20 +729,30 @@ fun AccountListSection( fontSize = 14.sp, ) - accounts.forEach { account -> - WalletAccountSection( - item = account, - balance = balanceMap[account.address.toAddress()] ?: "", - onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(account.address) } - ) - account.linkedAccounts.forEach { linkedAccount -> - LinkedAccountSection( - item = linkedAccount, - balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + if (isAddingAccount) { + LoadingWalletAccountRow() + } + + if (accounts.isEmpty()) { + repeat(2) { + ShimmerWalletAccountRow() + } + } else { + accounts.forEach { account -> + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "", onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(linkedAccount.address) } + onAccountClick = { onAccountClick(account.address) } ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(linkedAccount.address) } + ) + } } } } @@ -716,37 +761,164 @@ fun AccountListSection( @Composable fun BottomSection( onImportWalletClick: () -> Unit, - onAddProfileClick: () -> Unit + onAddAccountClick: () -> Unit = {}, + canAddAccount: Boolean = false ) { + Column { + if (canAddAccount) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onAddAccountClick), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_add_24), + contentDescription = "Add Account", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.add_account), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onImportWalletClick), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_import_wallet), + contentDescription = "Recover Profile", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.recover_profile), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + } +} + +@Composable +private fun LoadingWalletAccountRow() { + val infiniteTransition = rememberInfiniteTransition(label = "loading") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "loading_rotation" + ) Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 12.dp) ) { - Box(modifier = Modifier - .size(40.dp) - .background( - color = colorResource(id = R.color.bg_card), - shape = CircleShape - ) - .clickable(onClick = onAddProfileClick), + Box( + modifier = Modifier.size(45.dp), contentAlignment = Alignment.Center ) { + CircularProgressIndicator( + progress = { 0.25f }, + modifier = Modifier.size(45.dp).rotate(rotation), + color = colorResource(id = R.color.accent_green), + trackColor = colorResource(id = R.color.accent_green_8), + strokeWidth = 7.dp + ) Icon( - painter = painterResource(id = R.drawable.ic_baseline_add_24), - contentDescription = "Add Account", - tint = colorResource(id = R.color.text_1) + painter = painterResource(id = R.drawable.ic_coin_flow), + contentDescription = null, + modifier = Modifier.size(30.dp), + tint = Color.Unspecified ) } - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource(id = R.string.recover_profile), - color = colorResource(id = R.color.text_2), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable(onClick = onImportWalletClick) + Spacer(modifier = Modifier.width(9.dp)) + Column { + ShimmerBox(modifier = Modifier.width(60.dp).height(14.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(100.dp).height(12.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(80.dp).height(12.dp)) + } + } +} + +@Composable +private fun ShimmerBox( + modifier: Modifier, + shape: Shape = RoundedCornerShape(4.dp) +) { + val alpha by rememberInfiniteTransition(label = "shimmer").animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer_alpha" + ) + Box( + modifier = modifier + .alpha(alpha) + .background(colorResource(id = R.color.bg_card), shape) + ) +} + +@Composable +private fun ShimmerWalletAccountRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + ShimmerBox( + modifier = Modifier.size(42.dp), + shape = CircleShape ) + Spacer(modifier = Modifier.width(9.dp)) + Column { + ShimmerBox(modifier = Modifier.width(60.dp).height(14.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(100.dp).height(12.dp)) + Spacer(modifier = Modifier.height(2.dp)) + ShimmerBox(modifier = Modifier.width(80.dp).height(12.dp)) + } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt index 5639718c9..ebe74b3e8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingComponents.kt @@ -138,7 +138,7 @@ fun SettingSwitchItem( modifier = Modifier .fillMaxWidth() .clickable { onCheckedChange(!isChecked) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // Icon diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt index a4799afd7..5ee9fccb6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/compose/SettingScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -44,7 +46,7 @@ import com.flowfoundation.wallet.page.account.AccountListActivity import com.flowfoundation.wallet.page.security.SecuritySettingActivity import com.flowfoundation.wallet.utils.* import com.flowfoundation.wallet.utils.extensions.openInSystemBrowser -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import kotlinx.coroutines.launch @Composable @@ -255,7 +257,7 @@ fun SettingScreen( titleRes = R.string.bug_report, showDivider = true ) { - Instabug.show() + Luciq.show() } if (isSignedIn) { @@ -302,6 +304,7 @@ fun SettingScreen( text = stringResource(R.string.free_gas_fee_desc), fontSize = 12.sp, color = colorResource(R.color.text_2), + style = TextStyle(lineBreak = LineBreak.Paragraph), modifier = Modifier .fillMaxWidth() .padding(top = 6.dp) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt index fa773c49a..e068779c3 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt @@ -2,7 +2,7 @@ package com.flowfoundation.wallet.page.profile.presenter import android.annotation.SuppressLint import androidx.lifecycle.ViewModelProvider -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.zackratos.ultimatebarx.ultimatebarx.addStatusBarTopPadding import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.presenter.BasePresenter @@ -105,7 +105,7 @@ class ProfileFragmentPresenter( ) } - binding.group3.bugReport.setOnClickListener { Instabug.show() } + binding.group3.bugReport.setOnClickListener { Luciq.show() } binding.group3.aboutPreference.setOnClickListener { AboutActivity.launch(context) } binding.group4.switchAccountPreference.setOnClickListener { logd("ProfileFragmentPresenter", "switchAccountPreference clicked") diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt index 4114f4f7b..582681d7e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/subpage/developer/presenter/DeveloperModePresenter.kt @@ -15,6 +15,8 @@ import com.flowfoundation.wallet.manager.cadence.CadenceApiManager import com.flowfoundation.wallet.page.profile.subpage.developer.DeveloperModeViewModel import com.flowfoundation.wallet.page.profile.subpage.developer.LocalAccountKeyActivity import com.flowfoundation.wallet.page.profile.subpage.developer.model.DeveloperPageModel +import com.flowfoundation.wallet.utils.isDev +import com.flowfoundation.wallet.utils.isTesting import com.flowfoundation.wallet.utils.NETWORK_MAINNET import com.flowfoundation.wallet.utils.NETWORK_TESTNET import com.flowfoundation.wallet.utils.debug.DebugLogManager @@ -31,7 +33,9 @@ import com.flowfoundation.wallet.utils.updateChainNetworkPreference import com.flowfoundation.wallet.utils.getWatchCollectibleAddress import com.flowfoundation.wallet.utils.setWatchCollectibleAddress import com.flowfoundation.wallet.utils.clearWatchCollectibleAddress +import com.flowfoundation.wallet.utils.isHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.isWrapEOATxWithCadenceEnable +import com.flowfoundation.wallet.utils.setHideCOAWithZeroBalanceEnable import com.flowfoundation.wallet.utils.setWrapEOATxWithCadenceEnable import com.flowfoundation.wallet.widgets.ProgressDialog import kotlinx.coroutines.delay @@ -79,12 +83,15 @@ class DeveloperModePresenter( // Initialize Watch Collectible Address setupWatchCollectibleAddress() setupWrapEOATxWithCadence() + setupHideCOAWithZeroBalance() developerModePreference.setOnCheckedChangeListener { setDevelopContentVisible(it) setDeveloperModeEnable(it) if (!it) { changeNetwork(NETWORK_MAINNET) + } else { + ioScope { refreshChainNetworkSync() } } } @@ -128,7 +135,7 @@ class DeveloperModePresenter( private fun setDevelopContentVisible(visible: Boolean) { binding.group2.setVisible(visible) binding.group3.setVisible(visible) - binding.cvDebug.setVisible(visible) + binding.cvDebug.setVisible(visible && (isDev() || isTesting())) binding.cvAccountKey.setVisible(visible && showLocalAccountKeys) binding.cvReloadConfig.setVisible(visible) } @@ -174,6 +181,18 @@ class DeveloperModePresenter( } } + private fun setupHideCOAWithZeroBalance() { + ioScope { + val isHide = isHideCOAWithZeroBalanceEnable() + uiScope { + binding.hideCoaZeroBalance.setChecked(isHide) + binding.hideCoaZeroBalance.setOnCheckedChangeListener { + setHideCOAWithZeroBalanceEnable(it) + } + } + } + } + private fun setupWatchCollectibleAddress() { val savedAddress = getWatchCollectibleAddress() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt index 25d37960e..d3d855431 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyInfoFragment.kt @@ -20,7 +20,7 @@ import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -121,7 +121,7 @@ class PrivateKeyInfoFragment: Fragment() { } updateImportButtonState() - Instabug.addPrivateViews(etPrivateKey) + Luciq.addPrivateViews(etPrivateKey) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt index 92d756ac3..9458c2bc2 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/PrivateKeyStoreInfoFragment.kt @@ -31,7 +31,7 @@ import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import org.json.JSONObject @@ -122,8 +122,8 @@ class PrivateKeyStoreInfoFragment: Fragment() { // Set up clickable error message setupErrorMessage() - Instabug.addPrivateViews(etJson) - Instabug.addPrivateViews(etPassword) + Luciq.addPrivateViews(etJson) + Luciq.addPrivateViews(etPassword) } // Observe keystore format errors from ViewModel diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt index 13fdc98e9..3f93769f8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/fragment/SeedPhraseInfoFragment.kt @@ -34,7 +34,7 @@ import com.flowfoundation.wallet.utils.extensions.visible import com.flowfoundation.wallet.utils.listeners.SimpleTextWatcher import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.widgets.itemdecoration.ColorDividerItemDecoration -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import wallet.core.jni.HDWallet @@ -124,7 +124,7 @@ class SeedPhraseInfoFragment: Fragment() { ) } btnImport.isEnabled = false - Instabug.addPrivateViews(etSeedPhrase) + Luciq.addPrivateViews(etSeedPhrase) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt index 5ac7231a6..588e4c295 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/viewmodel/KeyStoreRestoreViewModel.kt @@ -52,6 +52,7 @@ import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.containsFlowWalletAddress import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.key.CryptoProviderManager +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.utils.secret.EncryptedMnemonicUtils import com.flowfoundation.wallet.utils.RandomUsernameGenerator import wallet.core.jni.StoredKey @@ -288,8 +289,8 @@ class KeyStoreRestoreViewModel : ViewModel() { fun importPrivateKey(privateKey: String, address: String) { loadingLiveData.postValue(true) restoreType = RestoreType.PRIVATE_KEY - try { - ioScope { + ioScope { + try { val storage = getStorage() val key = PrivateKey.create(storage).apply { logd("KeyStoreRestoreViewModel", "Created new PrivateKey instance") @@ -320,12 +321,12 @@ class KeyStoreRestoreViewModel : ViewModel() { p1PublicKey ?: "" ) } + } catch (e: Exception) { + e.printStackTrace() + ErrorReporter.reportWithMixpanel(BackupError.PRIVATE_KEY_RESTORE_FAILED, e) + loadingLiveData.postValue(false) + toast(msgRes = R.string.restore_failed) } - } catch (e: Exception) { - e.printStackTrace() - ErrorReporter.reportWithMixpanel(BackupError.PRIVATE_KEY_RESTORE_FAILED, e) - loadingLiveData.postValue(false) - toast(msgRes = R.string.restore_failed) } } @@ -334,8 +335,8 @@ class KeyStoreRestoreViewModel : ViewModel() { loadingLiveData.postValue(true) restoreType = RestoreType.SEED_PHRASE currentMnemonic = mnemonic // Store mnemonic for use in KeystoreAddress creation - try { - ioScope { + ioScope { + try { val storage = getStorage() val seedPhraseKey = SeedPhraseKey( mnemonicString = mnemonic, @@ -363,12 +364,12 @@ class KeyStoreRestoreViewModel : ViewModel() { p1PublicKey ?: "" ) } + } catch (e: Exception) { + e.printStackTrace() + ErrorReporter.reportWithMixpanel(BackupError.SEED_PHRASE_RESTORE_FAILED, e) + loadingLiveData.postValue(false) + toast(msgRes = R.string.restore_failed) } - } catch (e: Exception) { - e.printStackTrace() - ErrorReporter.reportWithMixpanel(BackupError.SEED_PHRASE_RESTORE_FAILED, e) - loadingLiveData.postValue(false) - toast(msgRes = R.string.restore_failed) } } @@ -646,6 +647,7 @@ class KeyStoreRestoreViewModel : ViewModel() { return } if (WalletManager.getCurrentFlowWalletAddress() == currentKeyStoreAddress?.address) { + loadingLiveData.postValue(false) toast(msgRes = R.string.wallet_already_logged_in, duration = Toast.LENGTH_LONG) val activity = BaseActivity.getCurrentActivity() ?: return activity.finish() @@ -654,6 +656,7 @@ class KeyStoreRestoreViewModel : ViewModel() { val account = AccountManager.list() .firstOrNull { it.containsFlowWalletAddress(currentKeyStoreAddress?.address ?: "") } if (account != null) { + loadingLiveData.postValue(false) AccountManager.switch(account) {} return } @@ -666,6 +669,7 @@ class KeyStoreRestoreViewModel : ViewModel() { val activity = BaseActivity.getCurrentActivity() ?: run { logd("KeyStoreRestoreViewModel", "ERROR: No current activity found") + loadingLiveData.postValue(false) return@ioScope } @@ -695,6 +699,7 @@ class KeyStoreRestoreViewModel : ViewModel() { } } ?: run { logd("KeyStoreRestoreViewModel", "ERROR: Could not find matching key on-chain for public key: ${currentKeyStoreAddress?.publicKey}") + loadingLiveData.postValue(false) toast(msgRes = R.string.login_failure) activity.finish() return@ioScope @@ -704,12 +709,14 @@ class KeyStoreRestoreViewModel : ViewModel() { if (currentKey.weight.toInt() < 1000) { logd("KeyStoreRestoreViewModel", "ERROR: Key weight insufficient: ${currentKey.weight}") + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_insufficient_weight) activity.finish() return@ioScope } if (currentKey.revoked) { logd("KeyStoreRestoreViewModel", "ERROR: Key is revoked") + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_key_revoked) activity.finish() return@ioScope @@ -818,6 +825,8 @@ class KeyStoreRestoreViewModel : ViewModel() { wallet = walletData ) ) + // Persist key material in independent storage + saveKeyToNewStorage(userId, keyStoreInfo) logd("KeyStoreRestoreViewModel", "Account added successfully") logd("KeyStoreRestoreViewModel", "Import process completed successfully") callback.invoke(true) @@ -859,6 +868,7 @@ class KeyStoreRestoreViewModel : ViewModel() { getFirebaseUid { uid -> if (uid.isNullOrBlank()) { logd("KeyStoreRestoreViewModel", "No Firebase UID found") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@getFirebaseUid } @@ -885,6 +895,7 @@ class KeyStoreRestoreViewModel : ViewModel() { if (resp.data?.customToken.isNullOrBlank()) { logd("KeyStoreRestoreViewModel", "No custom token in response") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@runCatching } @@ -925,6 +936,7 @@ class KeyStoreRestoreViewModel : ViewModel() { logd("KeyStoreRestoreViewModel", "ERROR: No wallet address found in key indexer after login") logd("KeyStoreRestoreViewModel", "Public key used for lookup: $publicKey") logd("KeyStoreRestoreViewModel", "Chain ID used: $chainId") + loadingLiveData.postValue(false) loginProcessCallback.invoke(false) return@ioScope } @@ -983,6 +995,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) clearUserCache() AccountManager.add(userAccount) + // Persist key material in independent storage + saveKeyToNewStorage(userId, Gson().toJson(keystoreAddress)) MixpanelManager.accountRestore(finalWalletAddress, restoreType) accountSuccessfullyAdded = true logd("KeyStoreRestoreViewModel", "Post-login process completed successfully.") @@ -1030,6 +1044,7 @@ class KeyStoreRestoreViewModel : ViewModel() { private fun loginWithKeyStoreAddress(flowAccountKey: AccountPublicKey, keystoreAddress: KeystoreAddress) { if (WalletManager.getCurrentFlowWalletAddress() == keystoreAddress.address) { + loadingLiveData.postValue(false) toast(msgRes = R.string.wallet_already_logged_in, duration = Toast.LENGTH_LONG) val activity = BaseActivity.getCurrentActivity() ?: return activity.finish() @@ -1038,18 +1053,24 @@ class KeyStoreRestoreViewModel : ViewModel() { val account = AccountManager.list() .firstOrNull { it.containsFlowWalletAddress(keystoreAddress.address) } if (account != null) { + loadingLiveData.postValue(false) AccountManager.switch(account) {} return } ioScope { val cryptoProvider = PrivateKeyStoreCryptoProvider(Gson().toJson(keystoreAddress)) - val activity = BaseActivity.getCurrentActivity() ?: return@ioScope + val activity = BaseActivity.getCurrentActivity() ?: run { + loadingLiveData.postValue(false) + return@ioScope + } if (flowAccountKey.weight.toInt() < 1000) { + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_insufficient_weight) activity.finish() return@ioScope } if (flowAccountKey.revoked) { + loadingLiveData.postValue(false) toast(msgRes = R.string.restore_failure_key_revoked) activity.finish() return@ioScope @@ -1123,6 +1144,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(userId, keyStoreInfo) MixpanelManager.accountRestore( cryptoProvider.getAddress(), restoreType @@ -1204,7 +1227,10 @@ class KeyStoreRestoreViewModel : ViewModel() { logd("KeyStoreRestoreViewModel", "Starting create account with username: $username") ioScope { val cryptoProvider = PrivateKeyStoreCryptoProvider(Gson().toJson(currentKeyStoreAddress)) - val activity = BaseActivity.getCurrentActivity() ?: return@ioScope + val activity = BaseActivity.getCurrentActivity() ?: run { + loadingLiveData.postValue(false) + return@ioScope + } createAccount(username, cryptoProvider) { isSuccess -> uiScope { loadingLiveData.postValue(false) @@ -1251,39 +1277,52 @@ class KeyStoreRestoreViewModel : ViewModel() { // Create EVMAccountInfo for keystore registration val evmAccountInfo = try { val storage = getStorage() - val privateKeyHex = cryptoProvider.getPrivateKey() - val key = PrivateKey.create(storage).apply { - val keyBytes = privateKeyHex.removePrefix("0x").hexToBytes() - importPrivateKey(keyBytes, KeyFormat.RAW) - } + if (currentMnemonic.isNullOrBlank().not()) { + val hdWallet = wallet.core.jni.HDWallet(currentMnemonic, "") + val evmDerivationPath = "m/44'/60'/0'/0/0" + val evmPrivateKey = hdWallet.getKeyByCurve(wallet.core.jni.Curve.SECP256K1, evmDerivationPath) + val evmPublicKey = evmPrivateKey.getPublicKeySecp256k1(false) + val evmAddress = wallet.core.jni.AnyAddress(evmPublicKey, wallet.core.jni.CoinType.ETHEREUM).description() + val jwtHash = Hash.keccak256(firebaseJwt.toByteArray(Charsets.UTF_8)) + val signatureData = evmPrivateKey.sign(jwtHash, wallet.core.jni.Curve.SECP256K1) + val evmSignature = "0x" + signatureData.joinToString("") { "%02x".format(it) } + EvmAccountInfo(eoaAddress = evmAddress, signature = evmSignature) + } else { + val privateKeyHex = cryptoProvider.getPrivateKey() + val key = PrivateKey.create(storage).apply { + val keyBytes = privateKeyHex.removePrefix("0x").hexToBytes() + importPrivateKey(keyBytes, KeyFormat.RAW) + } - // Get secp256k1 public key for EVM address derivation - val evmPublicKeyBytes = key.publicKey(SigningAlgorithm.ECDSA_secp256k1) - if (evmPublicKeyBytes != null) { - // Derive EVM address from public key using Keccak256 - val publicKeyForHash = if (evmPublicKeyBytes.size == 65 && evmPublicKeyBytes[0] == 0x04.toByte()) { - evmPublicKeyBytes.copyOfRange(1, evmPublicKeyBytes.size) + // Get secp256k1 public key for EVM address derivation + val evmPublicKeyBytes = key.publicKey(SigningAlgorithm.ECDSA_secp256k1) + if (evmPublicKeyBytes != null) { + // Derive EVM address from public key using Keccak256 + val publicKeyForHash = if (evmPublicKeyBytes.size == 65 && evmPublicKeyBytes[0] == 0x04.toByte()) { + evmPublicKeyBytes.copyOfRange(1, evmPublicKeyBytes.size) + } else { + evmPublicKeyBytes + } + val addressHash = Hash.keccak256(publicKeyForHash) + val evmAddress = "0x" + addressHash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } + logd("KeyStoreRestoreViewModel", "Derived EVM address: $evmAddress") + + // Sign Firebase JWT for EVM with secp256k1 key + val dataToSign = DomainTag.User.bytes + firebaseJwt.toByteArray(Charsets.UTF_8) + val evmSignatureBytes = key.sign(dataToSign, SigningAlgorithm.ECDSA_secp256k1, HashingAlgorithm.SHA2_256) + val evmSignature = evmSignatureBytes.joinToString("") { "%02x".format(it) } + logd("KeyStoreRestoreViewModel", "Generated EVM signature, length: ${evmSignature.length}") + + EvmAccountInfo( + eoaAddress = evmAddress, + signature = evmSignature + ) } else { - evmPublicKeyBytes + logd("KeyStoreRestoreViewModel", "Could not derive secp256k1 public key, skipping EVM account") + null } - val addressHash = Hash.keccak256(publicKeyForHash) - val evmAddress = "0x" + addressHash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } - logd("KeyStoreRestoreViewModel", "Derived EVM address: $evmAddress") - - // Sign Firebase JWT for EVM with secp256k1 key - val dataToSign = DomainTag.User.bytes + firebaseJwt.toByteArray(Charsets.UTF_8) - val evmSignatureBytes = key.sign(dataToSign, SigningAlgorithm.ECDSA_secp256k1, HashingAlgorithm.SHA2_256) - val evmSignature = evmSignatureBytes.joinToString("") { "%02x".format(it) } - logd("KeyStoreRestoreViewModel", "Generated EVM signature, length: ${evmSignature.length}") - - EvmAccountInfo( - eoaAddress = evmAddress, - signature = evmSignature - ) - } else { - logd("KeyStoreRestoreViewModel", "Could not derive secp256k1 public key, skipping EVM account") - null } + } catch (e: Exception) { logd("KeyStoreRestoreViewModel", "Error creating EVM account info: ${e.message}") null @@ -1329,6 +1368,8 @@ class KeyStoreRestoreViewModel : ViewModel() { ) ) ) + // Persist key material in independent storage + saveKeyToNewStorage(firebaseUid().orEmpty(), keyStoreInfo) MixpanelManager.accountCreated( cryptoProvider.getPublicKey(), AccountCreateKeyType.RESTORE_KEYSTORE, @@ -1341,11 +1382,15 @@ class KeyStoreRestoreViewModel : ViewModel() { } } } - } catch (e: HttpException) { - val errorBody = e.response()?.errorBody()?.string() - logd("KeyStoreRestoreViewModel", "HTTP Error: ${e.code()}, Response: $errorBody") + } catch (e: Exception) { + if (e is HttpException) { + val errorBody = e.response()?.errorBody()?.string() + logd("KeyStoreRestoreViewModel", "HTTP Error: ${e.code()}, Response: $errorBody") + } else { + logd("KeyStoreRestoreViewModel", "Error creating account: ${e.message}") + } callback.invoke(false) - throw e + // Don't rethrow to avoid crashing the coroutine if not handled } } } @@ -1365,6 +1410,31 @@ class KeyStoreRestoreViewModel : ViewModel() { } } + /** + * Write key material to the independent [KeyStorageManager] store. + * Prefers the in-memory mnemonic (seed-phrase restore path); falls back to + * the private key stored in keyStoreInfo (private-key import path). + */ + private fun saveKeyToNewStorage(uid: String, keyStoreInfo: String?) { + if (uid.isBlank()) return + val mnemonic = currentMnemonic + if (!mnemonic.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + return + } + if (!keyStoreInfo.isNullOrBlank()) { + try { + val ks = Gson().fromJson(keyStoreInfo, KeystoreAddress::class.java) + val hex = ks?.privateKey?.removePrefix("0x") + if (!hex.isNullOrBlank()) { + KeyStorageManager.savePrivateKey(uid, hex) + } + } catch (e: Exception) { + logd("KeyStoreRestoreViewModel", "saveKeyToNewStorage: failed to parse keyStoreInfo: ${e.message}") + } + } + } + private fun encryptedMnemonic(uid: String?): String? { val mnemonic = currentMnemonic ?: return null return if (!uid.isNullOrBlank()) { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt index 766c54704..db9893f1c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/RestoreMnemonicViewModel.kt @@ -1,27 +1,44 @@ package com.flowfoundation.wallet.page.restore.mnemonic import androidx.lifecycle.ViewModel +import com.flow.wallet.keys.SeedPhraseKey import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.firstFlowWalletAddress +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.walletdata.WalletDataManager import com.flowfoundation.wallet.page.restore.keystore.model.KeystoreAddress +import com.flowfoundation.wallet.utils.Env.getStorage import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.secret.EncryptedMnemonicUtils +import com.flowfoundation.wallet.wallet.DERIVATION_PATH import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.onflow.flow.models.SigningAlgorithm import wallet.core.jni.HDWallet class RestoreMnemonicViewModel : ViewModel() { + private val TAG = "RestoreMnemonicViewModel" + private val _isRestoring = MutableStateFlow(false) val isRestoring: StateFlow = _isRestoring.asStateFlow() private val _restoreSuccess = MutableStateFlow(false) val restoreSuccess: StateFlow = _restoreSuccess.asStateFlow() + private val _mnemonicMismatch = MutableStateFlow(false) + val mnemonicMismatch: StateFlow = _mnemonicMismatch.asStateFlow() + + fun resetMnemonicMismatch() { + _mnemonicMismatch.value = false + } + + @OptIn(ExperimentalStdlibApi::class) fun restoreMnemonic(mnemonic: String) { if (!validateMnemonic(mnemonic)) { // Should handle validation error in UI @@ -37,11 +54,41 @@ class RestoreMnemonicViewModel : ViewModel() { return@ioScope } + val currentKeyStoreInfo = currentAccount.keyStoreInfo + + // Validate that the mnemonic derives the same public key as stored in keyStoreInfo + if (!currentKeyStoreInfo.isNullOrBlank()) { + try { + val ks = Gson().fromJson(currentKeyStoreInfo, KeystoreAddress::class.java) + val seedPhraseKey = SeedPhraseKey( + mnemonicString = mnemonic, + passphrase = "", + derivationPath = DERIVATION_PATH, + storage = getStorage() + ) + val derivedPubKey = when (ks.signAlgo) { + SigningAlgorithm.ECDSA_secp256k1.cadenceIndex -> + seedPhraseKey.publicKey(SigningAlgorithm.ECDSA_secp256k1)?.toHexString()?.removePrefix("04") + else -> // ECDSA_P256 + seedPhraseKey.publicKey(SigningAlgorithm.ECDSA_P256)?.toHexString()?.removePrefix("04") + } + val storedPubKey = ks.publicKey.removePrefix("0x").lowercase() + if (derivedPubKey == null || !derivedPubKey.equals(storedPubKey, ignoreCase = true)) { + _mnemonicMismatch.value = true + _isRestoring.value = false + return@ioScope + } + } catch (e: Exception) { + loge(TAG, "Failed to validate mnemonic public key: ${e.message}") + _isRestoring.value = false + return@ioScope + } + } + // Encrypt mnemonic val encryptedMnemonic = EncryptedMnemonicUtils.encrypt(mnemonic, uid) // Update Account keystore info - val currentKeyStoreInfo = currentAccount.keyStoreInfo if (!currentKeyStoreInfo.isNullOrBlank()) { try { // Use atomic update to ensure keystoreInfo is updated safely @@ -52,12 +99,28 @@ class RestoreMnemonicViewModel : ViewModel() { val newKeystoreAddress = keystoreAddress.copy(encryptedMnemonic = encryptedMnemonic) account.copy(keyStoreInfo = Gson().toJson(newKeystoreAddress)) } else { - // This case should ideally not happen if keyStoreInfo was set initially. - // But if it does, we return the original account or handle it as an error. account } } - // Clear wallet cache and re-initialize + + // Sync to KeyStorageManager BEFORE clearing wallet cache. + // This ensures createWalletFromAccount() finds the seed phrase (not the + // old private key) when updateCurrentAccount() triggers wallet re-creation, + // preventing a spurious ACTION_RESTORE_MNEMONIC broadcast and ensuring + // setEoaDisabled(false) is called correctly. + try { + KeyStorageManager.saveSeedPhrase(uid, mnemonic) + KeyStorageManager.deletePrivateKey(uid) + val address = AccountManager.get()?.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } catch (e: Exception) { + loge(TAG, "KeyStorageManager sync failed: ${e.message}") + } + + // Clear wallet cache and re-initialize — now finds seed phrase path, + // sets EOA enabled and does NOT re-trigger the restore broadcast. WalletManager.clear() WalletDataManager.updateCurrentAccount() diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt index 2c36eb8f4..cc972c26e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/mnemonic/presenter/RestoreMnemonicScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -53,6 +54,7 @@ fun RestoreMnemonicScreen( ) { val isRestoring by viewModel.isRestoring.collectAsState() val restoreSuccess by viewModel.restoreSuccess.collectAsState() + val mnemonicMismatch by viewModel.mnemonicMismatch.collectAsState() var mnemonic by remember { mutableStateOf("") } var isValid by remember { mutableStateOf(false) } @@ -64,6 +66,7 @@ fun RestoreMnemonicScreen( LaunchedEffect(mnemonic) { isValid = viewModel.validateMnemonic(mnemonic.trim()) + if (mnemonicMismatch) viewModel.resetMnemonicMismatch() } Scaffold( @@ -116,20 +119,29 @@ fun RestoreMnemonicScreen( OutlinedTextField( value = mnemonic, - onValueChange = { mnemonic = it }, + onValueChange = { + mnemonic = it + if (mnemonicMismatch) viewModel.resetMnemonicMismatch() + }, + isError = mnemonicMismatch, + supportingText = if (mnemonicMismatch) { + { Text(stringResource(R.string.restore_mnemonic_mismatch), color = colorResource(id = R.color.error)) } + } else null, modifier = Modifier .fillMaxWidth() .height(200.dp), placeholder = { Text("Enter recovery phrase", color = colorResource(id = R.color.text_3)) }, shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = colorResource(id = R.color.bg_2), // Use bg_2 for input background + focusedContainerColor = colorResource(id = R.color.bg_2), unfocusedContainerColor = colorResource(id = R.color.bg_2), + errorContainerColor = colorResource(id = R.color.bg_2), focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, cursorColor = colorResource(id = R.color.colorSecondary), focusedTextColor = colorResource(id = R.color.text), - unfocusedTextColor = colorResource(id = R.color.text) + unfocusedTextColor = colorResource(id = R.color.text), + errorTextColor = colorResource(id = R.color.text), ) ) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt index 55f260c94..4d7bdf6ca 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/multirestore/viewmodel/MultiRestoreViewModel.kt @@ -51,7 +51,7 @@ import com.flowfoundation.wallet.utils.setMultiBackupCreated import com.flowfoundation.wallet.utils.setRegistered import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.utils.uiScope -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flow.wallet.keys.SeedPhraseKey import com.flow.wallet.storage.FileSystemStorage import com.flowfoundation.wallet.manager.flowjvm.transaction.sendTransactionWithMultiSignature @@ -527,7 +527,7 @@ class MultiRestoreViewModel : ViewModel(), OnTransactionStateChange { reportCadenceErrorToDebugView(scriptId, e) if (e is InvalidKeyException) { ErrorReporter.reportCriticalWithMixpanel(WalletError.QUERY_ACCOUNT_KEY_FAILED, e) - Instabug.show() + Luciq.show() } return null } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt index 2a5676055..92f1d1419 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPrivateKeyActivity.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.databinding.ActivitySecurityPrivateKeyBinding @@ -57,7 +57,7 @@ class SecurityPrivateKeyActivity : BaseActivity() { publicKeyCopyButton.setOnClickListener { copyToClipboard(cryptoProvider.getPublicKey()) } hashAlgorithm.text = getString(R.string.hash_algorithm, cryptoProvider.getHashAlgorithm().algorithm) signAlgorithm.text = getString(R.string.sign_algorithm, cryptoProvider.getSignatureAlgorithm().value) - Instabug.addPrivateViews(privateKeyView) + Luciq.addPrivateViews(privateKeyView) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt index 8c94bcb39..c1d60a9ba 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityPublicKeyActivity.kt @@ -13,7 +13,7 @@ import com.flowfoundation.wallet.utils.extensions.res2String import com.flowfoundation.wallet.utils.isNightMode import com.flowfoundation.wallet.utils.textToClipboard import com.flowfoundation.wallet.utils.toast -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.zackratos.ultimatebarx.ultimatebarx.UltimateBarX class SecurityPublicKeyActivity : BaseActivity() { @@ -51,7 +51,7 @@ class SecurityPublicKeyActivity : BaseActivity() { hashAlgorithm.text = getString(R.string.hash_algorithm, cryptoProvider.getHashAlgorithm().algorithm) signAlgorithm.text = getString(R.string.sign_algorithm, cryptoProvider.getSignatureAlgorithm().value) - Instabug.addPrivateViews(privateKeyView) + Luciq.addPrivateViews(privateKeyView) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt index 9d07c5672..f6ae68ffa 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Bundle import android.view.MenuItem import androidx.recyclerview.widget.GridLayoutManager -import com.instabug.library.Instabug +import ai.luciq.library.Luciq import com.flowfoundation.wallet.R import com.flowfoundation.wallet.base.activity.BaseActivity import com.flowfoundation.wallet.databinding.ActivitySecurityRecoveryBinding @@ -63,7 +63,7 @@ class SecurityRecoveryActivity : BaseActivity() { adapter = this@SecurityRecoveryActivity.adapter layoutManager = GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) addItemDecoration(GridSpaceItemDecoration(vertical = 16.0)) - Instabug.addPrivateViews(this) + Luciq.addPrivateViews(this) } loadMnemonic() } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt index f56f5ab0e..289c933e0 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletFragmentPresenter.kt @@ -84,6 +84,8 @@ class WalletFragmentPresenter( model.data?.let { reportEvent("wallet_coin_list_loaded", mapOf("count" to it.size.toString())) adapter.setNewDiffData(it) + binding.shimmerCoinList.stopShimmer() + binding.shimmerCoinList.gone() binding.refreshLayout.isRefreshing = false bindUserInfo() } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt index 1124d9833..dbc215f8d 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/presenter/WalletHeaderPresenter.kt @@ -3,7 +3,6 @@ package com.flowfoundation.wallet.page.wallet.presenter import android.annotation.SuppressLint import android.view.View import android.widget.TextView -import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider @@ -68,6 +67,8 @@ class WalletHeaderPresenter( ).ifEmpty { "0" } ) ivHide.setImageResource(if (isHideBalance) R.drawable.ic_eye_off else R.drawable.ic_eye_on) + shimmerBalance.stopShimmer() + shimmerBalance.gone() } val count = if (model.coinCount > 0 ) model.coinCount else FungibleTokenListManager.getCurrentDisplayTokenListSnapshot().size @@ -80,6 +81,8 @@ class WalletHeaderPresenter( cvReceive.setOnClickListener { ReactNativeActivity.launch(view.context, RNBridge.ScreenType.RECEIVE) } val address = shortenEVMString(WalletManager.selectedWalletAddress().toAddress()) tvAddress.text = address + shimmerAddress.stopShimmer() + shimmerAddress.gone() ivCopy.setVisible(address.isNotBlank()) ivCopy.setOnClickListener { copyAddress( diff --git a/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt b/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt index 390beabf7..ce22ca6a0 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/walletrestore/Utils.kt @@ -3,10 +3,9 @@ package com.flowfoundation.wallet.page.walletrestore import androidx.annotation.WorkerThread import com.google.firebase.auth.ktx.auth import com.google.firebase.ktx.Firebase -import com.flowfoundation.wallet.firebase.auth.deleteAnonymousUser import com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin import com.flowfoundation.wallet.firebase.auth.getFirebaseJwt -import com.flowfoundation.wallet.firebase.auth.isAnonymousSignIn +import com.flowfoundation.wallet.firebase.auth.setToAnonymous import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.DeviceInfoManager @@ -17,7 +16,6 @@ import com.flowfoundation.wallet.network.clearUserCache import com.flowfoundation.wallet.network.model.AccountKey import com.flowfoundation.wallet.network.model.WalletListData import com.flowfoundation.wallet.network.model.FlowAccountInfo -import com.flowfoundation.wallet.network.model.LoginRequest import com.flowfoundation.wallet.network.model.LoginV4Request import com.flowfoundation.wallet.network.retrofit import com.flowfoundation.wallet.utils.ioScope @@ -30,7 +28,6 @@ import com.flow.wallet.storage.FileSystemStorage import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.utils.Env import com.flowfoundation.wallet.wallet.DERIVATION_PATH -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.io.File @@ -137,6 +134,14 @@ fun requestWalletRestoreLogin( logd(TAG, "HDWalletCryptoProvider created successfully with public key: ${publicKey.take(20)}...") + // Ensure we are in anonymous state before fetching the UID, mirroring iOS behaviour. + // signInWithCustomToken will atomically replace the anonymous user later. + if (!setToAnonymous()) { + loge(TAG, "setToAnonymous failed before wallet restore login") + callback.invoke(false, ERROR_UID) + return@ioScope + } + getFirebaseUid { uid -> if (uid.isNullOrBlank()) { callback.invoke(false, ERROR_UID) @@ -147,16 +152,17 @@ fun requestWalletRestoreLogin( val deviceInfoRequest = DeviceInfoManager.getDeviceInfoRequest() val service = retrofit().create(ApiService::class.java) - // Test signature creation before making the request - val testSignature = try { - val jwt = getFirebaseJwt() + // Fetch JWT once; reuse for both Flow and EVM signatures. + val jwt = getFirebaseJwt() + + val flowSignature = try { cryptoProvider.getUserSignature(jwt) } catch (e: Exception) { loge(TAG, "Failed to create test signature: ${e.message}") throw RuntimeException("Crypto provider signature creation failed: ${e.message}") } - if (testSignature.isBlank()) { + if (flowSignature.isBlank()) { throw RuntimeException("Crypto provider returned empty signature") } @@ -167,9 +173,10 @@ fun requestWalletRestoreLogin( hashAlgo = cryptoProvider.getHashAlgorithm().cadenceIndex, signAlgo = cryptoProvider.getSignatureAlgorithm().cadenceIndex ) + val evmAccountInfo = cryptoProvider.getEvmAccountInfo(jwt) val loginRequest = LoginV4Request( - flowAccountInfo = FlowAccountInfo(accountKey = accountKey, signature = testSignature), - evmAccountInfo = null, // Wallet restore doesn't have mnemonic for EVM + flowAccountInfo = FlowAccountInfo(accountKey = accountKey, signature = flowSignature), + evmAccountInfo = evmAccountInfo, deviceInfo = deviceInfoRequest ) val resp = service.loginV4(loginRequest) @@ -227,43 +234,21 @@ fun requestWalletRestoreLogin( suspend fun firebaseLogin(customToken: String, callback: (isSuccess: Boolean) -> Unit) { logd(TAG, "=== firebaseLogin START ===") logd(TAG, "Custom token received, length: ${customToken.length}") - - val isAnonymous = isAnonymousSignIn() - logd(TAG, "Current Firebase auth state - isAnonymous: $isAnonymous") logd(TAG, "Current user UID: ${Firebase.auth.currentUser?.uid}") - val isSuccess = if (isAnonymous) { - logd(TAG, "Attempting to delete anonymous user") - val deleteResult = deleteAnonymousUser() - logd(TAG, "Delete anonymous user result: $deleteResult") - deleteResult - } else { - logd(TAG, "Signing out existing Firebase user") - Firebase.auth.signOut() - logd(TAG, "Firebase sign out completed") - true - } - - if (isSuccess) { - logd(TAG, "Auth cleanup successful, waiting 1 second before custom login") - // Add a delay to ensure Firebase auth state is cleared - delay(1000) - logd(TAG, "Starting Firebase custom login with token") - firebaseCustomLogin(customToken) { isSuccessful, errorMsg -> - logd(TAG, "Firebase custom login completed - success: $isSuccessful, error: $errorMsg") - if (isSuccessful) { - logd(TAG, "Firebase login successful, identifying user profile with Mixpanel") - MixpanelManager.identifyUserProfile() - logd(TAG, "Calling success callback") - callback(true) - } else { - logd(TAG, "ERROR: Firebase custom login failed - $errorMsg") - callback(false) - } + // signInWithCustomToken atomically replaces the current user (anonymous or otherwise) + // without going through a null state. Prior delete/signOut calls created a null window + // that raced with background getFirebaseJwt() → signInAnonymously() calls. + firebaseCustomLogin(customToken) { isSuccessful, errorMsg -> + logd(TAG, "Firebase custom login completed - success: $isSuccessful, error: $errorMsg") + if (isSuccessful) { + logd(TAG, "Firebase login successful, new UID: ${Firebase.auth.currentUser?.uid}") + MixpanelManager.identifyUserProfile() + callback(true) + } else { + logd(TAG, "ERROR: Firebase custom login failed - $errorMsg") + callback(false) } - } else { - logd(TAG, "ERROR: Auth cleanup failed, calling failure callback") - callback(false) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt index cc375175b..c3ad87402 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt @@ -2,6 +2,11 @@ package com.flowfoundation.wallet.reactnative import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate @@ -86,9 +91,21 @@ class ReactNativeActivity : ReactActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Opt into edge-to-edge. On API 35+ this is enforced by the system; on older APIs + // we set it explicitly so the behavior is consistent across all versions. + WindowCompat.setDecorFitsSystemWindows(window, false) logd(TAG, "onCreate called") super.onCreate(savedInstanceState) + // ReactActivity doesn't extend BaseActivity, so we apply nav bar insets here directly. + // This pads android.R.id.content so the React Native root view doesn't go behind the nav bar. + val contentView = window.decorView.findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> + val navBar = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + contentView?.updatePadding(bottom = navBar.bottom) + insets + } + // Log the intent extras for debugging intent?.let { logd(TAG, "Intent extras:") @@ -106,6 +123,14 @@ class ReactNativeActivity : ReactActivity() { setIntent(intent) } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // Re-trigger insets dispatch once the window is visible and has valid insets. + if (hasFocus) { + ViewCompat.requestApplyInsets(window.decorView) + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt index 092e6fa6f..d40a14ccb 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt @@ -22,6 +22,7 @@ import org.json.JSONObject import org.json.JSONArray import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.firstFlowWalletAddress import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.logw @@ -51,6 +52,7 @@ import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.generatePrefix import org.onflow.flow.infrastructure.Cadence.Companion.uint8 import com.facebook.react.modules.core.DeviceEventManagerModule +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.reactnative.bridge.handlers.AccountBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.AuthBridgeHandler import com.flowfoundation.wallet.reactnative.bridge.handlers.UIBridgeHandler @@ -160,6 +162,16 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // Update and store the mnemonic in Wallet Wallet.store().updateMnemonic(seedPhrase).store() + // Also persist in independent key storage so it survives account-cache loss + val uid = firebaseUid() ?: AccountManager.get()?.wallet?.id + if (!uid.isNullOrBlank()) { + KeyStorageManager.saveSeedPhrase(uid, seedPhrase) + val address = AccountManager.get()?.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } + logd(TAG, "saveNewKey() - Seed phrase saved successfully") uiScope { promise.resolve(null) @@ -202,6 +214,11 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // AccountManager.add(account) will update the list and cache it AccountManager.add(account) + // Delete any pkStorage entry that KeyStorageMigration (Case 2) may have created + if (!uid.isNullOrBlank()) { + KeyStorageManager.deletePrivateKey(uid) + } + // 3. Clear CryptoProvider CryptoProviderManager.clear() @@ -368,9 +385,30 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp // 4. Update local account state with new prefix logd(TAG, "keystoreMigration() - updating local account state") + val uid = firebaseUid() ?: account.wallet?.id + val oldPrefix = account.prefix account.prefix = newPrefix AccountManager.add(account) + // Sync new prefix to independent AKP storage + if (!uid.isNullOrBlank()) { + KeyStorageManager.saveAndroidKeystorePrefix(uid, newPrefix) + val address = account.firstFlowWalletAddress() + if (!address.isNullOrBlank()) { + KeyStorageManager.saveWalletAddress(uid, address) + } + } + + // Remove the stale file-private-key entry that KeyCompatibilityManager may find + if (!oldPrefix.isNullOrBlank()) { + try { + getStorage().remove("prefix_key_$oldPrefix") + logd(TAG, "Removed old prefix key from shared storage: prefix_key_$oldPrefix") + } catch (e: Exception) { + loge(TAG, "Failed to remove old prefix key: ${e.message}") + } + } + // 5. Reload CryptoProvider logd(TAG, "keystoreMigration() - reloading crypto provider") CryptoProviderManager.clear() diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt index cba853f59..de8b07f4e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AccountBridgeHandler.kt @@ -533,11 +533,12 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { switchList.filterIsInstance().forEach { localAccount -> logd(TAG, "getRecoverableProfiles() - processing LocalSwitchAccount: ${localAccount.username}") - val mainEmojiInfo = createEmojiInfo(localAccount.address) + val displayAddress = localAccount.address.ifBlank { null } + val mainEmojiInfo = if (displayAddress != null) createEmojiInfo(displayAddress) else null val mainAccount = RNBridge.WalletAccount( - id = "main_${localAccount.address}", + id = "main_${displayAddress ?: localAccount.userId ?: localAccount.username}", name = mainEmojiInfo?.name ?: localAccount.username, - address = localAccount.address, + address = displayAddress ?: "", emojiInfo = mainEmojiInfo, parentEmoji = null, parentAddress = null, @@ -581,26 +582,35 @@ class AccountBridgeHandler(private val reactContext: ReactApplicationContext) { logd(TAG, "switchToProfile() called with userId: $userId") ioScope { try { - // Find the account with the matching userId (wallet id) - val accounts = AccountManager.list() - val targetAccount = accounts.find { it.wallet?.id == userId } - - if (targetAccount == null) { - logw(TAG, "switchToProfile() - account not found for userId: $userId") - uiScope { - promise.reject("PROFILE_NOT_FOUND", "Account not found for userId: $userId") + // 1. Check normal logged-in accounts first + val targetAccount = AccountManager.list().find { it.wallet?.id == userId } + if (targetAccount != null) { + logd(TAG, "switchToProfile() - found normal account: ${targetAccount.userInfo.username}") + AccountManager.switch(targetAccount) { + logd(TAG, "switchToProfile() - switch completed for userId: $userId") + uiScope { promise.resolve(null) } } return@ioScope } - logd(TAG, "switchToProfile() - found account: ${targetAccount.userInfo.username}") - - // Switch to the account - AccountManager.switch(targetAccount) { - logd(TAG, "switchToProfile() - switch completed for userId: $userId") - uiScope { - promise.resolve(null) // Success - resolve with no value + // 2. Not a normal account — check LocalSwitchAccount list (orphan keys) + // These are accounts with key material but no account cache entry. + val localAccount = AccountManager.getSwitchAccountList() + .filterIsInstance() + .find { it.userId == userId } + + if (localAccount != null) { + logd(TAG, "switchToProfile() - found LocalSwitchAccount for userId: $userId") + AccountManager.switch(localAccount) { + logd(TAG, "switchToProfile() - LocalSwitchAccount switch completed for userId: $userId") + uiScope { promise.resolve(null) } } + return@ioScope + } + + logw(TAG, "switchToProfile() - account not found for userId: $userId") + uiScope { + promise.reject("PROFILE_NOT_FOUND", "Account not found for userId: $userId") } } catch (e: Exception) { loge(TAG, "switchToProfile() - error: ${e.message}") diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt index 5f025e7a0..1ef11d806 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/AuthBridgeHandler.kt @@ -17,6 +17,7 @@ import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flowfoundation.wallet.manager.key.storage.KeyStorageManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.walletdata.EOAWallet import com.flowfoundation.wallet.manager.walletdata.FlowWallet @@ -354,53 +355,35 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { try { com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> if (isSuccessful) { - logd(TAG, "signInWithCustomToken() - Custom token authentication successful, waiting for JWT...") - // Wait for JWT to be available after sign-in - // This prevents race conditions where API calls happen before token propagates + // firebaseCustomLogin already calls getIdToken(true) internally before invoking this callback, + // so the Firebase ID token is already refreshed at this point. + // Calling getFirebaseJwt(forceRefresh=true) repeatedly here would trigger App Check rate limiting. + logd(TAG, "signInWithCustomToken() - Custom token authentication successful, validating with backend...") ioScope { - var tokenReady = false var backendValidated = false var attempts = 0 - val maxAttempts = 15 + val maxAttempts = 5 - while (!tokenReady && attempts < maxAttempts) { + while (!backendValidated && attempts < maxAttempts) { attempts++ try { - val jwt = getFirebaseJwt(forceRefresh = true) - val firebaseUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - - if (jwt.isNotBlank() && firebaseUid != null) { - logd(TAG, "signInWithCustomToken() - JWT ready after $attempts attempt(s), Firebase UID: $firebaseUid") - - // Validate with backend - make sure the user is recognized - try { - val service = com.flowfoundation.wallet.network.retrofit() - .create(com.flowfoundation.wallet.network.ApiService::class.java) - val userInfo = service.userInfo().data - logd(TAG, "signInWithCustomToken() - Backend validated, username: ${userInfo.username}") - tokenReady = true - backendValidated = true - } catch (apiError: Exception) { - logd(TAG, "signInWithCustomToken() - Backend validation failed on attempt $attempts: ${apiError.message}") - // Backend might not be ready yet, continue waiting - kotlinx.coroutines.delay(500) - } - } else { - logd(TAG, "signInWithCustomToken() - JWT not ready, attempt $attempts/$maxAttempts") - kotlinx.coroutines.delay(300) - } - } catch (e: Exception) { - logd(TAG, "signInWithCustomToken() - JWT check error on attempt $attempts: ${e.message}") - kotlinx.coroutines.delay(300) + val service = com.flowfoundation.wallet.network.retrofit() + .create(com.flowfoundation.wallet.network.ApiService::class.java) + val userInfo = service.userInfo().data + logd(TAG, "signInWithCustomToken() - Backend validated on attempt $attempts, username: ${userInfo.username}") + backendValidated = true + } catch (apiError: Exception) { + logd(TAG, "signInWithCustomToken() - Backend validation failed on attempt $attempts: ${apiError.message}") + kotlinx.coroutines.delay(1000) } } - if (tokenReady && backendValidated) { + if (backendValidated) { uiScope { promise.resolve(null) } } else { - loge(TAG, "signInWithCustomToken() - Auth not ready after $maxAttempts attempts (tokenReady=$tokenReady, backendValidated=$backendValidated)") + loge(TAG, "signInWithCustomToken() - Backend validation failed after $maxAttempts attempts") uiScope { promise.reject("CUSTOM_TOKEN_AUTH_ERROR", "Authentication succeeded but backend validation failed", null) } @@ -446,57 +429,15 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { onSuccess = { ioScope { try { - // Force Firebase ID token refresh to get the new account's JWT - // This ensures API requests use the new account's credentials - logd(TAG, "saveMnemonic() - Forcing Firebase ID token refresh...") - var tokenRefreshed = false - var refreshAttempts = 0 - val maxRefreshAttempts = 10 - - while (!tokenRefreshed && refreshAttempts < maxRefreshAttempts) { - kotlinx.coroutines.delay(500) // Wait 500ms between checks - refreshAttempts++ - try { - // Force refresh the token - val jwt = getFirebaseJwt(forceRefresh = true) - val currentUid = com.flowfoundation.wallet.firebase.auth.firebaseUid() - - if (jwt.isNotBlank() && currentUid != null) { - tokenRefreshed = true - logd(TAG, "saveMnemonic() - Firebase ID token refreshed after $refreshAttempts attempt(s), UID: $currentUid") - - // Verify the username matches by making a test API call - try { - val testService = com.flowfoundation.wallet.network.retrofit() - .create(com.flowfoundation.wallet.network.ApiService::class.java) - val testUserInfo = testService.userInfo().data - logd(TAG, "saveMnemonic() - Token validated, backend returned username: ${testUserInfo.username}") - - // Check if username matches (case-insensitive, ignoring numeric suffix) - // Backend normalizes to lowercase and adds suffix: "FancyRiverVolcano" -> "fancyrivervolcano_476" - val backendUsernameBase = testUserInfo.username.substringBefore("_").lowercase() - val expectedUsernameBase = username.lowercase() - - if (backendUsernameBase != expectedUsernameBase) { - logw(TAG, "saveMnemonic() - Username mismatch! Expected: $expectedUsernameBase, Got: $backendUsernameBase. Retrying...") - tokenRefreshed = false // Retry - } else { - logd(TAG, "saveMnemonic() - Username validated: $expectedUsernameBase matches $backendUsernameBase") - } - } catch (e: Exception) { - logw(TAG, "saveMnemonic() - Could not validate token with backend, continuing: ${e.message}") - } - } else { - logd(TAG, "saveMnemonic() - Waiting for token refresh (attempt $refreshAttempts/$maxRefreshAttempts)") - } - } catch (e: Exception) { - logd(TAG, "saveMnemonic() - Error during token refresh (attempt $refreshAttempts): ${e.message}") - } - } - - if (!tokenRefreshed) { - logw(TAG, "saveMnemonic() - Warning: Token may not be for correct user, proceeding anyway") + // authenticateWithFirebase -> firebaseCustomLogin already calls getIdToken(true) before + // invoking this callback, so the token is already refreshed. Calling + // getFirebaseJwt(forceRefresh=true) in a loop here would trigger App Check rate limiting. + val currentFirebaseUser = Firebase.auth.currentUser + val currentUid = currentFirebaseUser?.uid + if (currentFirebaseUser == null || currentFirebaseUser.isAnonymous) { + throw IllegalStateException("Firebase auth failed: user is still anonymous after custom token login") } + logd(TAG, "saveMnemonic() - Firebase authenticated, UID: $currentUid") // Fetch user info from backend val service = com.flowfoundation.wallet.network.retrofit() @@ -550,6 +491,8 @@ class AuthBridgeHandler(private val reactContext: ReactApplicationContext) { throw IllegalStateException("Failed to store mnemonic for userId: $userId") } logd(TAG, "saveMnemonic() - Mnemonic stored successfully via AccountWalletManager") + // Also persist in independent key storage so it survives account-cache loss + KeyStorageManager.saveSeedPhrase(userId, mnemonic) // Account discovery is now handled by React Native layer // React Native will handle wallet initialization after Flow address is created @@ -674,45 +617,45 @@ private fun authenticateWithFirebase( ) { logd(TAG, "authenticateWithFirebase() - Checking current Firebase auth state...") + // getV4RegistrationSignatures() ensures anonymous sign-in before saveMnemonic() is called, + // so currentUser is always non-null here. Mirror the registerFirebase() pattern in UserRegisterUtils. val currentUser = Firebase.auth.currentUser - val currentUid = currentUser?.uid - val isAnonymous = currentUser?.isAnonymous ?: true - - if (currentUser != null) { - logd(TAG, "authenticateWithFirebase() - Current user: UID=$currentUid, isAnonymous=$isAnonymous") + logd(TAG, "authenticateWithFirebase() - Current user: UID=${currentUser?.uid}, isAnonymous=${currentUser?.isAnonymous}") + + fun doLogin() { + logd(TAG, "authenticateWithFirebase() - Signing in with new custom token...") + com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> + if (isSuccessful) { + logd(TAG, "authenticateWithFirebase() - Firebase authentication successful, new UID: ${Firebase.auth.currentUser?.uid}") + onSuccess() + } else { + val errorMessage = exception?.message ?: "Firebase authentication failed" + loge(TAG, "authenticateWithFirebase() - Failed: $errorMessage") + onFailure(errorMessage) + } + } } - // If already authenticated with a non-anonymous user, we MUST sign out first - // to switch to the new account. Firebase won't switch users without signing out. - if (currentUser != null && !isAnonymous) { - logd(TAG, "authenticateWithFirebase() - Signing out current user to switch accounts...") - - // Sign out the current user - Firebase.auth.signOut() - logd(TAG, "authenticateWithFirebase() - User signed out successfully") - - // Delete Firebase messaging token for the old user + if (currentUser == null || !currentUser.isAnonymous) { + // No user, or non-anonymous user: signOut() is synchronous, safe to call directly before login + if (currentUser != null) { + logd(TAG, "authenticateWithFirebase() - Signing out non-anonymous user before login...") + Firebase.auth.signOut() + } com.google.firebase.messaging.FirebaseMessaging.getInstance().deleteToken() - } else if (isAnonymous) { - // Delete anonymous user + doLogin() + } else { + // Anonymous user: await delete() before signing in - same pattern as registerFirebase() + // delete() is async; calling signInWithCustomToken without waiting would race with it + logd(TAG, "authenticateWithFirebase() - Deleting anonymous user before login...") com.google.firebase.messaging.FirebaseMessaging.getInstance().deleteToken() - currentUser?.delete()?.addOnCompleteListener { - logd(TAG, "authenticateWithFirebase() - Previous anonymous user deleted") - } - } - - logd(TAG, "authenticateWithFirebase() - Signing in with new custom token...") - - // Sign in with the new account's custom token - com.flowfoundation.wallet.firebase.auth.firebaseCustomLogin(customToken) { isSuccessful, exception -> - if (isSuccessful) { - val newUid = Firebase.auth.currentUser?.uid - logd(TAG, "authenticateWithFirebase() - Firebase authentication successful, new UID: $newUid") - onSuccess() - } else { - val errorMessage = exception?.message ?: "Firebase authentication failed" - loge(TAG, "authenticateWithFirebase() - Failed: $errorMessage") - onFailure(errorMessage) + currentUser.delete().addOnCompleteListener { task -> + logd(TAG, "authenticateWithFirebase() - Anonymous user delete finished, exception: ${task.exception}") + if (task.isSuccessful) { + doLogin() + } else { + onFailure("Failed to delete anonymous user: ${task.exception?.message}") + } } } } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt index 059c93622..78d2c350c 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/handlers/UtilsBridgeHandler.kt @@ -210,7 +210,7 @@ class UtilsBridgeHandler(private val reactContext: ReactApplicationContext) { args.getString(i) ?: "" } - // Delegate to the centralized Instabug logging system in Log.kt + // Delegate to the centralized Luciq logging system in Log.kt logToInstabug(level, message, *stringArgs) } catch (e: Exception) { // Fallback with just the message if args conversion fails diff --git a/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt b/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt index 664ef1dbb..9c6ab6cf5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt +++ b/app/src/main/java/com/flowfoundation/wallet/service/MessagingService.kt @@ -8,7 +8,7 @@ import com.flowfoundation.wallet.firebase.messaging.uploadPushToken import com.flowfoundation.wallet.utils.ioScope import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.updatePushToken -import com.instabug.chat.Replies +import ai.luciq.chat.Replies class MessagingService : FirebaseMessagingService() { @@ -29,7 +29,7 @@ class MessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { logd(TAG, "receive new firebase message:$message") parseFirebaseMessaging(message) - if (Replies.isInstabugNotification(message.getData())) { + if (Replies.isLuciqNotification(message.getData())) { Replies.showNotification(message.getData()) } } diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt b/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt index ff09101fd..bba3038c4 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/Log.kt @@ -5,7 +5,7 @@ import com.flowfoundation.wallet.BuildConfig import com.flowfoundation.wallet.firebase.analytics.reportErrorToDebugView import com.flowfoundation.wallet.firebase.analytics.reportException import com.flowfoundation.wallet.utils.debug.fragments.debugViewer.DebugViewerDataSource -import com.instabug.library.logging.InstabugLog +import ai.luciq.library.logging.LuciqLog import com.nftco.flow.sdk.FlowException import retrofit2.HttpException @@ -13,19 +13,19 @@ private const val MAX_LOG_LENGTH = 3500 private const val LOG_PREFIX = "[" private const val LOG_SUFFIX = "]" -fun logv(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.VERBOSE, InstabugLog::v) -fun logd(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.DEBUG, InstabugLog::d) -fun logi(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.INFO, InstabugLog::i) -fun logw(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.WARN, InstabugLog::w) +fun logv(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.VERBOSE, LuciqLog::v) +fun logd(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.DEBUG, LuciqLog::d) +fun logi(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.INFO, LuciqLog::i) +fun logw(tag: String?, msg: Any?) = logWithLevel(tag, msg, Log.WARN, LuciqLog::w) fun loge(tag: String?, msg: Any?) { - logWithLevel(tag, msg, Log.ERROR, InstabugLog::e) + logWithLevel(tag, msg, Log.ERROR, LuciqLog::e) reportErrorToDebugView(tag, mapOf("errorInfo" to (msg?.toString() ?: ""))) } fun loge(throwable: Throwable?, printStackTrace: Boolean = true, report: Boolean = true) { val message = throwable?.message ?: "" log("Exception", message, Log.ERROR) - InstabugLog.e("Exception: $message : ${throwable?.cause ?: ""}") + LuciqLog.e("Exception: $message : ${throwable?.cause ?: ""}") if (printLog() && printStackTrace) { throwable?.printStackTrace() @@ -102,7 +102,7 @@ private fun printLog() = BuildConfig.DEBUG || isDev() /** * Native logging method for React Native bridge callback - * Directly reports to Instabug for reliable logging + * Directly reports to Luciq for reliable logging */ fun logToInstabug(level: String, message: String, vararg args: String) { try { @@ -119,23 +119,23 @@ fun logToInstabug(level: String, message: String, vararg args: String) { // Convert string level to Android Log level constant for DebugViewerDataSource val logLevel = when (level.lowercase()) { "debug" -> { - InstabugLog.d(taggedMessage) + LuciqLog.d(taggedMessage) Log.DEBUG } "info" -> { - InstabugLog.i(taggedMessage) + LuciqLog.i(taggedMessage) Log.INFO } "warn" -> { - InstabugLog.w(taggedMessage) + LuciqLog.w(taggedMessage) Log.WARN } "error" -> { - InstabugLog.e(taggedMessage) + LuciqLog.e(taggedMessage) Log.ERROR } else -> { - InstabugLog.i(taggedMessage) + LuciqLog.i(taggedMessage) Log.INFO } } @@ -143,7 +143,7 @@ fun logToInstabug(level: String, message: String, vararg args: String) { // Also log to DebugViewer with correct level type DebugViewerDataSource.log(logLevel, "FRW-Native", taggedMessage) } catch (e: Exception) { - // Fallback to direct Instabug error report to avoid recursion - InstabugLog.e("[FRW-Native] Error in logToNative: ${e.message}") + // Fallback to direct Luciq error report to avoid recursion + LuciqLog.e("[FRW-Native] Error in logToNative: ${e.message}") } } diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt b/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt index 528a94865..0ea831e19 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/PreferenceUtils.kt @@ -37,6 +37,7 @@ private val KEY_HIDE_WALLET_BALANCE = booleanPreferencesKey("KEY_HIDE_WALLET_BAL private val KEY_FREE_GAS_ENABLE = booleanPreferencesKey("KEY_FREE_GAS_ENABLE") private val KEY_WRAP_EOA_TX_WITH_CADENCE = booleanPreferencesKey("KEY_WRAP_EOA_TX_WITH_CADENCE") +private val KEY_HIDE_COA_WITH_ZERO_BALANCE = booleanPreferencesKey("KEY_HIDE_COA_WITH_ZERO_BALANCE") private const val KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED = "KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED" private val KEY_IS_MEOW_DOMAIN_CLAIMED = booleanPreferencesKey("KEY_IS_MEOW_DOMAIN_CLAIMED") @@ -163,7 +164,12 @@ suspend fun isWrapEOATxWithCadenceEnable(): Boolean = dataStore.data.map { it[KE fun setWrapEOATxWithCadenceEnable(isWrap: Boolean) { edit { dataStore.edit { it[KEY_WRAP_EOA_TX_WITH_CADENCE] = isWrap } } } +suspend fun isHideCOAWithZeroBalanceEnable(): Boolean = + dataStore.data.map { it[KEY_HIDE_COA_WITH_ZERO_BALANCE] ?: true }.first() +fun setHideCOAWithZeroBalanceEnable(isHide: Boolean) { + edit { dataStore.edit { it[KEY_HIDE_COA_WITH_ZERO_BALANCE] = isHide } } +} fun isStakingGuideDisplayed(): Boolean { return sharedPreferencesTraditional.getBoolean(KEY_IS_STAKING_GUIDE_PAGE_DISPLAYED, false) diff --git a/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt b/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt index d3ff766b5..66cd8bec6 100644 --- a/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/utils/error/ErrorReporter.kt @@ -5,23 +5,23 @@ import com.flowfoundation.wallet.manager.transaction.TransactionStateManager import com.flowfoundation.wallet.mixpanel.MixpanelManager import com.flowfoundation.wallet.utils.getCurrentCodeLocation import com.flowfoundation.wallet.utils.getLocationInfo -import com.instabug.crash.CrashReporting -import com.instabug.crash.models.IBGNonFatalException -import com.instabug.library.Instabug +import ai.luciq.crash.CrashReporting +import ai.luciq.crash.models.LuciqNonFatalException +import ai.luciq.library.Luciq import org.onflow.flow.infrastructure.CadenceErrorCode object ErrorReporter { private fun report(error: BaseError, cause: Throwable? = null) { - IBGNonFatalException.Builder(CustomException(error, cause)) - .setLevel(IBGNonFatalException.Level.ERROR) + LuciqNonFatalException.Builder(CustomException(error, cause)) + .setLevel(LuciqNonFatalException.Level.ERROR) .build() .let { exception -> CrashReporting.report(exception)} } fun reportMoveAssetsError(locationInfo: String) { reportWithMixpanel(MoveError.FAILED_TO_SUBMIT_TRANSACTION, locationInfo) - Instabug.show() + Luciq.show() } fun reportWithMixpanel(error: BaseError, locationInfo: String) { @@ -40,8 +40,8 @@ object ErrorReporter { fun reportCriticalWithMixpanel(error: BaseError, cause: Throwable? = null) { val throwable = cause ?: CustomException(error) - IBGNonFatalException.Builder(throwable) - .setLevel(IBGNonFatalException.Level.CRITICAL) + LuciqNonFatalException.Builder(throwable) + .setLevel(LuciqNonFatalException.Level.CRITICAL) .build() .let { exception -> CrashReporting.report(exception)} MixpanelManager.error(error, throwable.getLocationInfo(), throwable.message) @@ -54,8 +54,8 @@ object ErrorReporter { // Map CadenceErrorCode to WalletError for consistent error reporting val walletError = WalletError(errorCode, cadenceError.name) val cause = CustomTransactionException(walletError, errorMessage) - IBGNonFatalException.Builder(cause) - .setLevel(IBGNonFatalException.Level.ERROR) + LuciqNonFatalException.Builder(cause) + .setLevel(LuciqNonFatalException.Level.ERROR) .setFingerprint("$scriptId.$errorCode") .build() .let { exception -> CrashReporting.report(exception)} diff --git a/app/src/main/res/layout/activity_developer_mode_setting.xml b/app/src/main/res/layout/activity_developer_mode_setting.xml index aea133ece..8416522fd 100644 --- a/app/src/main/res/layout/activity_developer_mode_setting.xml +++ b/app/src/main/res/layout/activity_developer_mode_setting.xml @@ -196,7 +196,32 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:iconEnable="false" - app:titleId="@string/debug_view"/> + app:titleId="@string/wrap_eoa_tx_with_cadence"/> + + + + + + + + + diff --git a/app/src/main/res/layout/activity_webview.xml b/app/src/main/res/layout/activity_webview.xml index f4c09c666..96f4b4a30 100644 --- a/app/src/main/res/layout/activity_webview.xml +++ b/app/src/main/res/layout/activity_webview.xml @@ -1,10 +1,17 @@ - \ No newline at end of file + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/layout/fragment_coordinator_wallet.xml b/app/src/main/res/layout/fragment_coordinator_wallet.xml index 0102ab8f3..fc4975a30 100644 --- a/app/src/main/res/layout/fragment_coordinator_wallet.xml +++ b/app/src/main/res/layout/fragment_coordinator_wallet.xml @@ -166,6 +166,10 @@ android:id="@+id/wallet_header" layout="@layout/layout_wallet_coordinator_header"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_wallet_coordinator_header.xml b/app/src/main/res/layout/layout_wallet_coordinator_header.xml index 091cbf9ed..a806bef0d 100644 --- a/app/src/main/res/layout/layout_wallet_coordinator_header.xml +++ b/app/src/main/res/layout/layout_wallet_coordinator_header.xml @@ -73,6 +73,41 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/tv_address" app:layout_constraintBottom_toBottomOf="@id/tv_address"/> + + + + + + + + @@ -330,4 +365,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 372bebd54..8be83dc35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,7 @@ created your previous wallet. Sign in with \nRecovery Phrase Enter the Recovery Phrase + Recovery phrase does not match this account Account not Found No account found with the recovery phrase. Do you want to create a new account with your phrase? your phrase @@ -653,6 +654,7 @@ Please try again later. Debug View Wrap EOA TX with Cadence + Hide COA With Zero Balance Export Log View Recovery Phrase Clear diff --git a/build.gradle b/build.gradle index 7860e5c0a..0fe9f72bb 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ buildscript { classpath 'com.google.gms:google-services:4.4.2' classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' - classpath 'com.instabug.library:instabug-plugin:12.0.0' + classpath 'ai.luciq.library:luciq-plugin:19.3.0' classpath 'com.google.firebase:firebase-appdistribution-gradle:5.0.0' // Google Play Publisher plugin for uploading AAB to Play Console classpath 'com.github.triplet.gradle:play-publisher:3.10.1' diff --git a/gradle.properties b/gradle.properties index 695617573..c7b150426 100644 --- a/gradle.properties +++ b/gradle.properties @@ -64,7 +64,7 @@ android.r8.dexing.parallel=true #android.bundle.enableUncompressedNativeLibs=false #android.experimental.enableArtProfiles=true -vCode=333 +vCode=400000465 vName=r3.1.0 # --- GitHub Packages (Flow Wallet Kit) ---