calls badge clears on view; outbox tests track the directory-first send

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 15:21:03 -04:00
co-authored by Claude Opus 4.8
parent 7727cfa432
commit 31548674de
3 changed files with 38 additions and 7 deletions
@@ -533,8 +533,10 @@ private fun BottomNav(
KitIcon.Chats, KitIcon.ChatsActive, "Messages", phase.unread.size, "nav-messages")
NavButton(model, phase, MessengerViewModel.Phase.Screen.CALLS,
KitIcon.Call, KitIcon.Call, "Calls",
// Missed calls are worth a badge; answered ones are not news.
phase.calls.count { !it.connected && it.incoming }, "nav-calls")
// Missed calls SINCE the tab was last opened — it used to count
// every missed call in the log for ever, a badge nothing could
// clear. Opening the tab is what "seeing" them means.
model.missedCallsUnseen(phase), "nav-calls")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES,
KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories")
// Alerts left the bar 2026-08-28 — five tabs plus it was crowded, and
@@ -709,7 +709,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun closeChat() = update { it.copy(openChat = null) }
fun showScreen(screen: Phase.Screen) = update { it.copy(screen = screen, error = null) }
fun showScreen(screen: Phase.Screen) {
// Opening the Calls tab is what clears its missed badge.
if (screen == Phase.Screen.CALLS) markCallsSeen()
update { it.copy(screen = screen, error = null) }
}
// -- Contacts: the rest of the CRUD --------------------------------------
@@ -1109,6 +1113,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** Remembered across the privacy prompt, so "Understood, call" keeps it. */
private var pendingCallVideo = false
/** Missed incoming calls newer than the last time the Calls tab was open. */
fun missedCallsUnseen(phase: Phase.SignedIn): Int {
val seenAt = callPrefs().getLong(CALLS_SEEN_AT, 0L)
return phase.calls.count { !it.connected && it.incoming && it.at > seenAt }
}
/** The Calls tab was opened: everything currently missed counts as seen. */
fun markCallsSeen() {
callPrefs().edit().putLong(CALLS_SEEN_AT, System.currentTimeMillis()).apply()
// Nudge the state so the badge re-reads the pref.
update { it.copy() }
}
fun requestCall(peerId: Long, video: Boolean = false) {
pendingCallVideo = video
if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) placeCall(peerId, video)
@@ -1656,6 +1673,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
private companion object {
const val CALLS_SEEN_AT = "calls_seen_at"
const val POLL_INTERVAL_MS = 5_000L
const val CALL_SETUP_POLL_MS = 1_000L
}
@@ -162,6 +162,10 @@ class OutboxTest {
// than widening its visibility for a test.
.put("pub", android.util.Base64.encodeToString(
opk.publicKey, android.util.Base64.NO_WRAP)))
// The send asks the directory first (free), THEN the key route (which
// pops a prekey) — two requests now, same payload shape.
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json("""{"ok":true}""")) // the envelope post
@@ -207,6 +211,9 @@ class OutboxTest {
.put("id", opk.id)
.put("pub", android.util.Base64.encodeToString(
opk.publicKey, android.util.Base64.NO_WRAP)))
// Directory first, then keys — see the note in the first test.
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json("""{"ok":true}"""))
@@ -248,11 +255,13 @@ class OutboxTest {
// Member 101 registers; 102 is still away.
val m1 = DeviceIdentity.create()
val opk1 = m1.generateOneTimePreKeys(1).first()
server.enqueue(json(org.json.JSONObject().put("devices",
val m1Wire = org.json.JSONObject().put("devices",
org.json.JSONArray().put(m1.registrationWire().put("opk",
org.json.JSONObject().put("id", opk1.id).put("pub",
android.util.Base64.encodeToString(opk1.publicKey,
android.util.Base64.NO_WRAP))))).toString()))
android.util.Base64.NO_WRAP))))).toString()
server.enqueue(json(m1Wire)) // the directory
server.enqueue(json(m1Wire)) // the key route
server.enqueue(json("""{"ok":true}"""))
server.enqueue(json("""{"devices":[]}""")) // 102 still gone
session.flushOutbox()
@@ -265,11 +274,13 @@ class OutboxTest {
// Member 102 comes back.
val m2 = DeviceIdentity.create()
val opk2 = m2.generateOneTimePreKeys(1).first()
server.enqueue(json(org.json.JSONObject().put("devices",
val m2Wire = org.json.JSONObject().put("devices",
org.json.JSONArray().put(m2.registrationWire().put("opk",
org.json.JSONObject().put("id", opk2.id).put("pub",
android.util.Base64.encodeToString(opk2.publicKey,
android.util.Base64.NO_WRAP))))).toString()))
android.util.Base64.NO_WRAP))))).toString()
server.enqueue(json(m2Wire)) // the directory
server.enqueue(json(m2Wire)) // the key route
server.enqueue(json("""{"ok":true}"""))
val touched = session.flushOutbox()