diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index aa96def..f220382 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 398685c..6d1fd13 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index a520b9c..0a39756 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -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()