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
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransactionInfo> =
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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions app/src/test/java/one/monero/moneroone/TxCacheParserTest.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}