Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.flipcash.app.persistence.dao.ContactDao
import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao
import com.flipcash.app.persistence.dao.MessageDao
import com.flipcash.app.persistence.dao.TokenDao
import com.flipcash.app.persistence.dao.UserProfileDao
import com.flipcash.app.persistence.entities.BlockedUserEntity
import com.flipcash.app.persistence.entities.ChatMemberEntity
import com.flipcash.app.persistence.entities.ChatMessageEntity
Expand All @@ -32,6 +33,7 @@ import com.flipcash.app.persistence.entities.MessageEntity
import com.flipcash.app.persistence.entities.SocialLinkEntity
import com.flipcash.app.persistence.entities.TokenEntity
import com.flipcash.app.persistence.entities.TokenValuationEntity
import com.flipcash.app.persistence.entities.UserProfileEntity
import com.getcode.utils.TraceType
import com.getcode.utils.trace
import com.getcode.vendor.Base58
Expand All @@ -50,6 +52,7 @@ import com.getcode.utils.subByteArray
ChatMessageEntity::class,
ChatMemberEntity::class,
BlockedUserEntity::class,
UserProfileEntity::class,
],
autoMigrations = [
AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class),
Expand All @@ -76,8 +79,11 @@ import com.getcode.utils.subByteArray
AutoMigration(from = 22, to = 23, spec = FlipcashDatabase.Migration22To23::class),
AutoMigration(from = 23, to = 24),
AutoMigration(from = 24, to = 25),
// 25 -> 26 is a manual migration (MIGRATION_25_26): it normalizes the
// per-row user_profile_json blob into the shared user_profiles table, which
// needs data movement an AutoMigration can't express.
],
version = 25,
version = 26,
)
@TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class)
abstract class FlipcashDatabase : RoomDatabase() {
Expand All @@ -90,6 +96,7 @@ abstract class FlipcashDatabase : RoomDatabase() {
abstract fun chatMessageDao(): ChatMessageDao
abstract fun chatMemberDao(): ChatMemberDao
abstract fun blockedUserDao(): BlockedUserDao
abstract fun userProfileDao(): UserProfileDao

class Migration1To2 : Migration(1, 2), AutoMigrationSpec {
override fun migrate(db: SupportSQLiteDatabase) {
Expand Down Expand Up @@ -176,6 +183,86 @@ abstract class FlipcashDatabase : RoomDatabase() {
}

companion object {

/**
* Normalizes the profile cache. Before v26 the full profile was serialized as
* `user_profile_json` and duplicated onto every `chat_members` row (one per
* membership) and onto `blocked_users`. v26 collapses it into a single
* `user_profiles` table keyed by `user_id_hex`, joined back via `@Relation`.
*
* The blob can't be decomposed into columns in SQL, so each user's blob is staged
* verbatim into `user_profiles.pending_migration_json` (one row per user; the full
* chat blob is preferred over the name+avatar-only blocked blob via `INSERT OR
* IGNORE`). A one-shot Kotlin backfill ([backfillMigratedProfiles]) decomposes it
* afterwards, and reads fall back to the staged blob until it runs.
*
* Column drops use table-recreate (not `ALTER TABLE ... DROP COLUMN`) for
* compatibility with the minSdk-29 SQLite build.
*/
val MIGRATION_25_26 = object : Migration(25, 26) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. New normalized table. Column set must match UserProfileEntity so
// Room's post-migration schema validation passes.
db.execSQL(
"CREATE TABLE IF NOT EXISTS `user_profiles` (" +
"`user_id_hex` TEXT NOT NULL, " +
"`display_name` TEXT NOT NULL, " +
"`phone_value` TEXT, " +
"`phone_verified` INTEGER, " +
"`email_value` TEXT, " +
"`email_verified` INTEGER, " +
"`social_accounts_json` TEXT, " +
"`profile_picture_json` TEXT, " +
"`pending_migration_json` TEXT, " +
"PRIMARY KEY(`user_id_hex`))"
)

// 2. Stage each user's legacy blob (one row per user). chat_members holds
// the full profile, so insert it first; blocked_users (name+avatar only)
// fills in users not in any chat. display_name is a placeholder until
// the backfill parses the blob.
db.execSQL(
"INSERT OR IGNORE INTO user_profiles (user_id_hex, display_name, pending_migration_json) " +
"SELECT user_id_hex, '', user_profile_json FROM chat_members " +
"WHERE user_profile_json IS NOT NULL"
)
db.execSQL(
"INSERT OR IGNORE INTO user_profiles (user_id_hex, display_name, pending_migration_json) " +
"SELECT user_id_hex, '', user_profile_json FROM blocked_users " +
"WHERE user_profile_json IS NOT NULL"
)

// 3. Drop the duplicated blob column from chat_members via table-recreate.
db.execSQL(
"CREATE TABLE `chat_members_new` (" +
"`chat_id_hex` TEXT NOT NULL, " +
"`user_id_hex` TEXT NOT NULL, " +
"`pointers_json` TEXT, " +
"PRIMARY KEY(`chat_id_hex`, `user_id_hex`))"
)
db.execSQL(
"INSERT INTO `chat_members_new` (chat_id_hex, user_id_hex, pointers_json) " +
"SELECT chat_id_hex, user_id_hex, pointers_json FROM chat_members"
)
db.execSQL("DROP TABLE chat_members")
db.execSQL("ALTER TABLE chat_members_new RENAME TO chat_members")

// 4. Same for blocked_users.
db.execSQL(
"CREATE TABLE `blocked_users_new` (" +
"`user_id_hex` TEXT NOT NULL, " +
"`blocked_at_epoch_ms` INTEGER NOT NULL, " +
"PRIMARY KEY(`user_id_hex`))"
)
db.execSQL(
"INSERT INTO `blocked_users_new` (user_id_hex, blocked_at_epoch_ms) " +
"SELECT user_id_hex, blocked_at_epoch_ms FROM blocked_users"
)
db.execSQL("DROP TABLE blocked_users")
db.execSQL("ALTER TABLE blocked_users_new RENAME TO blocked_users")
}
}

private var instance: FlipcashDatabase? = null
fun requireInstance() = requireNotNull(instance)
fun getInstance(): FlipcashDatabase? = instance
Expand Down Expand Up @@ -206,6 +293,7 @@ abstract class FlipcashDatabase : RoomDatabase() {

instance =
Room.databaseBuilder(context, FlipcashDatabase::class.java, dbName)
.addMigrations(MIGRATION_25_26)
.fallbackToDestructiveMigration()
.build()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.flipcash.app.persistence

import com.flipcash.app.persistence.entities.decomposePending

/**
* Decomposes any profiles the v25→v26 migration staged as raw JSON (see
* [FlipcashDatabase.MIGRATION_25_26]) into the normalized `user_profiles` columns,
* clearing the staging blob as it goes.
*
* Idempotent and safe to call repeatedly: it only touches rows that still carry a blob,
* processing them in batches until none remain. Reads already fall back to the staged
* blob, so this is best-effort cleanup rather than a correctness dependency.
*/
suspend fun FlipcashDatabase.backfillMigratedProfiles(batchSize: Int = 200) {
val dao = userProfileDao()
while (true) {
val batch = dao.pendingMigrationBatch(batchSize)
if (batch.isEmpty()) return
dao.update(batch.map { it.decomposePending() })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,34 @@ class ChatTypeConverters {

// endregion

// region SocialAccount list (normalized user_profiles column)

@TypeConverter
fun fromSocialAccountList(value: String?): List<SocialAccountSerialized>? {
return value?.let { runCatching { json.decodeFromString<List<SocialAccountSerialized>>(it) }.getOrNull() }
}

@TypeConverter
fun toSocialAccountList(accounts: List<SocialAccountSerialized>?): String? {
return accounts?.let { json.encodeToString(it) }
}

// endregion

// region MediaItem (normalized user_profiles avatar column)

@TypeConverter
fun fromMediaItem(value: String?): MediaItem? {
return value?.let { runCatching { json.decodeFromString<MediaItem>(it) }.getOrNull() }
}

@TypeConverter
fun toMediaItem(item: MediaItem?): String? {
return item?.let { json.encodeToString(it) }
}

// endregion

// region ReactionSummary

@TypeConverter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.flipcash.app.persistence.entities.BlockedUserEntity
import com.flipcash.app.persistence.entities.BlockedUserWithProfile

@Dao
interface BlockedUserDao {

/** Blocklist ordered most-recently-blocked first, matching the server's ordering. */
@Transaction
@Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC")
fun observePaged(): PagingSource<Int, BlockedUserEntity>
fun observePaged(): PagingSource<Int, BlockedUserWithProfile>

@Transaction
@Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC")
suspend fun getAll(): List<BlockedUserEntity>
suspend fun getAll(): List<BlockedUserWithProfile>

@Transaction
@Insert(onConflict = OnConflictStrategy.REPLACE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.flipcash.app.persistence.entities.ChatMemberEntity
import com.flipcash.app.persistence.entities.ChatMemberWithProfile
import kotlinx.coroutines.flow.Flow

@Dao
interface ChatMemberDao {

@Transaction
@Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex")
suspend fun getMembersForChat(chatIdHex: String): List<ChatMemberEntity>
suspend fun getMembersForChat(chatIdHex: String): List<ChatMemberWithProfile>

@Transaction
@Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex")
fun observeMembersForChat(chatIdHex: String): Flow<List<ChatMemberEntity>>
fun observeMembersForChat(chatIdHex: String): Flow<List<ChatMemberWithProfile>>

@Transaction
@Query("SELECT * FROM chat_members")
fun observeAll(): Flow<List<ChatMemberEntity>>
fun observeAll(): Flow<List<ChatMemberWithProfile>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ChatMemberEntity)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.flipcash.app.persistence.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.flipcash.app.persistence.entities.UserProfileEntity
import com.flipcash.services.models.chat.MediaItem

@Dao
interface UserProfileDao {

/**
* Authoritative full-profile write (chat member sync). The caller has the complete
* profile, so a whole-row replace is correct — and it clears any staged migration blob.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertFull(profiles: List<UserProfileEntity>)

/**
* Partial write for callers that only know name + avatar (blocklist sync). Inserts a new
* row, or updates *only* those two columns on an existing one — so it never downgrades a
* richer profile already cached from a chat, and never clears a pending migration blob.
*
* 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.
*/
@Query(
"""
INSERT OR REPLACE INTO user_profiles (
user_id_hex, display_name, phone_value, phone_verified,
email_value, email_verified, social_accounts_json,
profile_picture_json, pending_migration_json
) VALUES (
:userIdHex,
:displayName,
(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),
(SELECT email_verified FROM user_profiles WHERE user_id_hex = :userIdHex),
(SELECT social_accounts_json FROM user_profiles WHERE user_id_hex = :userIdHex),
COALESCE(:profilePicture, (SELECT profile_picture_json FROM user_profiles WHERE user_id_hex = :userIdHex)),
(SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = :userIdHex)
)
"""
)
suspend fun upsertNameAndAvatar(userIdHex: String, displayName: String, profilePicture: MediaItem?)

/** A batch of rows still carrying a staged legacy blob; drives [backfillMigratedProfiles]. */
@Query("SELECT * FROM user_profiles WHERE pending_migration_json IS NOT NULL LIMIT :limit")
suspend fun pendingMigrationBatch(limit: Int): List<UserProfileEntity>

@Update
suspend fun update(profiles: List<UserProfileEntity>)

@Query("DELETE FROM user_profiles")
suspend fun deleteAll()
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
package com.flipcash.app.persistence.entities

import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.flipcash.app.persistence.converters.UserProfileSerialized
import androidx.room.Relation

/**
* A single user on the current account's blocklist, cached for offline display.
*
* The server's blocklist entry only carries the user id + when they were blocked, so the
* display profile ([userProfileJson], resolved separately when the page is fetched) is embedded
* here — mirroring how [ChatMemberEntity] embeds a member's profile — so the list renders name +
* avatar without a per-row network lookup.
* The server's blocklist entry only carries the user id + when they were blocked; the
* display profile (name + avatar) lives in the shared, normalized [UserProfileEntity]
* and is joined via [BlockedUserWithProfile] so the list renders without a per-row
* network lookup.
*/
@Entity(tableName = "blocked_users")
data class BlockedUserEntity(
@PrimaryKey @ColumnInfo(name = "user_id_hex") val userIdHex: String,
@ColumnInfo(name = "blocked_at_epoch_ms") val blockedAtEpochMs: Long,
@ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?,
)

/**
* A blocklist row joined to the shared, normalized [UserProfileEntity]. [profile] may be
* null if the user's profile hasn't been cached yet; the read mapper falls back to
* empty display data in that case.
*/
data class BlockedUserWithProfile(
@Embedded val blocked: BlockedUserEntity,
@Relation(parentColumn = "user_id_hex", entityColumn = "user_id_hex")
val profile: UserProfileEntity?,
)
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.flipcash.app.persistence.entities

import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Relation
import com.flipcash.app.persistence.converters.MessagePointerSerialized
import com.flipcash.app.persistence.converters.UserProfileSerialized

@Entity(
tableName = "chat_members",
Expand All @@ -12,6 +13,16 @@ import com.flipcash.app.persistence.converters.UserProfileSerialized
data class ChatMemberEntity(
@ColumnInfo(name = "chat_id_hex") val chatIdHex: String,
@ColumnInfo(name = "user_id_hex") val userIdHex: String,
@ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?,
@ColumnInfo(name = "pointers_json") val pointersJson: List<MessagePointerSerialized>?,
)

/**
* A chat member row joined to the shared, normalized [UserProfileEntity]. [profile] is
* null until the member's profile has been synced (or, transiently, before the v26
* migration backfill runs — the read mapper falls back to the staged blob in that case).
*/
data class ChatMemberWithProfile(
@Embedded val member: ChatMemberEntity,
@Relation(parentColumn = "user_id_hex", entityColumn = "user_id_hex")
val profile: UserProfileEntity?,
)
Loading
Loading