diff --git a/app/src/main/java/one/monero/moneroone/core/wallet/WalletViewModel.kt b/app/src/main/java/one/monero/moneroone/core/wallet/WalletViewModel.kt index d921bae..4424da0 100644 --- a/app/src/main/java/one/monero/moneroone/core/wallet/WalletViewModel.kt +++ b/app/src/main/java/one/monero/moneroone/core/wallet/WalletViewModel.kt @@ -66,6 +66,28 @@ sealed class SendState { data class Error(val message: String) : SendState() } +// Parse the widget tx cache (dir|amount|timestamp|hash|fee|blockheight|confirmations;...) +// back into TransactionInfo for cold-start seeding. Kit's live emissions overwrite these. +// Legacy entries missing the fee/blockheight/confirmations fields are skipped — the next +// emit rewrites the cache with all seven fields, so the list only stays empty for one boot. +internal fun parseCachedTxs(cache: String): List = + cache.split(";") + .filter { it.isNotBlank() } + .mapNotNull { entry -> + val parts = entry.split("|") + if (parts.size < 7) return@mapNotNull null + val dir = if (parts[0] == "in") 0 else 1 + val amount = parts[1].toLongOrNull() ?: return@mapNotNull null + val timestamp = parts[2].toLongOrNull() ?: return@mapNotNull null + val fee = parts[4].toLongOrNull() ?: 0L + val blockheight = parts[5].toLongOrNull() ?: 0L + val confirmations = parts[6].toLongOrNull() ?: 0L + TransactionInfo( + dir, blockheight == 0L, false, amount, fee, blockheight, parts[3], + timestamp, "", 0, 0, confirmations, 0, "", null + ) + } + class WalletViewModel(application: Application) : AndroidViewModel(application) { private val context = application.applicationContext @@ -119,6 +141,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) // Defer price fetch until a wallet exists. Avoids leaking IP to // monero.one before the user has generated/restored a key. if (_walletState.value.hasWallet) { + seedFromWidgetCache() fetchPrice() } checkAndApplyAutoLock() @@ -130,6 +153,30 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) _selectedCurrency.value = Currency.entries.find { it.code == currencyCode } ?: Currency.USD } + // Seed last-known state from the widget cache to mask the cold-start 0-balance / + // empty-list flash. Kit's live emissions overwrite these on reconnect. + private fun seedFromWidgetCache() { + _walletState.update { + it.copy( + balance = Balance(WidgetDataStore.getBalance(context), WidgetDataStore.getUnlockedBalance(context)), + // Connecting (not the cached status) so the sync indicator reflects + // a refresh in progress, not a finished sync. + syncState = SyncState.Connecting(waiting = false) + ) + } + val cachedPrice = WidgetDataStore.getPrice(context) + if (cachedPrice > 0f) { + _currentPrice.value = CurrentPrice( + price = cachedPrice.toDouble(), + change24h = WidgetDataStore.getChange24h(context).toDouble() + ) + } + val cachedTxs = parseCachedTxs(WidgetDataStore.getTransactions(context)) + if (cachedTxs.isNotEmpty()) { + _walletState.update { it.copy(transactions = cachedTxs) } + } + } + fun checkAndApplyAutoLock() { val prefs = context.getSharedPreferences("monero_wallet", Context.MODE_PRIVATE) val backgroundTimestamp = prefs.getLong("background_timestamp", 0) @@ -545,13 +592,14 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) WalletManager.transactionsFlow.collect { transactions -> Timber.d("Transactions updated: count=${transactions.size}") _walletState.update { it.copy(transactions = transactions) } - // Update transactions widget (store last 4 for the iOS-style large layout) + // Cache txs for widget (reads dir|amount|timestamp) + cold-start UI seed + // (reads all 7). Format: dir|amount|timestamp|hash|fee|blockheight|confirmations val txString = transactions .sortedByDescending { it.timestamp } - .take(4) + .take(50) .joinToString(";") { tx -> val dir = if (tx.direction == io.horizontalsystems.monerokit.model.TransactionInfo.Direction.Direction_In) "in" else "out" - "$dir|${tx.amount}|${tx.timestamp}" + "$dir|${tx.amount}|${tx.timestamp}|${tx.hash}|${tx.fee}|${tx.blockheight}|${tx.confirmations}" } WidgetDataStore.saveTransactions(context, txString) WalletWidget.updateAll(context) diff --git a/app/src/test/java/one/monero/moneroone/TxCacheParserTest.kt b/app/src/test/java/one/monero/moneroone/TxCacheParserTest.kt new file mode 100644 index 0000000..a4ac7aa --- /dev/null +++ b/app/src/test/java/one/monero/moneroone/TxCacheParserTest.kt @@ -0,0 +1,68 @@ +package one.monero.moneroone + +import io.horizontalsystems.monerokit.model.TransactionInfo +import one.monero.moneroone.core.wallet.parseCachedTxs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TxCacheParserTest { + + @Test + fun roundTripsWriterFormatWithFullFields() { + // Format the writer emits: dir|amount|timestamp|hash|fee|blockheight|confirmations + val cache = "in|1000000000000|1700000000|abc123|0|310000|15;" + + "out|500000000000|1700000001|def456|12000|0|0" + val parsed = parseCachedTxs(cache) + + assertEquals(2, parsed.size) + + val first = parsed[0] + assertEquals(TransactionInfo.Direction.Direction_In, first.direction) + assertEquals(1_000_000_000_000L, first.amount) + assertEquals(1_700_000_000L, first.timestamp) + assertEquals("abc123", first.hash) + assertEquals(0L, first.fee) + assertEquals(310_000L, first.blockheight) + assertEquals(15L, first.confirmations) + assertFalse(first.isPending) // blockheight > 0 → confirmed + + // Pending tx: blockheight 0 → isPending, fee preserved, confirmations 0. + val second = parsed[1] + assertEquals(TransactionInfo.Direction.Direction_Out, second.direction) + assertEquals("def456", second.hash) + assertEquals(12_000L, second.fee) + assertEquals(0L, second.blockheight) + assertEquals(0L, second.confirmations) + assertTrue(second.isPending) + } + + @Test + fun skipsLegacyEntriesMissingTheExtraFields() { + // Pre-upgrade 4-field entries lack fee/blockheight/confirmations → skip, don't + // fabricate a Confirmed status. The next kit emit rewrites the cache with 7 fields. + val cache = "in|1000000000000|1700000000|abc123;" + + "in|500000000000|1700000001|hash2|0|310000|10" + val parsed = parseCachedTxs(cache) + + assertEquals(1, parsed.size) + assertEquals("hash2", parsed[0].hash) + } + + @Test + fun blankAndGarbageEntriesAreDropped() { + val cache = ";in|notanumber|1700000000|hash1|0|0|0;;in|500000000000|1700000001|hash2|0|0|5;" + val parsed = parseCachedTxs(cache) + + // "notanumber" fails toLongOrNull → dropped; blank entries filtered. + assertEquals(1, parsed.size) + assertEquals("hash2", parsed[0].hash) + assertEquals(5L, parsed[0].confirmations) + } + + @Test + fun emptyCacheYieldsEmptyList() { + assertTrue(parseCachedTxs("").isEmpty()) + } +}