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:
co-authored by
Claude Opus 5
parent
7719ae062e
commit
8ba0020785
@@ -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
|
||||
* 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 ""
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val then = java.util.Calendar.getInstance().apply { timeInMillis = epochMillis }
|
||||
|
||||
@@ -195,6 +195,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
) {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||
MessengerViewModel.Phase.Screen.CALLS ->
|
||||
CallsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.STORIES ->
|
||||
StoriesFeed(model, current)
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
|
||||
@@ -444,6 +446,10 @@ private fun BottomNav(
|
||||
) {
|
||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.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,
|
||||
KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories")
|
||||
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. */
|
||||
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,
|
||||
/** Live stories, newest first (§6.6). */
|
||||
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
|
||||
* 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. */
|
||||
data class CallState(
|
||||
@@ -295,6 +298,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
previews = session?.previews(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||
) ?: emptyMap(),
|
||||
calls = session?.callLog() ?: emptyList(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
groupsLoaded = fetchedGroups.isSuccess,
|
||||
@@ -449,6 +453,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
previews = session?.previews(
|
||||
fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }
|
||||
) ?: emptyMap(),
|
||||
calls = session?.callLog() ?: emptyList(),
|
||||
myAvatar = session?.myAvatar(),
|
||||
groups = fetchedGroups.getOrDefault(emptyList()),
|
||||
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
|
||||
* 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 {
|
||||
calls?.let { return it }
|
||||
val manager = CallManager(
|
||||
@@ -912,6 +931,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
val active = calls?.current
|
||||
if (state == CallManager.State.IDLE || active == null) {
|
||||
@@ -975,6 +1018,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
private fun placeCall(peerId: Long) = viewModelScope.launch {
|
||||
callWasConnected = false
|
||||
val current = session ?: return@launch
|
||||
val manager = callManager()
|
||||
val turn = withContext(Dispatchers.IO) { current.turnCredentials() }
|
||||
|
||||
@@ -171,6 +171,47 @@ class Session(
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user