Add a Calls tab, and the call log it needed

The kit's bottom bar is four icons and one of them is a phone; its
"08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call
notification from the relay, and no history at all - so "who rang me yesterday"
had no answer anywhere.

So the log had to exist first. It lives in the vault with everything else: who
somebody called and when is precisely the metadata this app exists to keep off
a server, and encrypting the words while leaving the call log in the clear
would be an odd place to stop. Bounded at 500, because the whole log is
re-sealed on every write.

Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING ->
ACTIVE on the way, and recording each transition would turn one call into five
rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever
connected is captured BEFORE the manager is torn down - asking afterwards
always says no, which would file every answered call as missed.

The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp
direction icon before a 12pt line. Missed calls carry the kit's pink so the row
somebody is actually looking for is the one that stands out, and the tab badges
missed incoming calls only - an answered call is not news.

No video icon, though the kit's row has one at x=255: there is no video path in
either CallManager.

relativeTime is shared with the chat list rather than reimplemented - two
screens formatting times differently is drift nobody files a bug about and
everybody notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 18:38:21 -04:00
co-authored by Claude Opus 5
parent 7719ae062e
commit 8ba0020785
7 changed files with 297 additions and 2 deletions
@@ -0,0 +1,161 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Calls placed and received — the kit's `08 Calls-Listing`.
*
* Measured out of `item/call/White`: a 48dp avatar, the name at 16pt, and a
* second line that is a 12dp direction icon followed by a 12pt time. The kit
* puts a video icon at x=255 and a call icon at x=303; only the call icon is
* here, because there is no video path in either CallManager and a control
* that does nothing is worse than a missing one.
*
* 🔴 **This screen needed a call log to exist first.** Nothing recorded calls
* anywhere — there was a live call UI and a "missed call" notification from the
* relay, and no history at all, so "who rang me yesterday" had no answer. The
* log lives in the vault with everything else: who somebody called and when is
* precisely the metadata this app exists to keep off a server, and encrypting
* the words while leaving the call log in the clear would be an odd place to
* stop.
*/
@Composable
fun ColumnScope.CallsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
Text(
"Calls",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth(),
)
if (phase.calls.isEmpty()) {
// ⚠️ Says what would appear here. An empty screen with no explanation
// is a feature nobody discovers, and this one has no other entry point
// — calls start from a conversation.
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Icon(
KitIcon.Call,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"Calls you place and receive appear here. Start one from a "
+ "conversation — they go directly between the two phones, "
+ "so this list is the only record of them and it is on "
+ "this device.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
}
return
}
Column(modifier = Modifier.fillMaxWidth().testTag("calls")) {
phase.calls.forEach { entry ->
val name = phase.buddies.firstOrNull { it.userId == entry.peerId }
?.let { it.displayName.ifBlank { it.handle } }
?: "user ${entry.peerId}"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.requestCall(entry.peerId) }
.padding(vertical = Kit.rowGap / 2)
.testTag("call-${entry.peerId}-${entry.at}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Avatar(
name = name,
online = false,
picture = phase.avatars[entry.peerId],
model = model,
)
Column(modifier = Modifier.weight(1f)) {
Text(
name,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// 🔴 The direction icon the kit puts before the time,
// and the one thing that makes a missed call findable:
// an incoming call that never connected is the row
// somebody is actually looking for.
Icon(
if (entry.connected) KitIcon.Call else KitIcon.CallEnd,
contentDescription = null,
tint = if (entry.connected) Palette.SecondaryText else Palette.Pink,
modifier = Modifier.size(12.dp),
)
Spacer(Modifier.width(6.dp))
Text(
buildString {
append(if (entry.incoming) "Incoming" else "Outgoing")
if (!entry.connected) append(" · missed")
append(" · ")
append(relativeTime(entry.at))
},
fontSize = 12.sp,
color = if (entry.connected) Palette.SecondaryText else Palette.Pink,
maxLines = 1,
)
}
}
// Ring them back, which is the only thing this row is for.
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
.clip(CircleShape)
.background(Palette.AccentSoft)
.clickable { model.requestCall(entry.peerId) }
.testTag("call-back-${entry.peerId}"),
contentAlignment = Alignment.Center,
) {
Icon(
KitIcon.Call,
contentDescription = "Call $name",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(Kit.icon),
)
}
}
Divider()
}
}
}
@@ -330,7 +330,7 @@ fun ColumnScope.BuddyList(
* then a date. Not "3 hours ago" — the design shows a clock, and a relative * then a date. Not "3 hours ago" — the design shows a clock, and a relative
* phrase is wider than the column it has to sit in. * phrase is wider than the column it has to sit in.
*/ */
private fun relativeTime(epochMillis: Long): String { internal fun relativeTime(epochMillis: Long): String {
if (epochMillis <= 0L) return "" if (epochMillis <= 0L) return ""
val now = java.util.Calendar.getInstance() val now = java.util.Calendar.getInstance()
val then = java.util.Calendar.getInstance().apply { timeInMillis = epochMillis } val then = java.util.Calendar.getInstance().apply { timeInMillis = epochMillis }
@@ -195,6 +195,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
) { ) {
when (current.screen) { when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
MessengerViewModel.Phase.Screen.CALLS ->
CallsScreen(model, current)
MessengerViewModel.Phase.Screen.STORIES -> MessengerViewModel.Phase.Screen.STORIES ->
StoriesFeed(model, current) StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
@@ -444,6 +446,10 @@ private fun BottomNav(
) { ) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
KitIcon.Chats, KitIcon.ChatsActive, "Chats", phase.unread.size, "nav-contacts") KitIcon.Chats, KitIcon.ChatsActive, "Chats", phase.unread.size, "nav-contacts")
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")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES,
KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories") KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories")
NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS, NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
@@ -100,6 +100,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** Last line per thread, so a row can say more than a name. */ /** Last line per thread, so a row can say more than a name. */
val previews: Map<Long, Session.Preview> = emptyMap(), val previews: Map<Long, Session.Preview> = emptyMap(),
/** Calls placed and received, newest first. */
val calls: List<Session.CallEntry> = emptyList(),
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
/** Live stories, newest first (§6.6). */ /** Live stories, newest first (§6.6). */
val stories: List<Vault.Story> = emptyList(), val stories: List<Vault.Story> = emptyList(),
@@ -139,7 +142,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* entry point — that is where you see there IS something — but a * entry point — that is where you see there IS something — but a
* card feed cannot live above a contact list without pushing it off. * card feed cannot live above a contact list without pushing it off.
*/ */
enum class Screen { CONTACTS, STORIES, NOTIFICATIONS, ACCOUNT } enum class Screen { CONTACTS, CALLS, STORIES, NOTIFICATIONS, ACCOUNT }
/** What the call UI needs to draw itself. */ /** What the call UI needs to draw itself. */
data class CallState( data class CallState(
@@ -295,6 +298,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
previews = session?.previews( previews = session?.previews(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(), ) ?: emptyMap(),
calls = session?.callLog() ?: emptyList(),
myAvatar = session?.myAvatar(), myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()), groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess, groupsLoaded = fetchedGroups.isSuccess,
@@ -449,6 +453,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
previews = session?.previews( previews = session?.previews(
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
) ?: emptyMap(), ) ?: emptyMap(),
calls = session?.callLog() ?: emptyList(),
myAvatar = session?.myAvatar(), myAvatar = session?.myAvatar(),
groups = fetchedGroups.getOrDefault(emptyList()), groups = fetchedGroups.getOrDefault(emptyList()),
groupsLoaded = fetchedGroups.isSuccess, groupsLoaded = fetchedGroups.isSuccess,
@@ -900,6 +905,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* exactly once per process; a second call is not idempotent and takes the * exactly once per process; a second call is not idempotent and takes the
* process with it. * process with it.
*/ */
/** Whether the call now ending ever reached ACTIVE. */
private var callWasConnected = false
/** Guards against filing the same call twice: ENDED is often followed by IDLE. */
private var callFiled = false
/** Re-read the log after a call, so the Calls tab is current. */
private suspend fun refreshCallLog() {
val log = withContext(Dispatchers.IO) {
runCatching { session?.callLog() }.getOrNull() ?: emptyList()
}
update { it.copy(calls = log) }
}
private fun callManager(): CallManager { private fun callManager(): CallManager {
calls?.let { return it } calls?.let { return it }
val manager = CallManager( val manager = CallManager(
@@ -912,6 +931,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
} }
}, },
onStateChanged = { state -> onStateChanged = { state ->
// 🔴 Filed once, at the end. WebRTC walks DIALLING → RINGING →
// CONNECTING → ACTIVE on the way to a call, and recording each
// transition would turn one call into five rows.
//
// ⚠️ `everConnected` is read BEFORE the manager is torn down.
// Asking afterwards always says no, which would file every
// answered call as missed.
if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) {
calls?.current?.let { active ->
if (!callFiled) {
callFiled = true
val connected = callWasConnected
viewModelScope.launch(Dispatchers.IO) {
runCatching {
session?.recordCall(active.peerId, active.incoming, connected)
}
refreshCallLog()
}
}
}
} else {
callFiled = false
if (state == CallManager.State.ACTIVE) callWasConnected = true
}
update { phase -> update { phase ->
val active = calls?.current val active = calls?.current
if (state == CallManager.State.IDLE || active == null) { if (state == CallManager.State.IDLE || active == null) {
@@ -975,6 +1018,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
} }
private fun placeCall(peerId: Long) = viewModelScope.launch { private fun placeCall(peerId: Long) = viewModelScope.launch {
callWasConnected = false
val current = session ?: return@launch val current = session ?: return@launch
val manager = callManager() val manager = callManager()
val turn = withContext(Dispatchers.IO) { current.turnCredentials() } val turn = withContext(Dispatchers.IO) { current.turnCredentials() }
@@ -171,6 +171,47 @@ class Session(
return sent return sent
} }
/** One call, as the list draws it. */
data class CallEntry(
val peerId: Long,
val incoming: Boolean,
/** Whether it was ever actually connected — a missed call is the
* interesting row on this screen, so it has to be distinguishable. */
val connected: Boolean,
val at: Long,
)
fun callLog(): List<CallEntry> {
val stored = vault.callLog()
return (0 until stored.length()).mapNotNull { i ->
stored.optJSONObject(i)?.let {
CallEntry(
peerId = it.optLong("peer"),
incoming = it.optBoolean("in"),
connected = it.optBoolean("ok"),
at = it.optLong("at"),
)
}
}.sortedByDescending { it.at }
}
/**
* File a finished call.
*
* ⚠️ Called once, when the call reaches its end — not on every state
* change. WebRTC passes through DIALLING, RINGING, CONNECTING and ACTIVE
* on the way, and recording each of them would make one call into five
* rows.
*/
fun recordCall(peerId: Long, incoming: Boolean, connected: Boolean,
at: Long = System.currentTimeMillis()) {
val log = vault.callLog()
log.put(JSONObject()
.put("peer", peerId).put("in", incoming)
.put("ok", connected).put("at", at))
vault.saveCallLog(log)
}
// MARK: - Outbox // MARK: - Outbox
/** /**
@@ -100,6 +100,19 @@ class ScreenshotTest {
} }
} }
@Test fun callsScreen() = shootFullScreen("screen-calls") { model, phase ->
androidx.compose.foundation.layout.Column { CallsScreen(model, phase) }
}
@Test fun callsEmpty() = shoot("screen-calls-empty") {
val app = androidx.test.core.app.ApplicationProvider
.getApplicationContext<android.app.Application>()
androidx.compose.foundation.layout.Column {
CallsScreen(MessengerViewModel(app),
samplePhase().copy(calls = emptyList()))
}
}
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase -> @Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) } androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
} }
@@ -238,6 +251,14 @@ class ScreenshotTest {
expires = System.currentTimeMillis() / 1000.0 + 1800), expires = System.currentTimeMillis() / 1000.0 + 1800),
caption = "", postedAt = System.currentTimeMillis(), seen = true), caption = "", postedAt = System.currentTimeMillis(), seen = true),
), ),
calls = listOf(
Session.CallEntry(peerId = 32, incoming = true, connected = false,
at = System.currentTimeMillis() - 40 * 60_000L),
Session.CallEntry(peerId = 33, incoming = false, connected = true,
at = System.currentTimeMillis() - 5 * 3600_000L),
Session.CallEntry(peerId = 32, incoming = true, connected = true,
at = System.currentTimeMillis() - 30 * 3600_000L),
),
notifications = listOf( notifications = listOf(
MessengerApi.Notification(1, "contact request", MessengerApi.Notification(1, "contact request",
"Hoang Nguyen wants to add you", read = false), "Hoang Nguyen wants to add you", read = false),
@@ -60,6 +60,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val AVATARS = "avatars" private const val AVATARS = "avatars"
private const val MY_AVATAR = "my-avatar" private const val MY_AVATAR = "my-avatar"
private const val OUTBOX = "outbox" private const val OUTBOX = "outbox"
private const val CALL_LOG = "call-log"
/** Kept because the whole log is re-sealed on every write. */
private const val CALL_LOG_MAX = 500
/** The two kinds of thread, named once so they cannot drift apart. */ /** The two kinds of thread, named once so they cannot drift apart. */
fun threadForUser(userId: Long) = "user:$userId" fun threadForUser(userId: Long) = "user:$userId"
@@ -532,6 +535,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun saveOutbox(pending: JSONArray) = fun saveOutbox(pending: JSONArray) =
writeJson(OUTBOX, JSONObject().put("pending", pending)) writeJson(OUTBOX, JSONObject().put("pending", pending))
/**
* Calls placed and received, newest last.
*
* ⚠️ In the vault like everything else. Who somebody rang and when is
* exactly the metadata this app exists to keep off a server, and it would
* be strange to encrypt the words and leave the call log in the clear.
*
* ⚠️ Bounded. A log nobody prunes grows for the life of the install, and
* this one is re-sealed on every write.
*/
fun callLog(): JSONArray = readJson(CALL_LOG)?.optJSONArray("calls") ?: JSONArray()
fun saveCallLog(calls: JSONArray) {
val trimmed = if (calls.length() <= CALL_LOG_MAX) calls else JSONArray().also { out ->
for (i in (calls.length() - CALL_LOG_MAX) until calls.length()) out.put(calls.get(i))
}
writeJson(CALL_LOG, JSONObject().put("calls", trimmed))
}
/** Rewrite a thread wholesale, for restating a message's delivery state. */ /** Rewrite a thread wholesale, for restating a message's delivery state. */
fun replaceHistory(threadId: String, messages: JSONArray) = fun replaceHistory(threadId: String, messages: JSONArray) =
writeJson(historyName(threadId), JSONObject().put("messages", messages)) writeJson(historyName(threadId), JSONObject().put("messages", messages))