diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa50f77..d93f88d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) } android { @@ -70,7 +71,12 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) 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) // JSON diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index 1d43459..2a92bd9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -1,59 +1,47 @@ package com.bitcointxoko.gudariwallet.data import android.content.Context -import android.content.SharedPreferences import android.util.Log -import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse 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.api.SuccessAction -import com.bitcointxoko.gudariwallet.api.SuccessActionAdapter -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken private const val TAG = "PaymentCacheRepository" class PaymentCacheRepository(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences( - WalletConstants.PAYMENTS_PREFS_NAME, Context.MODE_PRIVATE - ) - private val gson = GsonProvider.gson + private val dao = AppDatabase.getInstance(context).paymentDao() + // ── In-memory detail cache — immutable once confirmed, no TTL needed ────── private val detailCache = mutableMapOf() // ── Payment list ────────────────────────────────────────────────────────── /** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */ - fun loadCachedPayments(): List { - val json = prefs.getString(WalletConstants.PAYMENTS_PREFS_KEY, null) ?: return emptyList() - val savedAt = prefs.getLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, 0L) - val ageMs = System.currentTimeMillis() - savedAt - + suspend fun loadCachedPayments(): List { + val savedAt = dao.getLatestSavedAt() ?: run { + Log.d(TAG, "PAYMENTS [CACHE MISS] no rows in db") + return emptyList() + } + val ageMs = System.currentTimeMillis() - savedAt if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) { Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list") return emptyList() } - - return runCatching { - val type: java.lang.reflect.Type = object : TypeToken>() {}.type - val list: List = 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() + return dao.getAll().map { it.toDomain() }.also { + Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${it.size} payments (age ${ageMs / 1000}s)") } } - /** Persist the first page of payments to SharedPreferences. */ - fun savePayments(payments: List) { + /** Persist the first page of payments to the database. */ + suspend fun savePayments(payments: List) { + val now = System.currentTimeMillis() runCatching { - prefs.edit() - .putString(WalletConstants.PAYMENTS_PREFS_KEY, gson.toJson(payments)) - .putLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, System.currentTimeMillis()) - .apply() + dao.deleteAll() + dao.insertAll(payments.map { it.toEntity(now) }) Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments") }.onFailure { Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}") @@ -63,11 +51,10 @@ class PaymentCacheRepository(context: Context) { // ── Payment detail ──────────────────────────────────────────────────────── /** Returns a cached detail response, or null if not yet fetched this session. */ - fun getCachedDetail(checkingId: String): PaymentDetailResponse? { - return detailCache[checkingId]?.also { + fun getCachedDetail(checkingId: String): PaymentDetailResponse? = + detailCache[checkingId]?.also { Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId") } - } /** Store a detail response in the in-memory cache. */ fun saveDetail(checkingId: String, detail: PaymentDetailResponse) { @@ -76,12 +63,9 @@ class PaymentCacheRepository(context: Context) { } /** Clear everything — useful for testing and logout. */ - fun clearAll() { + suspend fun clearAll() { detailCache.clear() - prefs.edit() - .remove(WalletConstants.PAYMENTS_PREFS_KEY) - .remove(WalletConstants.PAYMENTS_PREFS_SAVED_AT) - .apply() + dao.deleteAll() Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped") } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt new file mode 100644 index 0000000..ac060c1 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -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 } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt new file mode 100644 index 0000000..1ab845a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentConverters.kt @@ -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) } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt new file mode 100644 index 0000000..aa327a8 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -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) + + /** Returns all rows ordered by time descending. */ + @Query("SELECT * FROM payment_records ORDER BY time DESC") + suspend fun getAll(): List + + /** 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() +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt new file mode 100644 index 0000000..4932b7e --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt new file mode 100644 index 0000000..0706e3f --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt @@ -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) + } + ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index d1f21fc..ee83bd9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -35,9 +35,7 @@ class HistoryViewModel( // Cache read moved to IO dispatcher — never blocks the main thread, // so navigation to HistoryScreen is instant on tap. viewModelScope.launch { - val cached = withContext(Dispatchers.IO) { - paymentCache.loadCachedPayments() - } + val cached = paymentCache.loadCachedPayments() if (cached.isNotEmpty()) { Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately") _state.value = HistoryState.Success( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt index e6433a1..10bea3b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt @@ -37,9 +37,9 @@ object WalletConstants { 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 // ── Payments cache ──────────────────────────────────────────────────────── - const val PAYMENTS_PREFS_NAME = "payment_cache_prefs" - const val PAYMENTS_PREFS_KEY = "payment_list" - const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at" +// const val PAYMENTS_PREFS_NAME = "payment_cache_prefs" +// const val PAYMENTS_PREFS_KEY = "payment_list" +// 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 // ── Balance cache ───────────────────────────────────────────────────────── const val BALANCE_PREFS_NAME = "balance_cache_prefs" diff --git a/gradle.properties b/gradle.properties index 34c5e9e..534d305 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,9 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +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 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 934a357..d54a992 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ retrofit = "3.0.0" zxing-embedded = "4.3.0" navigationCompose = "2.8.9" 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-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" } 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }