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
|
* 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user