feat(ledger): live memory card on the usage screen; retire the debug-only chip

Moves the debug-build memory chip (home top bar) into the resource-usage
screen as a "Memory right now" card visible to everyone: app heap, native
heap, Coil RAM/disk cache fill, LocalCache note/profile/addressable/
chatroom counts, and the device memory class — refreshed every 2s only
while the screen is composed (same off-main collection the chip used,
DiskLruCache.size() contends with journal I/O).

The same snapshot is appended to the NIP-17 usage report (both from the
screen's send button and the high-consumption dialog), so a report now
also shows whether the device was under memory pressure at send time.

MemoryUsageChip.kt is deleted and the chip call removed from
UserDrawerSearchTopBar — the debug feature is retired from the home UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
This commit is contained in:
Claude
2026-07-12 21:24:46 +00:00
parent f0f0056be4
commit 716a7c82cb
6 changed files with 77 additions and 123 deletions
@@ -29,9 +29,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectMemorySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
@@ -60,6 +62,7 @@ fun DisplayResourceUsageAlert(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val alert = remember { mutableStateOf<ResourceUsageAlerts.Alert?>(null) }
LaunchedEffect(accountViewModel) {
@@ -109,10 +112,13 @@ fun DisplayResourceUsageAlert(
Button(onClick = {
nav.nav {
val report =
ResourceUsageReportAssembler().buildReport(
Amethyst.instance.resourceUsage.allDaysIncludingLive(),
Amethyst.instance.resourceUsage.today(),
)
withContext(Dispatchers.IO) {
ResourceUsageReportAssembler().buildReport(
Amethyst.instance.resourceUsage.allDaysIncludingLive(),
Amethyst.instance.resourceUsage.today(),
collectMemorySnapshot(context),
)
}
routeToMessage(
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
draftMessage = report,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.resourceusage
import android.os.Build
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.MemorySnapshot
import java.util.Locale
/**
@@ -35,6 +36,7 @@ class ResourceUsageReportAssembler {
fun buildReport(
days: Map<Long, Map<String, Long>>,
today: Long,
memory: MemorySnapshot? = null,
): String {
val sb = StringBuilder()
sb.append("Resource Usage Report: ")
@@ -59,6 +61,18 @@ class ResourceUsageReportAssembler {
sb.append("\n**Last 7 days**\n\n")
sb.append(summaryTable(UsageSummary.fromDays(weekCounters)))
if (memory != null) {
sb.append("\n**Memory right now**\n\n")
sb.append("| Metric | Value |\n")
sb.append("| --- | --- |\n")
sb.append("| Device class | ${memory.memoryClassMb} MB |\n")
sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n")
sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n")
sb.append("| Image cache (RAM) | ${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB |\n")
sb.append("| Image cache (disk) | ${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB |\n")
sb.append("| Cached notes/users/addressables/chatrooms | ${memory.noteCount}/${memory.userCount}/${memory.addressableCount}/${memory.chatroomCount} |\n")
}
sb.append("\nTechnical details (per epoch-day):\n")
sb.append("```\n")
days.toSortedMap().forEach { (day, counters) ->
@@ -1,116 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.topbars
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.MemorySnapshot
import com.vitorpamplona.amethyst.collectMemorySnapshot
import com.vitorpamplona.amethyst.isDebug
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@Composable
fun MemoryUsageChip() {
if (!isDebug) return
val context = LocalContext.current
var showDetail by remember { mutableStateOf(false) }
val snapshot by produceState<MemorySnapshot?>(null) {
while (true) {
// collectMemorySnapshot reads coil3.disk.DiskLruCache.size(), which is @Synchronized and
// contends with the disk cache's own journal I/O. On cold start that lock is held by a
// background worker for seconds (initial journal read + the burst of image writes), so
// running this on the produceState default (main) dispatcher froze the UI thread —
// the "Loading account" frame couldn't repaint until size() returned. Collect off-main.
value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) }
delay(2_000)
}
}
val s = snapshot ?: return
val chipColor =
when {
s.heapFraction > 0.80f -> Color(0xFFE53935) // red
s.heapFraction > 0.60f -> Color(0xFFFFA000) // amber
else -> Color(0xFF43A047) // green
}
Text(
text = "${s.heapUsedMb}/${s.heapMaxMb}MB",
color = chipColor,
style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.padding(horizontal = 4.dp)
.clickable { showDetail = true },
)
if (showDetail) {
MemoryDetailDialog(snapshot = s, onDismiss = { showDetail = false })
}
}
@Composable
private fun MemoryDetailDialog(
snapshot: MemorySnapshot,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Memory Usage") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Device class: ${snapshot.memoryClassMb} MB")
Text("JVM heap: ${snapshot.heapUsedMb} / ${snapshot.heapMaxMb} MB")
Text("Native heap: ${snapshot.nativeHeapUsedMb} MB")
Text("Coil memory: ${snapshot.imageCacheUsedMb} / ${snapshot.imageCacheMaxMb} MB")
Text("Coil disk: ${snapshot.imageDiskUsedMb} / ${snapshot.imageDiskMaxMb} MB")
Text("Notes: ${snapshot.noteCount}")
Text("Users: ${snapshot.userCount}")
Text("Addressables: ${snapshot.addressableCount}")
Text("Chatrooms: ${snapshot.chatroomCount}")
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
},
)
}
@@ -75,7 +75,6 @@ fun UserDrawerSearchTopBar(
}
},
actions = {
MemoryUsageChip()
IconButton(onClick = { nav.nav(Route.Search) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
}
@@ -36,14 +36,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.MemorySnapshot
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectMemorySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler
@@ -57,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
@@ -79,6 +84,16 @@ fun ResourceUsageScreen(
}
}
// Live memory snapshot, refreshed only while this screen is composed.
// Collected off-main: DiskLruCache.size() contends with journal I/O.
val context = LocalContext.current
val memory by produceState<MemorySnapshot?>(null) {
while (true) {
value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) }
delay(2_000)
}
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(id = R.string.resource_usage_title), nav) },
) { padding ->
@@ -105,7 +120,8 @@ fun ResourceUsageScreen(
UsageSummarySection(R.string.resource_usage_today, todaySummary)
UsageSummarySection(R.string.resource_usage_week, weekSummary)
SubsystemSection(weekSummary)
SendReportSection(accountViewModel, nav, loaded, today)
MemorySection(memory)
SendReportSection(accountViewModel, nav, loaded, today, memory)
}
}
}
@@ -191,12 +207,37 @@ private fun MetricRow(
}
}
@Composable
private fun MemorySection(memory: MemorySnapshot?) {
if (memory == null) return
SettingsSection(R.string.resource_usage_memory_section) {
MetricRow(R.string.resource_usage_memory_heap, "${memory.heapUsedMb} / ${memory.heapMaxMb} MB")
SettingsDivider()
MetricRow(R.string.resource_usage_memory_native, "${memory.nativeHeapUsedMb} MB")
SettingsDivider()
MetricRow(R.string.resource_usage_memory_image_cache, "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB")
SettingsDivider()
MetricRow(R.string.resource_usage_memory_image_disk, "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB")
SettingsDivider()
MetricRow(R.string.resource_usage_memory_notes, memory.noteCount.toString())
SettingsDivider()
MetricRow(R.string.resource_usage_memory_users, memory.userCount.toString())
SettingsDivider()
MetricRow(R.string.resource_usage_memory_addressables, memory.addressableCount.toString())
SettingsDivider()
MetricRow(R.string.resource_usage_memory_chatrooms, memory.chatroomCount.toString())
SettingsDivider()
MetricRow(R.string.resource_usage_memory_device_class, "${memory.memoryClassMb} MB")
}
}
@Composable
private fun SendReportSection(
accountViewModel: AccountViewModel,
nav: INav,
days: Map<Long, Map<String, Long>>,
today: Long,
memory: MemorySnapshot?,
) {
SettingsSection(R.string.resource_usage_send_section) {
Column(
@@ -210,7 +251,7 @@ private fun SendReportSection(
)
Button(
onClick = {
val report = ResourceUsageReportAssembler().buildReport(days, today)
val report = ResourceUsageReportAssembler().buildReport(days, today, memory)
nav.nav {
routeToMessage(
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
+10
View File
@@ -3208,6 +3208,16 @@
<string name="resource_usage_subsystem_push">Push registration</string>
<string name="resource_usage_subsystem_other">Other</string>
<string name="resource_usage_empty">No usage recorded yet.</string>
<string name="resource_usage_memory_section">Memory right now</string>
<string name="resource_usage_memory_heap">App memory (heap)</string>
<string name="resource_usage_memory_native">Native memory</string>
<string name="resource_usage_memory_image_cache">Image cache (RAM)</string>
<string name="resource_usage_memory_image_disk">Image cache (disk)</string>
<string name="resource_usage_memory_notes">Notes in memory</string>
<string name="resource_usage_memory_users">Profiles in memory</string>
<string name="resource_usage_memory_addressables">Addressable events in memory</string>
<string name="resource_usage_memory_chatrooms">Chatroom lists in memory</string>
<string name="resource_usage_memory_device_class">Device memory class</string>
<string name="resource_usage_send_section">Share with the developers</string>
<string name="resource_usage_send_explanation">If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen.</string>
<string name="resource_usage_send_button">Send report via DM</string>