Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -84,21 +85,25 @@
<activity
android:name=".manager.drive.GoogleDriveAuthActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen"/>

<activity
android:name=".manager.dropbox.DropboxAuthActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen"/>

<activity
android:name=".page.walletrestore.WalletRestoreActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"/>

<activity
android:name=".page.wallet.sync.WalletSyncActivity"
android:launchMode="singleTop"/>
android:launchMode="singleTop"
android:screenOrientation="portrait"/>

<activity
android:name=".page.wallet.confirm.WalletConfirmActivity"
Expand All @@ -111,7 +116,8 @@
android:name=".page.collection.CollectionActivity"
android:screenOrientation="portrait"/>
<activity
android:name=".page.nft.search.NFTSearchActivity"/>
android:name=".page.nft.search.NFTSearchActivity"
android:screenOrientation="portrait"/>

<activity
android:name=".page.profile.subpage.accountsetting.AccountSettingActivity"
Expand Down Expand Up @@ -277,11 +283,13 @@
<activity
android:name=".page.browser.subpage.filepicker.FilePickerActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen"/>

<activity
android:name=".page.security.biometric.BiometricActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen"/>

<activity
Expand Down Expand Up @@ -336,6 +344,7 @@
android:name=".page.component.deeplinking.DeepLinkingActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
Expand Down Expand Up @@ -412,6 +421,7 @@
<activity
android:name=".page.common.NotificationDispatchActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.Transparent.FullScreen"/>

<activity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.flowfoundation.wallet.base.activity

import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.BaseContextWrappingDelegate
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import com.flowfoundation.wallet.manager.app.ActivityManager
import java.lang.ref.WeakReference

Expand All @@ -11,10 +16,60 @@ open class BaseActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
currentActivity = WeakReference(this)
ActivityManager.setCurrentActivity(this) // Register with ActivityManager
ActivityManager.setCurrentActivity(this)
// Opt into edge-to-edge early. On API 35+ this is enforced by the system anyway.
// On API 28-34 this is needed so our insets listener is the sole source of nav bar padding.
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
}

override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// UltimateBarX fitWindow(true) calls in subclass onCreate may set
// setDecorFitsSystemWindows(true) after our onCreate. Reset it here so our
// insets listener is always in control of navigation bar padding.
WindowCompat.setDecorFitsSystemWindows(window, false)
applyNavigationBarInsets()
}

/**
* Override to return true when the activity/layout handles nav bar insets itself
* (e.g., via a direct ViewCompat.setOnApplyWindowInsetsListener on the root view).
* BaseActivity will skip its android.R.id.content padding to prevent double-padding.
*/
protected open fun handlesInsetsNatively(): Boolean = false

/**
* Adds bottom padding equal to the navigation bar height on android.R.id.content.
* Listener is attached to window.decorView — the top of the view hierarchy — so it
* receives insets before AppCompat's internal FitWindowsLinearLayout can consume them.
* Works for both 3-button nav (full bar height) and gesture nav (indicator height or 0).
* Status bar is intentionally excluded — handled by existing UltimateBarX calls per Activity.
*/
private fun applyNavigationBarInsets() {
if (handlesInsetsNatively()) return
val contentView = window.decorView.findViewById<View>(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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Account>? {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}")
}
Expand Down Expand Up @@ -127,61 +137,37 @@ object AccountCacheManager{
}

fun cache(data: List<Account>) {
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())
logd(TAG, "Created backup copy of validated account cache")
} 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<Account>) {
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() {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,4 +32,4 @@ private fun setupAppCheck() {
logd(TAG, "AppCheck token: $token")
}
}
}
}
Loading
Loading