feat: add AppDatabase and move payment caching to db
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -70,7 +71,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
// Biometric
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
implementation(libs.androidx.biometric)
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
|
|||||||
@@ -1,59 +1,47 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.toDomain
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.toEntity
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.api.SuccessAction
|
|
||||||
import com.bitcointxoko.gudariwallet.api.SuccessActionAdapter
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
|
|
||||||
private const val TAG = "PaymentCacheRepository"
|
private const val TAG = "PaymentCacheRepository"
|
||||||
|
|
||||||
class PaymentCacheRepository(context: Context) {
|
class PaymentCacheRepository(context: Context) {
|
||||||
|
|
||||||
private val prefs: SharedPreferences = context.getSharedPreferences(
|
private val dao = AppDatabase.getInstance(context).paymentDao()
|
||||||
WalletConstants.PAYMENTS_PREFS_NAME, Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
private val gson = GsonProvider.gson
|
|
||||||
// ── In-memory detail cache — immutable once confirmed, no TTL needed ──────
|
// ── In-memory detail cache — immutable once confirmed, no TTL needed ──────
|
||||||
private val detailCache = mutableMapOf<String, PaymentDetailResponse>()
|
private val detailCache = mutableMapOf<String, PaymentDetailResponse>()
|
||||||
|
|
||||||
// ── Payment list ──────────────────────────────────────────────────────────
|
// ── Payment list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */
|
/** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */
|
||||||
fun loadCachedPayments(): List<PaymentRecord> {
|
suspend fun loadCachedPayments(): List<PaymentRecord> {
|
||||||
val json = prefs.getString(WalletConstants.PAYMENTS_PREFS_KEY, null) ?: return emptyList()
|
val savedAt = dao.getLatestSavedAt() ?: run {
|
||||||
val savedAt = prefs.getLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, 0L)
|
Log.d(TAG, "PAYMENTS [CACHE MISS] no rows in db")
|
||||||
val ageMs = System.currentTimeMillis() - savedAt
|
return emptyList()
|
||||||
|
}
|
||||||
|
val ageMs = System.currentTimeMillis() - savedAt
|
||||||
if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) {
|
if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) {
|
||||||
Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list")
|
Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
return dao.getAll().map { it.toDomain() }.also {
|
||||||
return runCatching {
|
Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${it.size} payments (age ${ageMs / 1000}s)")
|
||||||
val type: java.lang.reflect.Type = object : TypeToken<List<PaymentRecord>>() {}.type
|
|
||||||
val list: List<PaymentRecord> = gson.fromJson(json, type)
|
|
||||||
Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${list.size} payments (age ${ageMs / 1000}s)")
|
|
||||||
list
|
|
||||||
}.getOrElse {
|
|
||||||
Log.w(TAG, "PAYMENTS [CACHE ERR ] failed to deserialise: ${it.message}")
|
|
||||||
emptyList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the first page of payments to SharedPreferences. */
|
/** Persist the first page of payments to the database. */
|
||||||
fun savePayments(payments: List<PaymentRecord>) {
|
suspend fun savePayments(payments: List<PaymentRecord>) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
runCatching {
|
runCatching {
|
||||||
prefs.edit()
|
dao.deleteAll()
|
||||||
.putString(WalletConstants.PAYMENTS_PREFS_KEY, gson.toJson(payments))
|
dao.insertAll(payments.map { it.toEntity(now) })
|
||||||
.putLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, System.currentTimeMillis())
|
|
||||||
.apply()
|
|
||||||
Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments")
|
Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}")
|
Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}")
|
||||||
@@ -63,11 +51,10 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
// ── Payment detail ────────────────────────────────────────────────────────
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns a cached detail response, or null if not yet fetched this session. */
|
/** Returns a cached detail response, or null if not yet fetched this session. */
|
||||||
fun getCachedDetail(checkingId: String): PaymentDetailResponse? {
|
fun getCachedDetail(checkingId: String): PaymentDetailResponse? =
|
||||||
return detailCache[checkingId]?.also {
|
detailCache[checkingId]?.also {
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId")
|
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Store a detail response in the in-memory cache. */
|
/** Store a detail response in the in-memory cache. */
|
||||||
fun saveDetail(checkingId: String, detail: PaymentDetailResponse) {
|
fun saveDetail(checkingId: String, detail: PaymentDetailResponse) {
|
||||||
@@ -76,12 +63,9 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Clear everything — useful for testing and logout. */
|
/** Clear everything — useful for testing and logout. */
|
||||||
fun clearAll() {
|
suspend fun clearAll() {
|
||||||
detailCache.clear()
|
detailCache.clear()
|
||||||
prefs.edit()
|
dao.deleteAll()
|
||||||
.remove(WalletConstants.PAYMENTS_PREFS_KEY)
|
|
||||||
.remove(WalletConstants.PAYMENTS_PREFS_SAVED_AT)
|
|
||||||
.apply()
|
|
||||||
Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped")
|
Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [PaymentRecordEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(PaymentConverters::class)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun paymentDao(): PaymentDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): AppDatabase =
|
||||||
|
INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"gudari_wallet.db"
|
||||||
|
).build().also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
||||||
|
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
||||||
|
|
||||||
|
object PaymentConverters {
|
||||||
|
private val gson = GsonProvider.gson
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
@JvmStatic
|
||||||
|
fun fromExtra(extra: PaymentExtra?): String? =
|
||||||
|
extra?.let { gson.toJson(it) }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
@JvmStatic
|
||||||
|
fun toExtra(json: String?): PaymentExtra? =
|
||||||
|
json?.let { gson.fromJson(it, PaymentExtra::class.java) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PaymentDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(payments: List<PaymentRecordEntity>)
|
||||||
|
|
||||||
|
/** Returns all rows ordered by time descending. */
|
||||||
|
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
||||||
|
suspend fun getAll(): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
/** Returns the savedAt timestamp of the most recently written row, or null if table is empty. */
|
||||||
|
@Query("SELECT savedAt FROM payment_records ORDER BY savedAt DESC LIMIT 1")
|
||||||
|
suspend fun getLatestSavedAt(): Long?
|
||||||
|
|
||||||
|
@Query("DELETE FROM payment_records")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
|
@Entity(tableName = "payment_records")
|
||||||
|
@TypeConverters(PaymentConverters::class)
|
||||||
|
data class PaymentRecordEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val checkingId: String,
|
||||||
|
val paymentHash: String,
|
||||||
|
val amountMsat: Long,
|
||||||
|
val feeMsat: Long,
|
||||||
|
val memo: String?,
|
||||||
|
val time: String,
|
||||||
|
val status: String,
|
||||||
|
val bolt11: String?,
|
||||||
|
val pending: Boolean,
|
||||||
|
val preimage: String?,
|
||||||
|
val extra: String?, // JSON blob — PaymentExtra serialised by TypeConverter
|
||||||
|
val savedAt: Long // epoch ms — used for TTL check
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
|
||||||
|
fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
|
||||||
|
PaymentRecordEntity(
|
||||||
|
checkingId = checkingId,
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
amountMsat = amountMsat,
|
||||||
|
feeMsat = feeMsat,
|
||||||
|
memo = memo,
|
||||||
|
time = time,
|
||||||
|
status = status,
|
||||||
|
bolt11 = bolt11,
|
||||||
|
pending = pending,
|
||||||
|
preimage = preimage,
|
||||||
|
extra = extra?.let { com.bitcointxoko.gudariwallet.api.GsonProvider.gson.toJson(it) },
|
||||||
|
savedAt = savedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PaymentRecordEntity.toDomain(): PaymentRecord =
|
||||||
|
PaymentRecord(
|
||||||
|
checkingId = checkingId,
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
amountMsat = amountMsat,
|
||||||
|
feeMsat = feeMsat,
|
||||||
|
memo = memo,
|
||||||
|
time = time,
|
||||||
|
status = status,
|
||||||
|
bolt11 = bolt11,
|
||||||
|
pending = pending,
|
||||||
|
preimage = preimage,
|
||||||
|
extra = extra?.let {
|
||||||
|
com.bitcointxoko.gudariwallet.api.GsonProvider.gson
|
||||||
|
.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -35,9 +35,7 @@ class HistoryViewModel(
|
|||||||
// Cache read moved to IO dispatcher — never blocks the main thread,
|
// Cache read moved to IO dispatcher — never blocks the main thread,
|
||||||
// so navigation to HistoryScreen is instant on tap.
|
// so navigation to HistoryScreen is instant on tap.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val cached = withContext(Dispatchers.IO) {
|
val cached = paymentCache.loadCachedPayments()
|
||||||
paymentCache.loadCachedPayments()
|
|
||||||
}
|
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
|
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
|
||||||
_state.value = HistoryState.Success(
|
_state.value = HistoryState.Success(
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ object WalletConstants {
|
|||||||
const val ALIAS_PREFS_KEY = "alias_cache"
|
const val ALIAS_PREFS_KEY = "alias_cache"
|
||||||
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
|
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
|
||||||
// ── Payments cache ────────────────────────────────────────────────────────
|
// ── Payments cache ────────────────────────────────────────────────────────
|
||||||
const val PAYMENTS_PREFS_NAME = "payment_cache_prefs"
|
// const val PAYMENTS_PREFS_NAME = "payment_cache_prefs"
|
||||||
const val PAYMENTS_PREFS_KEY = "payment_list"
|
// const val PAYMENTS_PREFS_KEY = "payment_list"
|
||||||
const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at"
|
// const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at"
|
||||||
const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
|
const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
|
||||||
// ── Balance cache ─────────────────────────────────────────────────────────
|
// ── Balance cache ─────────────────────────────────────────────────────────
|
||||||
const val BALANCE_PREFS_NAME = "balance_cache_prefs"
|
const val BALANCE_PREFS_NAME = "balance_cache_prefs"
|
||||||
|
|||||||
+6
-1
@@ -12,4 +12,9 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
# org.gradle.parallel=true
|
# org.gradle.parallel=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
#Your project is using a version of the Android Gradle Plugin that has built-in Kotlin support,
|
||||||
|
# which conflicts with how KSP tries to register its generated sources.
|
||||||
|
# This tells AGP to allow KSP to use the kotlin.sourceSets DSL to register its generated code
|
||||||
|
# (the Room-generated AppDatabase_Impl etc.), which it needs to do.
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
@@ -19,6 +19,8 @@ retrofit = "3.0.0"
|
|||||||
zxing-embedded = "4.3.0"
|
zxing-embedded = "4.3.0"
|
||||||
navigationCompose = "2.8.9"
|
navigationCompose = "2.8.9"
|
||||||
appcompat = "1.7.0"
|
appcompat = "1.7.0"
|
||||||
|
ksp = "2.2.10-2.0.2"
|
||||||
|
room = "2.7.1"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -52,10 +54,14 @@ zxing-android-embedded = { group = "com.journeyapps", name = "z
|
|||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user