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 @@ -104,8 +104,9 @@ import com.getcode.utils.subByteArray
// cannot be re-fetched, so a version bump that dropped the file would delete the
// half-written messages this table exists to keep.
AutoMigration(from = 35, to = 36), // chat_draft table
AutoMigration(from = 36, to = 37, spec = FlipcashDatabase.Migration36To37::class),
],
version = 36,
version = 37,
)
@TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class)
abstract class FlipcashDatabase : RoomDatabase() {
Expand Down Expand Up @@ -247,6 +248,30 @@ abstract class FlipcashDatabase : RoomDatabase() {
}
}

/**
* Re-arms the viewer-state gate so the new `can_edit` column can actually be filled.
*
* The column arrives as 0 on every existing row, and [ChatMetadataDao.updateViewerStateIfNewer]
* writes only when the incoming version beats the stored one. A chat whose viewer state has
* not otherwise changed reports the version it already reported, so the first fetch after this
* migration would be dropped and the grant would stay 0 until something unrelated moved the
* version — the Edit affordance missing on every chat that existed before the upgrade.
*
* Zeroing the stored version lets that next fetch win. Nothing is lost: every column the gate
* guards is server-owned and re-sent by the same fetch. The mute columns are deliberately left
* alone, so a muted chat reads as muted until that fetch confirms it rather than flickering
* unmuted in between.
*/
class Migration36To37 : AutoMigrationSpec {
override fun onPostMigrate(connection: SQLiteConnection) {
connection.execSQL(REARM_VIEWER_STATE)
}

companion object {
const val REARM_VIEWER_STATE = "UPDATE chat_metadata SET viewer_state_version = 0"
}
}

companion object {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,32 @@ interface ChatMetadataDao {
* from re-applying itself on the way back through an upsert.
*
* Both mute columns are written together so a switch between the two shapes clears the other.
*
* The viewer's grants ride the same gate. They are part of the same server-owned state and
* carry no version of their own, so splitting them out would let a stale payload reinstate a
* grant the newest one had withdrawn.
*
* A stored version of zero is the exception, and it has to be: the server only bumps the
* version when the viewer's state changes, so a chat the viewer has never muted stays at
* zero forever and a strict `>` would reject its every payload — including the grants riding
* along with them. That is not hypothetical. `EditChat` is granted on chats at version zero,
* and gating them out left the edit affordance permanently hidden on a group its owner could
* in fact edit. Zero means nothing has ever been applied here, so anything the server sends
* beats it. Ordering is lost for that first write, but two payloads at version zero describe
* the same unchanged state, and the alternative is storing none of them. The strict gate
* resumes as soon as a non-zero version lands.
*/
@Query(
"UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " +
"mute_forever = :muteForever, viewer_state_version = :version " +
"WHERE chat_id_hex = :chatIdHex AND :version > viewer_state_version"
"mute_forever = :muteForever, viewer_state_version = :version, " +
"can_edit = :canEdit " +
"WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)"
)
suspend fun updateViewerStateIfNewer(
chatIdHex: String,
muteUntilEpochMs: Long?,
muteForever: Boolean,
canEdit: Boolean,
version: Long,
)

Expand All @@ -149,10 +165,13 @@ interface ChatMetadataDao {
* version goes back to zero with it: a rejoin starts the server's numbering over, so a
* retained version would make the gate reject the new state and the old mute would survive a
* chat the user has left and re-entered.
*
* The grants go with it. A chat the user is no longer in grants nothing, and leaving one
* while holding an edit right would otherwise keep the affordance on screen.
*/
@Query(
"UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " +
"viewer_state_version = 0 WHERE chat_id_hex = :chatIdHex"
"viewer_state_version = 0, can_edit = 0 WHERE chat_id_hex = :chatIdHex"
)
suspend fun clearViewerState(chatIdHex: String)

Expand Down Expand Up @@ -189,6 +208,7 @@ interface ChatMetadataDao {
chatIdHex = entity.chatIdHex,
muteUntilEpochMs = entity.muteUntilEpochMs,
muteForever = entity.muteForever,
canEdit = entity.canEdit,
version = entity.viewerStateVersion,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ data class ChatMetadataEntity(
val muteForever: Boolean = false,
@ColumnInfo(name = "viewer_state_version", defaultValue = "0")
val viewerStateVersion: Long = 0,
// The viewer's server-computed grants. Stored rather than left to the fetch that carried it
// because every screen reads the viewer state back out of this row: a grant that lives only
// in the response is already gone by the time anything draws. Defaults false, matching
// ViewerState.Permissions — a column no write has reached yet denies rather than grants.
@ColumnInfo(name = "can_edit", defaultValue = "0")
val canEdit: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class ChatMetadataDaoTest {
muteUntilEpochMs: Long? = null,
muteForever: Boolean = false,
viewerStateVersion: Long = 0,
canEdit: Boolean = false,
) = ChatMetadataEntity(
chatIdHex = chatIdHex,
chatType = chatType,
Expand All @@ -78,6 +79,7 @@ class ChatMetadataDaoTest {
muteUntilEpochMs = muteUntilEpochMs,
muteForever = muteForever,
viewerStateVersion = viewerStateVersion,
canEdit = canEdit,
)

@Test
Expand Down Expand Up @@ -224,6 +226,7 @@ class ChatMetadataDaoTest {
chatIdHex = CHAT_HEX,
muteUntilEpochMs = null,
muteForever = false,
canEdit = false,
version = 5,
)

Expand All @@ -241,17 +244,54 @@ class ChatMetadataDaoTest {
@Test
fun `an older viewer state version leaves the mute alone`() = runTest {
dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0))
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 5)
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 5)

// The mute this supersedes, arriving late.
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, version = 4)
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, version = 4)

val stored = requireNotNull(dao.getById(CHAT_HEX))
assertEquals(true, stored.muteForever)
assertNull(stored.muteUntilEpochMs)
assertEquals(5L, stored.viewerStateVersion)
}

/**
* The server bumps `ViewerState.version` only when the viewer's state changes, so a chat the
* viewer has never muted sits at version zero indefinitely — and its grants ride in on those
* same zero-versioned payloads. A strict `>` rejected every one of them, which is how a group
* its owner could edit ended up with the edit affordance permanently hidden.
*/
@Test
fun `a viewer state at version zero lands on a row that has never had one`() = runTest {
dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0))

