diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt index 02e0f83..d77bdac 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -4,6 +4,7 @@ import android.app.Application import com.bitcointxoko.gudariwallet.data.db.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import timber.log.Timber class ContactRepository(app: Application) { private val dao = AppDatabase.getInstance(app).contactDao() @@ -125,15 +126,17 @@ class ContactRepository(app: Application) { // --- Contact-Tx link --- - fun observeContactsForPayment(checkingId: String): Flow> = - dao.observeContactsForPayment(checkingId) + fun observeContactsForPayment(paymentHash: String): Flow> = + dao.observeContactsForPayment(paymentHash) - suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) { - dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role)) + suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) { + Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId") + dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role)) } - suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) { - dao.unlinkPaymentFromContact(checkingId, contactId) + suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) { + Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId") + dao.unlinkPaymentFromContact(paymentHash, contactId) } fun observePaymentIdsForContact(contactId: String): Flow> = @@ -141,11 +144,20 @@ class ContactRepository(app: Application) { fun observeAllPaymentContactNames(): Flow> = dao.observePaymentContactNames() - .map { list -> list.associate { it.checkingId to it.contactName } } + .map { list -> + list.associate { it.paymentHash to it.contactName } + .also { map -> + Timber.d( + "CONTACT [MAP EMIT ] ${map.size} entries: ${ + map.entries.take(5).joinToString { "${it.key.take(8)}→${it.value}" } + }" + ) + } + } - suspend fun findCheckingIdsByContactName(query: String): List = - dao.findCheckingIdsByContactName(query) + suspend fun findPaymentHashesByContactName(query: String): List = + dao.findPaymentHashesByContactName(query) - suspend fun findCheckingIdsByContactIds(contactIds: List): List = - dao.findCheckingIdsByContactIds(contactIds) + suspend fun findPaymentHashesByContactIds(contactIds: List): List = + dao.findPaymentHashesByContactIds(contactIds) } 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 5a364e9..db625c8 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -58,13 +58,33 @@ class PaymentCacheRepository(context: Context) { if (payments.isEmpty()) return val now = System.currentTimeMillis() runCatching { - dao.insertAll(payments.map { it.toEntity(now) }) - Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})") + payments.forEach { payment -> + val e = payment.toEntity(now) + Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}") + dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE) + dao.updateSafe( // always patches server-owned fields in-place + checkingId = e.checkingId, + paymentHash = e.paymentHash, + amountMsat = e.amountMsat, + feeMsat = e.feeMsat, + memo = e.memo, + time = e.time, + createdAt = e.createdAt, + status = e.status, + bolt11 = e.bolt11, + pending = e.pending, + preimage = e.preimage, + extra = e.extra, + savedAt = e.savedAt + ) + } + Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})") }.onFailure { Timber.w("PAYMENTS [MERGE ERR] ${it.message}") } } + /** Persist the first page of payments to the database. */ suspend fun savePayments(payments: List) { val now = System.currentTimeMillis() @@ -130,8 +150,24 @@ class PaymentCacheRepository(context: Context) { /** Upsert PaymentRecord to database. */ suspend fun upsertPayment(payment: PaymentRecord) { - val entity = payment.toEntity(savedAt = System.currentTimeMillis()) - dao.insert(entity) + val e = payment.toEntity(savedAt = System.currentTimeMillis()) + Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}") + dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE + dao.updateSafe( // always patches server-owned fields in-place + checkingId = e.checkingId, + paymentHash = e.paymentHash, + amountMsat = e.amountMsat, + feeMsat = e.feeMsat, + memo = e.memo, + time = e.time, + createdAt = e.createdAt, + status = e.status, + bolt11 = e.bolt11, + pending = e.pending, + preimage = e.preimage, + extra = e.extra, + savedAt = e.savedAt + ) Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}") } @@ -166,11 +202,11 @@ class PaymentCacheRepository(context: Context) { * contact ID filter. Amount/date/status are applied in SQL; * direction and type are handled in-memory afterwards (same as queryAll). */ - suspend fun queryByCheckingIds( - checkingIds : List, + suspend fun queryByPaymentHashes( + paymentHashes : List, filter : PaymentFilter ): List { - if (checkingIds.isEmpty()) return emptyList() + if (paymentHashes.isEmpty()) return emptyList() val statusPattern: String? = if (filter.statuses.isEmpty()) null else { filter.statuses.flatMap { s -> when (s) { @@ -180,8 +216,8 @@ class PaymentCacheRepository(context: Context) { } }.joinToString(",") } - return dao.getByCheckingIdsFiltered( - checkingIds = checkingIds, + return dao.getByPaymentHashesFiltered( + paymentHashes = paymentHashes, minAmountMsat = filter.minAmountSat?.let { it * 1000L }, maxAmountMsat = filter.maxAmountSat?.let { it * 1000L }, minCreatedAt = filter.minCreatedAt, 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 index ec7da92..743775b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase PaymentAddressEntity::class, TxContactCrossRef::class, ], - version = 9, + version = 10, exportSchema = false ) @TypeConverters(PaymentConverters::class) @@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_9_10 = object : Migration(9, 9 + 1) { + override fun migrate(database: SupportSQLiteDatabase) { + // 1. Create replacement table — same schema minus the payment_records FK + database.execSQL(""" + CREATE TABLE IF NOT EXISTS tx_contact_links_new ( + checkingId TEXT NOT NULL, + contactId TEXT NOT NULL, + role TEXT, + PRIMARY KEY (checkingId, contactId), + FOREIGN KEY (contactId) REFERENCES contacts(id) + ON DELETE CASCADE + ) + """) + // 2. Copy existing rows + database.execSQL(""" + INSERT INTO tx_contact_links_new (checkingId, contactId, role) + SELECT checkingId, contactId, role FROM tx_contact_links + """) + // 3. Swap tables + database.execSQL("DROP TABLE tx_contact_links") + database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links") + } + } + fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( @@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, - MIGRATION_8_9 + MIGRATION_8_9, + MIGRATION_9_10 ) .build() .also { INSTANCE = it } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt index 36b27d9..353e9ed 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt @@ -76,51 +76,51 @@ interface ContactDao { suspend fun linkPaymentToContact(crossRef: TxContactCrossRef) // Delete a link - @Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId") - suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) + @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId") + suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) // Delete all contact links for a payment - @Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId") - suspend fun clearContactsForPayment(checkingId: String) + @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash") + suspend fun clearContactsForPayment(paymentHash: String) // Get all contacts linked to a payment @Query(""" SELECT c.* FROM contacts c INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id - WHERE tcl.checkingId = :checkingId + WHERE tcl.paymentHash = :paymentHash """) - fun observeContactsForPayment(checkingId: String): Flow> + fun observeContactsForPayment(paymentHash: String): Flow> // Get all payments linked to a contact @Query(""" - SELECT checkingId FROM tx_contact_links + SELECT paymentHash FROM tx_contact_links WHERE contactId = :contactId ORDER BY createdAt DESC """) fun observePaymentIdsForContact(contactId: String): Flow> - // Returns all (checkingId, displayName) pairs that have a linked contact, + // Returns all (paymentHash, displayName) pairs that have a linked contact, // so HistoryViewModel can build its lookup map in a single reactive query. @Query(""" - SELECT tcl.checkingId AS checkingId, + SELECT tcl.paymentHash AS paymentHash, COALESCE(c.localAlias, c.displayName, c.name) AS contactName FROM tx_contact_links tcl INNER JOIN contacts c ON c.id = tcl.contactId """) fun observePaymentContactNames(): Flow> - // For text search matching contact names — returns checkingIds of matched payments + // For text search matching contact names — returns paymentHashes of matched payments @Query(""" - SELECT tcl.checkingId FROM tx_contact_links tcl + SELECT tcl.paymentHash FROM tx_contact_links tcl INNER JOIN contacts c ON c.id = tcl.contactId WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%' """) - suspend fun findCheckingIdsByContactName(query: String): List + suspend fun findPaymentHashesByContactName(query: String): List - // For filter-by-contact — returns checkingIds for specific contact IDs + // For filter-by-contact — returns paymentHashs for specific contact IDs @Query(""" - SELECT DISTINCT checkingId FROM tx_contact_links + SELECT DISTINCT paymentHash FROM tx_contact_links WHERE contactId IN (:contactIds) """) - suspend fun findCheckingIdsByContactIds(contactIds: List): List + suspend fun findPaymentHashesByContactIds(contactIds: List): List } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentContactName.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentContactName.kt index 3180278..56c77c1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentContactName.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentContactName.kt @@ -1,6 +1,6 @@ package com.bitcointxoko.gudariwallet.data.db data class PaymentContactName( - val checkingId : String, + val paymentHash : String, val contactName : String ) 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 index 6434467..5822f45 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -13,6 +13,45 @@ interface PaymentDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(payment: PaymentRecordEntity) + // Safe upsert used by mergePayments only. + // IGNORE skips the row on conflict (no DELETE), then updateSafe patches + // the server-owned fields in-place — the parent row is never deleted, + // so the TxContactCrossRef CASCADE is never triggered. + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIgnore(payment: PaymentRecordEntity) + + @Query(""" + UPDATE payment_records SET + paymentHash = :paymentHash, + amountMsat = :amountMsat, + feeMsat = :feeMsat, + memo = :memo, + time = :time, + createdAt = :createdAt, + status = :status, + bolt11 = :bolt11, + pending = :pending, + preimage = :preimage, + extra = :extra, + savedAt = :savedAt + WHERE checkingId = :checkingId +""") + suspend fun updateSafe( + checkingId: String, + paymentHash: String, + amountMsat: Long, + feeMsat: Long, + memo: String?, + time: String, + createdAt: Long?, + status: String, + bolt11: String?, + pending: Boolean, + preimage: String?, + extra: String?, + savedAt: Long + ) + /** Returns all rows ordered by time descending. */ @Query("SELECT * FROM payment_records ORDER BY time DESC") suspend fun getAll(): List @@ -38,7 +77,7 @@ interface PaymentDao { @Query(""" SELECT * FROM payment_records - WHERE checkingId IN (:checkingIds) + WHERE paymentHash IN (:paymentHashes) AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat) AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat) AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt) @@ -47,8 +86,8 @@ interface PaymentDao { ',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%') ORDER BY createdAt DESC, time DESC """) - suspend fun getByCheckingIdsFiltered( - checkingIds : List, + suspend fun getByPaymentHashesFiltered( + paymentHashes : List, minAmountMsat : Long? = null, maxAmountMsat : Long? = null, minCreatedAt : Long? = null, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt index fa06def..e666e39 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt @@ -6,28 +6,10 @@ import androidx.room.Index @Entity( tableName = "tx_contact_links", - primaryKeys = ["checkingId", "contactId"], - foreignKeys = [ - ForeignKey( - entity = PaymentRecordEntity::class, - parentColumns = ["checkingId"], - childColumns = ["checkingId"], - onDelete = ForeignKey.CASCADE - ), - ForeignKey( - entity = ContactEntity::class, - parentColumns = ["id"], - childColumns = ["contactId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [ - Index("checkingId"), - Index("contactId") - ] + primaryKeys = ["paymentHash", "contactId"] ) data class TxContactCrossRef( - val checkingId : String, + val paymentHash : String, val contactId : String, val role : String? = null, val createdAt : Long = System.currentTimeMillis() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt index 39d9b74..c1fe3d7 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt @@ -235,7 +235,7 @@ fun HistoryScreen( payment = payment, fiatCurrency = fiatCurrency, fiatSatsPerUnit = fiatSatsPerUnit, - contactName = contactNames[payment.checkingId], + contactName = contactNames[payment.paymentHash], onClick = { onPaymentClick(payment) } ) HorizontalDivider( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt index 3686359..446c12a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt @@ -133,13 +133,22 @@ class HistoryViewModel( } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults -> + val filteredState = combine( + syncManager.state, + _filter, _roomResults, + contactRepo.observeAllPaymentContactNames() + ) { s, f, roomResults, contactNames -> + Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries") if (s !is HistoryState.Success) return@combine s - if (roomResults == null) { - s.copy(payments = s.payments.applyInMemoryFilters(f)) + val payments = if (roomResults == null) { + s.payments.applyInMemoryFilters(f) } else { - s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false) + roomResults.applyInMemoryFilters(f) } + s.copy( + payments = payments, + canLoadMore = roomResults == null && s.canLoadMore + ) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) // ── In-memory filter logic ──────────────────────────────────────────────── @@ -164,7 +173,7 @@ class HistoryViewModel( contactRepo.observeAllPaymentContactNames() .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + started = SharingStarted.Eagerly, initialValue = emptyMap() ) // Maps contactId → display name (for filter chips) @@ -203,6 +212,7 @@ class HistoryViewModel( ) fun assignContact(paymentHash: String, contactId: String) { + Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId") viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) } @@ -220,6 +230,7 @@ class HistoryViewModel( addressType : PaymentAddressType?, address : String? ) { + Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name") viewModelScope.launch { val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS } val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } @@ -228,6 +239,7 @@ class HistoryViewModel( lnAddress = lnAddress, lnUrl = lnUrl ) + Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash") contactRepo.linkPaymentToContact(paymentHash, contact.id) } } @@ -265,11 +277,11 @@ class HistoryViewModel( // ── Supplemental contact search ─────────────────────── // 1. Text query may match contact names not in payment_records if (f.searchQuery.isNotBlank()) { - val contactCheckingIds = contactRepo - .findCheckingIdsByContactName(f.searchQuery) - if (contactCheckingIds.isNotEmpty()) { + val contactPaymentHashes = contactRepo + .findPaymentHashesByContactName(f.searchQuery) + if (contactPaymentHashes.isNotEmpty()) { val contactPayments = paymentCache - .queryByCheckingIds(contactCheckingIds, f) + .queryByPaymentHashes(contactPaymentHashes, f) // Merge without duplicates val existingIds = baseResults.map { it.checkingId }.toSet() baseResults += contactPayments @@ -279,12 +291,12 @@ class HistoryViewModel( // 2. Explicit contact ID filter if (f.contactIds.isNotEmpty()) { - val contactCheckingIds = contactRepo - .findCheckingIdsByContactIds(f.contactIds.toList()) - if (contactCheckingIds.isNotEmpty()) { + val contactPaymentHashes = contactRepo + .findPaymentHashesByContactIds(f.contactIds.toList()) + if (contactPaymentHashes.isNotEmpty()) { // Fetch ALL payments for these contacts, WITH the full filter applied - val contactFiltered = paymentCache.queryByCheckingIds( - checkingIds = contactCheckingIds, + val contactFiltered = paymentCache.queryByPaymentHashes( + paymentHashes = contactPaymentHashes, filter = f ) _roomResults.value = contactFiltered diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index 47874d0..4d75ff1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -400,7 +400,7 @@ class SendViewModel( // ── 3. Link payment → contact (FK now guaranteed to exist) ─────── runCatching { contactRepo.linkPaymentToContact( - checkingId = paymentHash, + paymentHash = paymentHash, contactId = contactId, role = "recipient", )