fix: contact name wiped on refresh, migrate to paymentHash instead of checkingId

This commit is contained in:
2026-06-13 22:52:12 +02:00
parent c817768e8a
commit effe8f4b04
10 changed files with 183 additions and 77 deletions
@@ -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<List<ContactEntity>> =
dao.observeContactsForPayment(checkingId)
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
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<List<String>> =
@@ -141,11 +144,20 @@ class ContactRepository(app: Application) {
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
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<String> =
dao.findCheckingIdsByContactName(query)
suspend fun findPaymentHashesByContactName(query: String): List<String> =
dao.findPaymentHashesByContactName(query)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
dao.findCheckingIdsByContactIds(contactIds)
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
dao.findPaymentHashesByContactIds(contactIds)
}
@@ -58,13 +58,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return
val now = System.currentTimeMillis()
runCatching {
dao.insertAll(payments.map { it.toEntity(now) })
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<PaymentRecord>) {
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<String>,
suspend fun queryByPaymentHashes(
paymentHashes : List<String>,
filter : PaymentFilter
): List<PaymentRecord> {
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,
@@ -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 }
@@ -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<List<ContactEntity>>
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// 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<List<String>>
// 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<List<PaymentContactName>>
// 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<String>
suspend fun findPaymentHashesByContactName(query: String): List<String>
// 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<String>): List<String>
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
}
@@ -1,6 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName(
val checkingId : String,
val paymentHash : String,
val contactName : String
)
@@ -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<PaymentRecordEntity>
@@ -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<String>,
suspend fun getByPaymentHashesFiltered(
paymentHashes : List<String>,
minAmountMsat : Long? = null,
maxAmountMsat : Long? = null,
minCreatedAt : Long? = null,
@@ -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()
@@ -235,7 +235,7 @@ fun HistoryScreen(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.checkingId],
contactName = contactNames[payment.paymentHash],
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
@@ -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
@@ -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",
)