Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ba8a631
feat(notifications): model push-triggered work as PushAction
bmc08gt Sep 9, 2026
106a80d
feat(notifications): extract push handling rules into a pure planner
bmc08gt Sep 9, 2026
0b902c0
refactor(notifications): route onMessageReceived through the planner
bmc08gt Sep 9, 2026
ddae046
feat(notifications): handle data-only pushes behind PushSilentSync flag
bmc08gt Sep 9, 2026
f718f1c
feat(notifications): report the app's own standby bucket
bmc08gt Sep 9, 2026
71c2d39
feat(notifications): trace push delivery latency, bucket and priority
bmc08gt Sep 9, 2026
2471f7b
fix(notifications): record push trace fields that were being dropped
bmc08gt Sep 9, 2026
2cc1f13
refactor(notifications): make the push event switch a table
bmc08gt Sep 10, 2026
5ddb375
fix(notifications): only read the silent-sync flag when it can change…
bmc08gt Sep 10, 2026
be0e48d
chore(spike): add the push delivery measurement harness
bmc08gt Sep 10, 2026
02f2d3a
docs(spikes): add the standby-bucket delivery results
bmc08gt Sep 10, 2026
11ae878
feat(spike): measure per-push CPU from /proc instead of log volume
bmc08gt Sep 10, 2026
76c911f
docs(spikes): read the idle CPU term directly, and size the budget on it
bmc08gt Sep 10, 2026
1ef6434
chore(deps): bump flipcash2-client-protocol to 0.5.0
bmc08gt Sep 10, 2026
c5f025f
feat(chat): persist a push-carried message without an RPC
bmc08gt Sep 10, 2026
427c84c
feat(notifications): plan the carried message instead of a fetch
bmc08gt Sep 10, 2026
64e6eca
docs(spikes): retire the fitted idle term where it is still stated as…
bmc08gt Sep 10, 2026
0a8796a
refactor(notifications): drop the PushSilentSync flag
bmc08gt Sep 10, 2026
a6e53b0
test(chat): cover the write a push-carried message performs
bmc08gt Sep 11, 2026
82b4ead
test(persistence): pin upsert's event_sequence guard
bmc08gt Sep 11, 2026
3ebd3a2
docs(persistence): say why the event-sequence guard is strict
bmc08gt Sep 11, 2026
b3988ee
fix(spike): end the CPU cell when the killer takes the process
bmc08gt Sep 11, 2026
08a2590
docs(spike): size the preload against a measured push cost
bmc08gt Sep 11, 2026
15dda0f
Merge branch 'code/cash' into feat/push-silent-sync
bmc08gt Sep 11, 2026
4e0a542
refactor(chat): resolve stream messages from events only
bmc08gt Sep 11, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ maestro/screenshots/results/
maestro/screenshots/diffs/

docs/superpowers/