dao.updateViewerStateIfNewer(
chatIdHex = CHAT_HEX,
muteUntilEpochMs = null,
muteForever = false,
canEdit = true,
version = 0,
)

assertEquals(true, dao.getById(CHAT_HEX)?.canEdit)
}

/**
* The exception is scoped to the stored zero. Once a real version has landed, a zero-versioned
* payload is a stale one and must not be able to withdraw the grant it carries.
*/
@Test
fun `a viewer state at version zero cannot overwrite a real one`() = runTest {
dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0))
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 3)

dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 0)

val stored = requireNotNull(dao.getById(CHAT_HEX))
assertEquals(true, stored.canEdit)
assertEquals(3L, stored.viewerStateVersion)
}

/**
* The same gate an upsert goes through. A `ChatMetadata` rebuilt from this row reports the
* version it was read at, so pushing it back must be a no-op rather than a re-application.
Expand All @@ -269,7 +309,7 @@ class ChatMetadataDaoTest {
fun `switching to an indefinite mute clears the deadline`() = runTest {
dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1))

dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 2)
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 2)

val stored = requireNotNull(dao.getById(CHAT_HEX))
assertNull(stored.muteUntilEpochMs)
Expand All @@ -293,10 +333,65 @@ class ChatMetadataDaoTest {
assertEquals(0L, stored.viewerStateVersion)

// A rejoin's first state numbers from the bottom again, and must still apply.
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, version = 1)
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, version = 1)
assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs)
}

/**
* The grant carries no version of its own, so it is only ever as current as the viewer state
* that delivered it. A payload that loses the version race must not repaint it.
*/
@Test
fun `an older viewer state leaves the edit grant alone`() = runTest {
dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4))
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 5)

// The withdrawal this supersedes, arriving late.
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 3)

assertEquals(true, dao.getById(CHAT_HEX)?.canEdit)
}

