diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt new file mode 100644 index 0000000..84c687b --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt @@ -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() + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 8d38ece..73b6a1b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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 } 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 b45e7dd..ef7f9bf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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, 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 09076bc..f6c74e0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = emptyMap(), + + /** Calls placed and received, newest first. */ + val calls: List = emptyList(), val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, /** Live stories, newest first (ยง6.6). */ val stories: List = 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() } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 406d737..554901c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { + 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 /** diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 24a8192..d3f906b 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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() + androidx.compose.foundation.layout.Column { + CallsScreen(MessengerViewModel(app), + samplePhase().copy(calls = emptyList())) + } + } + @Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase -> androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) } } @@ -238,6 +251,14 @@ class ScreenshotTest { expires = System.currentTimeMillis() / 1000.0 + 1800), 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( MessengerApi.Notification(1, "contact request", "Hoang Nguyen wants to add you", read = false), diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index af10b28..b5933b4 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -60,6 +60,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val AVATARS = "avatars" private const val MY_AVATAR = "my-avatar" 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. */ fun threadForUser(userId: Long) = "user:$userId" @@ -532,6 +535,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun saveOutbox(pending: JSONArray) = 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. */ fun replaceHistory(threadId: String, messages: JSONArray) = writeJson(historyName(threadId), JSONObject().put("messages", messages))