# Raw spike capture: full device logcat, so it can carry unrelated personal
# content. Keep the summarised results, not the source logs.
docs/spikes/raw/
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ interface MessagingOperations {
/** Fetches the full message history for [chatId] from the server and persists locally. */
suspend fun loadMessages(chatId: ChatId)

/**
* Persists a message the server delivered inside a push payload, without an RPC.
*
* Idempotent on `eventSequence`: a copy that is not newer than the stored row is
* dropped, so re-delivery of the same push and a following [loadMessages] converge
* on the same transcript.
*/
suspend fun applyPushedMessage(chatId: ChatId, message: ChatMessage)

/**
* Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ import kotlin.time.Duration.Companion.seconds
* local persistence and in-memory state.
*
* Responsibilities:
* - **Message persistence** — resolves messages from `ChatUpdate.events` (preferred)
* or the deprecated `newMessages` field, then upserts to Room.
* - **Message persistence** — resolves messages from `ChatUpdate.events`, then
* upserts to Room.
* - **Gap-aware event sequencing** — uses [EventSequenceTracker] to maintain a
* contiguous frontier. Only the highest contiguous sequence is persisted; if a
* gap is detected, a timed [getDelta][performDeltaSync] backfill is scheduled.
Expand Down Expand Up @@ -290,17 +290,12 @@ class EventStreamDelegate @Inject constructor(
private suspend fun applyUpdate(update: ChatUpdate) {
val chatId = update.chatId

// --- Resolve messages: prefer events, fall back to deprecated newMessages ---
// --- Resolve messages from the event log ---

val resolvedMessages = if (update.events.isNotEmpty()) {
update.events
.flatMap { event -> event.mutations.map { it.message } }
.sortedBy { it.eventSequence }
.distinctBy { it.messageId }
} else {
@Suppress("DEPRECATION")
update.newMessages
}
val resolvedMessages = update.events
.flatMap { event -> event.mutations.map { it.message } }
.sortedBy { it.eventSequence }
.distinctBy { it.messageId }

trace(
tag = TAG,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,20 @@ class MessagingDelegate @Inject constructor(
}
}

override suspend fun applyPushedMessage(chatId: ChatId, message: ChatMessage) {
// The DAO's upsert already drops a copy older than the stored row, so the only
// ordering this has to protect is the metadata below it.
messageDataSource.upsert(chatId, listOf(message))

// Deliberately not advancing the event-log cursor. A push carries one message, not
// a page, so seating the cursor at its sequence would let a later catch-up resume
// from a frontier it never actually fetched and skip whatever it missed in between.
if (message.messageId > (metadataDataSource.getLastMessageId(chatId) ?: 0L)) {
metadataDataSource.updateLastMessageId(chatId, message.messageId)
metadataDataSource.updateLastActivity(chatId, message.timestamp.toEpochMilliseconds())
}
}

override suspend fun sendMessage(
chatId: ChatId,
content: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.controllers.EventStreamingController
import com.flipcash.services.models.chat.ChatEvent
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatMutation
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.internal.ChatIdGenerator
Expand Down Expand Up @@ -156,12 +158,24 @@ class ChatCoordinatorEagerBalanceTest {
unreadSeq = 0,
)

// Each update carries its messages as event-log events. Sequences run
// contiguously from 1 across the whole test so the gap detector stays quiet
// and these tests exercise only the behaviour they name.
private var nextEventSequence = 1L

private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate(
chatId = chatId,
newMessages = messages.toList(),
pointerUpdates = emptyList(),
typingNotifications = emptyList(),
metadataUpdates = emptyList(),
events = messages.map { message ->
ChatEvent(
sequence = nextEventSequence++,
count = 1,
ts = message.timestamp,
mutations = listOf(ChatMutation.MessageSent(message)),
)
},
)

private suspend fun triggerCollection() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,56 +161,7 @@ class ChatCoordinatorEventsTest {
coordinator.onUserLoggedIn(mockk(relaxed = true))
}

// region Events vs newMessages

@Test
fun `events are preferred over deprecated newMessages`() = runTest(testDispatchers.dispatcher) {
triggerCollection()

val eventMsg = textMessage(id = 1, eventSequence = 1)
val deprecatedMsg = textMessage(id = 99)

@Suppress("DEPRECATION")
val update = ChatUpdate(
chatId = chatId,
newMessages = listOf(deprecatedMsg),
events = listOf(chatEvent(1, eventMsg)),
)
chatUpdatesChannel.send(update)
advanceTimeBy(1_000.milliseconds)
runCurrent()

// Should upsert the event message, not the deprecated one
coVerify {
messageDataSource.upsert(chatId, match { messages ->
messages.size == 1 && messages[0].messageId == 1L
})
}
coordinator.teardown()
}

@Test
fun `falls back to newMessages when events is empty`() = runTest(testDispatchers.dispatcher) {
triggerCollection()

val msg = textMessage(id = 42)
@Suppress("DEPRECATION")
val update = ChatUpdate(
chatId = chatId,
newMessages = listOf(msg),
events = emptyList(),
)
chatUpdatesChannel.send(update)
advanceTimeBy(1_000.milliseconds)
runCurrent()

coVerify {
messageDataSource.upsert(chatId, match { messages ->
messages.size == 1 && messages[0].messageId == 42L
})
}
coordinator.teardown()
}
// region Event message resolution

@Test
fun `multiple events are flattened and deduped by messageId`() = runTest(testDispatchers.dispatcher) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.flipcash.shared.chat

import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Instant

/**
* Covers the write a push-carried message performs in place of a fetch.
*
* Three properties matter here, and none of them are visible from the planner: the write
* reaches the same data source a fetched page would, the event cursor stays where it was,
* and the feed row only moves forward.
*/
class MessagingPushedMessageTest {

private val chatId = ChatId("aabbccdd")

private fun message(id: Long, eventSequence: Long, epochSeconds: Long = 1_757_000_000) =
ChatMessage(
messageId = id,
senderId = listOf<Byte>(4, 5, 6),
content = listOf(MessageContent.Text("msg-$id")),
timestamp = Instant.fromEpochSeconds(epochSeconds),
unreadSeq = 0,
eventSequence = eventSequence,
)

private fun delegateWith(
metadataDataSource: ChatMetadataDataSource,
messageDataSource: ChatMessageDataSource = mockk(relaxed = true),
messagingController: ChatMessagingController = mockk(relaxed = true),
) = MessagingDelegate(
chatController = mockk(relaxed = true),
messagingController = messagingController,
metadataDataSource = metadataDataSource,
messageDataSource = messageDataSource,
memberDataSource = mockk(relaxed = true),
notificationManager = mockk(relaxed = true),
userManager = mockk(relaxed = true),
stateHolder = mockk(relaxed = true),
analytics = mockk(relaxed = true),
)

@Test
fun `a pushed message is written through the same source a fetched page uses`() = runTest {
val pushed = message(id = 12, eventSequence = 9)
val messageDataSource = mockk<ChatMessageDataSource>(relaxed = true)
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 11

delegateWith(metadataDataSource, messageDataSource).applyPushedMessage(chatId, pushed)

coVerify(exactly = 1) { messageDataSource.upsert(chatId, listOf(pushed)) }
}

@Test
fun `applying a pushed message makes no network call`() = runTest {
val messagingController = mockk<ChatMessagingController>(relaxed = true)
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 0

delegateWith(metadataDataSource, messagingController = messagingController)
.applyPushedMessage(chatId, message(id = 1, eventSequence = 3))

coVerify(exactly = 0) { messagingController.getMessages(any(), any()) }
}

@Test
fun `a pushed message does not seat the event cursor`() = runTest {
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 0

delegateWith(metadataDataSource).applyPushedMessage(chatId, message(id = 12, eventSequence = 99))

// A push carries one message, not a page. Seating the cursor at its sequence would let a
// later catch-up resume from a frontier it never fetched.
coVerify(exactly = 0) { metadataDataSource.updateLatestEventSequence(chatId, any()) }
}

@Test
fun `a newer pushed message moves the feed row forward`() = runTest {
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 11

delegateWith(metadataDataSource)
.applyPushedMessage(chatId, message(id = 12, eventSequence = 9, epochSeconds = 1_757_000_042))

coVerify(exactly = 1) { metadataDataSource.updateLastMessageId(chatId, 12) }
coVerify(exactly = 1) { metadataDataSource.updateLastActivity(chatId, 1_757_000_042_000) }
}

@Test
fun `a re-delivered push does not rewind the feed row`() = runTest {
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 20

delegateWith(metadataDataSource).applyPushedMessage(chatId, message(id = 12, eventSequence = 9))

coVerify(exactly = 0) { metadataDataSource.updateLastMessageId(chatId, any()) }
coVerify(exactly = 0) { metadataDataSource.updateLastActivity(chatId, any()) }
}

@Test
fun `the same push applied twice moves the feed row once`() = runTest {
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns 11 andThen 12
val pushed = message(id = 12, eventSequence = 9)
val delegate = delegateWith(metadataDataSource)

delegate.applyPushedMessage(chatId, pushed)
delegate.applyPushedMessage(chatId, pushed)

coVerify(exactly = 1) { metadataDataSource.updateLastMessageId(chatId, 12) }
}

@Test
fun `a chat with no stored message id accepts the first push`() = runTest {
val metadataDataSource = mockk<ChatMetadataDataSource>(relaxed = true)
coEvery { metadataDataSource.getLastMessageId(chatId) } returns null

delegateWith(metadataDataSource).applyPushedMessage(chatId, message(id = 1, eventSequence = 3))

coVerify(exactly = 1) { metadataDataSource.updateLastMessageId(chatId, 1) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.controllers.EventStreamingController
import com.flipcash.services.models.chat.ChatEvent
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatMutation
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.user.UserManager
Expand Down Expand Up @@ -180,15 +182,24 @@ class ReceivedCounterTest {
unreadSeq = 0,
)

// newMessages is deprecated in favour of `events`, but applyUpdate still falls
// back to it and every existing chat test builds updates this way. Matching the
// existing harness keeps these tests readable next to their neighbours.
// Each update carries its messages as event-log events. Sequences run
// contiguously from 1 across the whole test so the gap detector stays quiet
// and these tests exercise only the behaviour they name.
private var nextEventSequence = 1L

private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate(
chatId = chatId,
newMessages = messages.toList(),
pointerUpdates = emptyList(),
typingNotifications = emptyList(),
metadataUpdates = emptyList(),
events = messages.map { message ->
ChatEvent(
sequence = nextEventSequence++,
count = 1,
ts = message.timestamp,
mutations = listOf(ChatMutation.MessageSent(message)),
)
},
)

private suspend fun TestScope.deliver(vararg messages: ChatMessage) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.controllers.EventStreamingController
import com.flipcash.services.models.chat.ChatEvent
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatMutation
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
Expand Down Expand Up @@ -183,15 +185,24 @@ class ReceivedEventTest {
unreadSeq = 0,
)

// newMessages is deprecated in favour of `events`, but applyUpdate still falls
// back to it and every existing chat test builds updates this way. Matching the
// existing harness keeps these tests readable next to their neighbours.
// Each update carries its messages as event-log events. Sequences run
// contiguously from 1 across the whole test so the gap detector stays quiet
// and these tests exercise only the behaviour they name.
private var nextEventSequence = 1L

private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate(
chatId = chatId,
newMessages = messages.toList(),
pointerUpdates = emptyList(),
typingNotifications = emptyList(),
metadataUpdates = emptyList(),
events = messages.map { message ->
ChatEvent(
sequence = nextEventSequence++,
count = 1,
ts = message.timestamp,
mutations = listOf(ChatMutation.MessageSent(message)),
)
},
)

private suspend fun TestScope.deliver(vararg messages: ChatMessage) {
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/shared/notifications/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:phone"))
implementation(project(":apps:flipcash:shared:push"))
Expand Down
Loading
Loading