/**
* A withdrawn grant has to reach the row. Ownership moves, and an Edit affordance left on
* screen offers an action the server answers with DENIED.
*/
@Test
fun `a newer viewer state withdraws the edit grant`() = runTest {
dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 4))

dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 5)

assertEquals(false, dao.getById(CHAT_HEX)?.canEdit)
}

/** A chat the user has left grants nothing. */
@Test
fun `clearing the viewer state drops the edit grant`() = runTest {
dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 7))

dao.clearViewerState(CHAT_HEX)

assertEquals(false, dao.getById(CHAT_HEX)?.canEdit)
}

/**
* Rows migrated from v36 hold the new column at 0 and the version the server last sent. The
* next fetch reports that same version, so without the re-arm it loses the gate and the grant
* stays 0 — the Edit affordance missing on every chat that predates the upgrade.
*/
@Test
fun `the v37 re-arm lets the next fetch fill the edit grant`() = runTest {
dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 7))

db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE)

// The version the row already held, which the gate would otherwise drop.
dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 7)

assertEquals(true, dao.getById(CHAT_HEX)?.canEdit)
}

@Test
fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest {
dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ChatMetadataDataSource @Inject constructor(
chatIdHex = mapper.chatIdHex(chatId),
muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute),
muteForever = mapper.isMuteForever(viewerState.mute),
canEdit = viewerState.permissions.canEdit,
version = viewerState.version,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class ChatEntityMapper @Inject constructor() {
muteUntilEpochMs = muteUntilEpochMs(metadata.viewerState?.mute),
muteForever = isMuteForever(metadata.viewerState?.mute),
viewerStateVersion = metadata.viewerState?.version ?: 0,
canEdit = metadata.viewerState?.permissions?.canEdit ?: false,
)
}

Expand Down Expand Up @@ -135,6 +136,10 @@ class ChatEntityMapper @Inject constructor() {
* `isActiveAt(now)`'s answer, asked at render time. Dropping it here would make the row and
* the domain model disagree about what is stored, and a later write gate compare against a
* version whose state had been silently rewritten.
*
* A granted `can_edit` counts as state held about the viewer, so it keeps the row out of the
* null case on its own: a chat nobody has muted still has to report that the viewer may edit
* it, and returning null there would deny an edit the server allows.
*/
private fun ChatMetadataEntity.toViewerState(): ViewerState? {
// Read into a local first: a smart cast will not hold across a module boundary.
Expand All @@ -144,8 +149,12 @@ class ChatEntityMapper @Inject constructor() {
until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until))
else -> null
}
if (mute == null && viewerStateVersion == 0L) return null
return ViewerState(mute = mute, version = viewerStateVersion)
if (mute == null && viewerStateVersion == 0L && !canEdit) return null
return ViewerState(
mute = mute,
version = viewerStateVersion,
permissions = ViewerState.Permissions(canEdit = canEdit),
)
}

// endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,41 @@ class ChatEntityMapperTest {
assertEquals(null, restored.viewerState)
}

@Test
fun `an edit grant survives the round trip`() {
val entity = mapper.toEntity(
groupMetadata().copy(
viewerState = ViewerState(
version = 3,
permissions = ViewerState.Permissions(canEdit = true),
)
)
)
assertEquals(true, entity.canEdit)

val restored = mapper.toMetadata(entity, emptyList(), null)

assertEquals(true, restored.viewerState?.permissions?.canEdit)
}

/**
* A grant is state held about the viewer even when nothing else is: an unmuted chat at
* version 0 that the server says is editable. Folding that row into a null viewer state
* would deny an edit the server allows, so the emptiness test has to count the grant.
*/
@Test
fun `a chat holding only an edit grant still has viewer state`() {
val entity = mapper.toEntity(
groupMetadata().copy(
viewerState = ViewerState(permissions = ViewerState.Permissions(canEdit = true))
)
)

val restored = mapper.toMetadata(entity, emptyList(), null)

assertEquals(true, restored.viewerState?.permissions?.canEdit)
}

private companion object {
const val CHAT_HEX = "aabbccdd"
}
Expand Down
Loading