From a1af0f793facc2cc97724bfb64666c3960c46910 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 11:39:41 -0400 Subject: [PATCH] fix(blocklist): keep the cached name when a blocked user's profile fetch fails BlocklistCoordinator.refresh and BlocklistRemoteMediator store each blocked user's profile as getProfileForUser(...).getOrNull(), and writeProfile wrote `profile?.displayName.orEmpty()`. A failed fetch therefore replaced a cached display_name with "" in user_profiles, which every screen reads names from. upsertNameAndAvatar now takes a nullable name and COALESCEs it with the cached one, as it already did for the avatar. With nothing cached it stores "", since the column is NOT NULL. An empty name from a successful fetch still replaces the cached one. --- .../app/persistence/dao/UserProfileDao.kt | 10 +++--- .../persistence/UserProfileMigrationTest.kt | 31 +++++++++++++++++++ .../sources/BlockedUserDataSource.kt | 5 +-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt index 487be14087..26bbc2eb0c 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -27,8 +27,10 @@ interface UserProfileDao { * Uses `INSERT OR REPLACE` with correlated sub-selects (rather than `ON CONFLICT DO * UPDATE`) so it works on the minSdk-29 SQLite build, which predates UPSERT. The * sub-selects read the current row before the replace, preserving every column the - * blocklist doesn't know about; the avatar keeps its existing value when [profilePicture] - * is null via COALESCE. + * blocklist doesn't know about. The name and avatar keep their existing values when + * [displayName] or [profilePicture] is null, via COALESCE: null means the caller has no + * profile to write (a failed fetch), whereas an empty name is one the user really has. A + * null name with nothing cached stores an empty one, since the column is NOT NULL. */ @Query( """ @@ -38,7 +40,7 @@ interface UserProfileDao { profile_picture_json, username, pending_migration_json ) VALUES ( :userIdHex, - :displayName, + COALESCE(:displayName, (SELECT display_name FROM user_profiles WHERE user_id_hex = :userIdHex), ''), (SELECT phone_value FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT phone_verified FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT email_value FROM user_profiles WHERE user_id_hex = :userIdHex), @@ -50,7 +52,7 @@ interface UserProfileDao { ) """ ) - suspend fun upsertNameAndAvatar(userIdHex: String, displayName: String, profilePicture: MediaItem?) + suspend fun upsertNameAndAvatar(userIdHex: String, displayName: String?, profilePicture: MediaItem?) /** The cached profile for [userIdHex], or null if none is cached. */ @Query("SELECT * FROM user_profiles WHERE user_id_hex = :userIdHex LIMIT 1") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt index 7e876c8409..0504bcc6e2 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt @@ -200,6 +200,37 @@ class UserProfileDaoTest { assertEquals("alice", dao.getByUserId("u1")?.username) } + @Test + fun `partial write with no name keeps the cached name`() = runBlocking { + val dao = db.userProfileDao() + dao.upsertFull(listOf(fullProfile("u1"))) + + // The blocklist writes no name when the profile fetch failed; that is not a rename. + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null) + + assertEquals("Alice", dao.getByUserId("u1")?.displayName) + } + + @Test + fun `partial write with no name and nothing cached stores an empty name`() = runBlocking { + val dao = db.userProfileDao() + + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = null, profilePicture = null) + + assertEquals("", dao.getByUserId("u1")?.displayName) + } + + @Test + fun `partial write with an empty name replaces the cached name`() = runBlocking { + val dao = db.userProfileDao() + dao.upsertFull(listOf(fullProfile("u1"))) + + // A fetched profile with no name is the user's own choice, not a failed lookup. + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "", profilePicture = null) + + assertEquals("", dao.getByUserId("u1")?.displayName) + } + @Test fun `chat member relation joins the shared normalized profile`() = runBlocking { db.userProfileDao().upsertFull(listOf(fullProfile("u1"))) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt index 20692a7440..7f0c9d07d2 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt @@ -62,12 +62,13 @@ class BlockedUserDataSource @Inject constructor( /** * Writes the blocked user's name + avatar into the shared `user_profiles` table without * disturbing any richer profile already cached from a chat (the blocklist only resolves - * those two fields). See [com.flipcash.app.persistence.dao.UserProfileDao.upsertNameAndAvatar]. + * those two fields). A null profile means the fetch failed, so the cached name stays rather + * than being blanked. See [com.flipcash.app.persistence.dao.UserProfileDao.upsertNameAndAvatar]. */ private suspend fun FlipcashDatabase.writeProfile(resolved: ResolvedBlockedUser) { userProfileDao().upsertNameAndAvatar( userIdHex = resolved.blocked.userId.hexEncodedString(), - displayName = resolved.profile?.displayName.orEmpty(), + displayName = resolved.profile?.displayName, profilePicture = resolved.profile?.profilePicture, ) }