From 3feb0049e64a3386c7840154f8f7b88fa95e056c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:29:33 +0000 Subject: [PATCH 01/39] perf: reduce background and mobile-data battery usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five targeted fixes for the biggest idle drains: - NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for the whole life of the process even after disconnect() (i.e. the entire time the app sat in the background). isActive is now a StateFlow and the loop suspends on it, so an inactive client schedules zero timers. - Relay WebSocket pings on cellular: every ping on an idle connection promotes the radio out of its low-power state, per connection. The mobile-data client now pings every 240s instead of 120s (wifi keeps 120s), staying under common carrier NAT idle timeouts. - Always-on service watchdog: the 5-min health-check alarm no longer uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a service that can't do useful network work on a sleeping device was pure cost; the alarm now fires as soon as the device is next awake, and the WorkManager + FCM/UnifiedPush layers still cover doze. - ScheduledPostWorker: the 15-min periodic worker was enqueued forever for every user, waking (often cold-starting) the process with nothing to publish. It is now enqueued exactly while the store holds a PENDING post, driven by a store-flow observer in AppModules. - CalendarReminderWorker: same unconditional 15-min periodic schedule, but worse — LocalCache is memory-only, so a WorkManager wake of a dead process can never find an RSVP to remind about. The worker is now scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its own chain when nothing upcoming remains (or the feature is disabled). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- PULL_NOTIFICATION.md | 10 +++- .../com/vitorpamplona/amethyst/AppModules.kt | 56 +++++++++++++++---- .../calendar/CalendarReminderWorker.kt | 33 ++++++++++- .../notifications/ServiceWatchdogManager.kt | 7 ++- .../okhttp/OkHttpClientFactoryForRelays.kt | 23 +++++++- .../scheduledposts/ScheduledPostWorker.kt | 23 ++++++-- .../CalendarReminderSettingsScreen.kt | 13 +++-- .../nip01Core/relay/client/NostrClient.kt | 35 ++++++++---- 8 files changed, 163 insertions(+), 37 deletions(-) diff --git a/PULL_NOTIFICATION.md b/PULL_NOTIFICATION.md index ef71d19e2a..bd407f484b 100644 --- a/PULL_NOTIFICATION.md +++ b/PULL_NOTIFICATION.md @@ -158,13 +158,17 @@ Google Play Services infrastructure. ### Layer 4: AlarmManager Watchdog (5 minutes) -**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5 +**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5 minutes. The receiver checks if the service should be running and restarts it. **Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail (sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't -delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to -wake the device from sleep, ensuring the check happens even in Doze. +delivered), the watchdog will catch it within 5 minutes of the device being awake. +The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of +sleep every 5 minutes is a battery cost with no payoff, because a service restarted +on a sleeping device can't do useful network work until the device wakes anyway. +While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover +delivery; the moment the device wakes, the pending watchdog alarm fires. ### Layer 5: WorkManager Periodic Catch-Up (15 minutes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..fbdd4b444e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus @@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.safeCacheDir +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver @@ -105,6 +107,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache @@ -124,6 +127,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend @@ -887,17 +892,48 @@ class AppModules( // starts observing LocalCache for notification-worthy events notificationDispatcher.start() - // Schedule the scheduled-posts worker (periodic + one-time catch-up). - // Runs independently of the always-on notification setting so scheduled - // posts still fire when always-on notifications are disabled. - ScheduledPostWorker.schedule(appContext) - ScheduledPostWorker.scheduleCatchUp(appContext) + // Keep the scheduled-posts worker (15-min periodic + one-time catch-up) + // enqueued exactly while the store holds a PENDING post. An always-on + // periodic worker wakes — and often cold-starts — the whole process every + // 15 minutes forever, even for users who never schedule a post. The store + // is durable (JSON on disk) and the single source of truth, so the worker + // is (re-)enqueued from any mutation that produces a PENDING row and + // cancelled when the last one drains. Runs independently of the always-on + // notification setting so scheduled posts still fire when always-on + // notifications are disabled. + applicationIOScope.launch { + // Force the initial disk load; the flow's initial value is an empty + // list until the store is first touched. + scheduledPostStore.list() + scheduledPostStore.flow + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .distinctUntilChanged() + .collect { hasPending -> + if (hasPending) { + ScheduledPostWorker.schedule(appContext) + ScheduledPostWorker.scheduleCatchUp(appContext) + } else { + ScheduledPostWorker.cancelPeriodic(appContext) + } + } + } - // Periodic scan that posts "starting soon" notifications for NIP-52 appointments the - // user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager - // periodic minimum and the lead-time window. - com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker - .schedule(appContext) + // "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as + // ACCEPTED. The 15-min periodic scanner is only scheduled while it can + // plausibly fire: this observer enqueues it when an accepted RSVP lands in + // LocalCache, and the worker cancels its own chain when the cache holds + // nothing that could still start. LocalCache is memory-only, so the + // unconditional schedule this replaces could never fire from a WorkManager + // cold start anyway — it only cost battery. + applicationIOScope.launch { + LocalCache + .observeNewEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))) + .collect { rsvp -> + if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) { + CalendarReminderWorker.schedule(appContext) + } + } + } // Watch for account login and start/stop always-on notification service applicationIOScope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0cb0b6f217..4560fe86e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -47,6 +47,13 @@ import java.util.concurrent.TimeUnit * consults [CalendarReminderStore] to skip events that have already been notified for. Run as * a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of * the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon"). + * + * The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP + * observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and + * [doWork] cancels the chain when the cache holds no accepted RSVP that could still start. + * LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty + * cache and can never fire a reminder — an unconditional periodic schedule would cold-start + * the whole app graph every 15 minutes forever for zero benefit. */ class CalendarReminderWorker( appContext: Context, @@ -55,7 +62,10 @@ class CalendarReminderWorker( override suspend fun doWork(): Result { val prefs = CalendarReminderPrefs(applicationContext) if (!prefs.isEnabled()) { - Log.d(TAG) { "Reminders disabled; skipping scan." } + Log.d(TAG) { "Reminders disabled; ending periodic chain." } + // The settings toggle re-schedules on enable; no reason to keep + // waking the process while the feature is off. + cancel(applicationContext) return Result.success() } val now = TimeUtils.now() @@ -110,6 +120,27 @@ class CalendarReminderWorker( // Prune entries for events that ended more than a day ago — they can't fire again. store.forgetBefore(now - PRUNE_AGE_SECONDS) + + // Nothing left that could ever fire → end the periodic chain instead of + // waking the process every 15 minutes forever. An RSVP whose target event + // hasn't been fetched yet (start unknown) counts as "could still fire" so + // the chain survives until the target resolves. The ACCEPTED-RSVP observer + // in AppModules re-schedules the worker the next time a live session sees + // an accepted RSVP. + val couldStillFire = + acceptedRsvps.any { rsvp -> + val targetAddress = rsvp.calendarEventAddress() ?: return@any false + val start = + LocalCache.addressables + .get(targetAddress) + ?.appointmentView() + ?.startSeconds + start == null || start > now + } + if (!couldStillFire) { + Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } + cancel(applicationContext) + } return Result.success() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt index dc9fd2ccdc..1d2b317928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt @@ -52,8 +52,13 @@ class ServiceWatchdogManager { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + // ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling + // the CPU out of sleep — if the device is asleep, a restarted service + // couldn't do useful network work anyway. The alarm fires as soon as + // the device is next awake, and the deeper restart layers (15-min + // WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases. alarmManager.setInexactRepeating( - AlarmManager.ELAPSED_REALTIME_WAKEUP, + AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, WATCHDOG_INTERVAL_MS, pendingIntent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index fbce710e6f..1f826aad05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -40,7 +40,16 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 - const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 + + // Every WebSocket ping on an otherwise-idle cellular connection promotes + // the radio out of its low-power state and pays the multi-second "tail" + // energy cost — per connection. On mobile data the interval is doubled to + // halve those wake-ups while staying under the ~5-minute idle timeout of + // the most aggressive carrier NATs (so connections aren't silently dropped + // between pings). Wifi keeps the tighter interval: pings there are cheap + // and detect dead connections sooner. + const val WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS: Long = 120 + const val WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS: Long = 240 } val myDispatcher = @@ -78,6 +87,7 @@ class OkHttpClientFactoryForRelays( fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, + pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS, ): OkHttpClient { if (proxy != lastProxy) { rootClient.connectionPool.evictAll() @@ -91,7 +101,7 @@ class OkHttpClientFactoryForRelays( .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) - .pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) + .pingInterval(Duration.ofSeconds(pingIntervalSeconds)) .build() } @@ -102,12 +112,14 @@ class OkHttpClientFactoryForRelays( buildHttpClient( buildLocalSocksProxy(localSocksProxyPort), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), + buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildHttpClient(isMobile: Boolean?): OkHttpClient = buildHttpClient( null, buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), + buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildTimeout(isMobile: Boolean): Int = @@ -117,5 +129,12 @@ class OkHttpClientFactoryForRelays( DEFAULT_TIMEOUT_ON_WIFI_SECS } + fun buildPingInterval(isMobile: Boolean): Long = + if (isMobile) { + WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS + } else { + WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS + } + fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index b9b339332f..6663d98463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -42,10 +42,15 @@ import java.util.concurrent.TimeUnit /** * Scans the scheduled-post store and publishes posts whose publish time has arrived. * - * - schedule(context): periodic, every 15 min (WorkManager minimum). - * - scheduleCatchUp(context): one-time, on app start, to flush posts that came - * due while the device was off or while WorkManager - * was deferred by Doze. + * - schedule(context): periodic, every 15 min (WorkManager minimum). Only + * enqueued while the store holds a PENDING post — the + * store observer in AppModules schedules it when a + * pending post appears and cancels it when the last + * one drains, so the worker never wakes the process + * with nothing to publish. + * - scheduleCatchUp(context): one-time, to flush posts that came due while the + * device was off or while WorkManager was deferred + * by Doze. */ class ScheduledPostWorker( appContext: Context, @@ -107,6 +112,16 @@ class ScheduledPostWorker( Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" } } + /** + * Ends the 15-minute periodic chain (keeps any catch-up run). Called by the + * store observer in AppModules when the last PENDING post drains, so the + * worker doesn't keep waking the process with nothing to publish. + */ + fun cancelPeriodic(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + Log.d(TAG) { "cancelPeriodic(): cancelled periodic worker" } + } + fun cancel(context: Context) { WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index 8299ce35d4..6959718e23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) { onCheckedChange = { enabled = it prefs.setEnabled(it) - // Toggling off doesn't cancel the worker — the worker itself short- - // circuits when isEnabled() returns false. Keeping the schedule alive - // means flipping it back on takes effect immediately without needing a - // re-launch via AppModules. - if (it) CalendarReminderWorker.schedule(context) + // Cancel eagerly on disable so the periodic worker stops waking the + // process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP + // observer in AppModules re-schedules on the next relevant RSVP too. + if (it) { + CalendarReminderWorker.schedule(context) + } else { + CalendarReminderWorker.cancel(context) + } }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 98d8f6a377..2e98749f34 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn @@ -107,7 +108,12 @@ class NostrClient( // new filters will be sent to the relays and a potential reconnect can // be triggered. // Default: STARTS active - private var isActive = true + // + // A StateFlow (not a plain Boolean) so the keep-alive loop below can + // suspend without any scheduled timer while the client is inactive — + // e.g. the whole time the app sits in the background after the host + // calls [disconnect]. + private val isActiveFlow = MutableStateFlow(true) /** * Whatches for any changes in the relay list from subscriptions or outbox @@ -132,16 +138,16 @@ class NostrClient( // Reconnects all relays that may have disconnected override fun connect() { - isActive = true + isActiveFlow.value = true relayPool.connect() } override fun disconnect() { - isActive = false + isActiveFlow.value = false relayPool.disconnect() } - override fun isActive() = isActive + override fun isActive() = isActiveFlow.value class Reconnect( val onlyIfChanged: Boolean, @@ -173,12 +179,19 @@ class NostrClient( * error code) would stay disconnected forever in the absence of any * subscription change. The per-relay [BasicRelayClient] backoff still * gates the actual reconnect attempt, so dead relays are not hammered. + * + * While the client is inactive (the host called [disconnect], e.g. the + * app moved to the background) the loop suspends on [isActiveFlow] + * instead of ticking: no timer fires at all until [connect] flips the + * flag back, saving periodic CPU wake-ups for the whole background + * stretch. */ private val keepAliveJob = scope.launch { while (true) { + isActiveFlow.first { it } delay(KEEP_ALIVE_INTERVAL_MS) - if (this@NostrClient.isActive) { + if (this@NostrClient.isActive()) { relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) } } @@ -202,7 +215,7 @@ class NostrClient( ) { val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) - if (isActive) { + if (isActive()) { activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> if (cmd is CloseCmd || cmd is AuthCmd) { relayPool.sendIfConnected(relay, cmd) @@ -225,7 +238,7 @@ class NostrClient( ) { val relaysToUpdate = activeCounts.addOrUpdate(subId, filters) - if (isActive) { + if (isActive()) { activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> if (cmd is CloseCmd || cmd is AuthCmd) { relayPool.sendIfConnected(relay, cmd) @@ -245,7 +258,7 @@ class NostrClient( ) { val relaysToUpdate = eventOutbox.markAsSending(event, relayList) - if (isActive) { + if (isActive()) { eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync) // wakes up all the other relays @@ -257,14 +270,14 @@ class NostrClient( val relaysToUpdateReqs = activeRequests.remove(subId) val relaysToUpdateCounts = activeCounts.remove(subId) - if (isActive) { + if (isActive()) { activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected) activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected) } } override fun syncFilters(relay: IRelayClient) { - if (isActive) { + if (isActive()) { scope.launch { activeRequests.syncState(relay.url, relay::sendOrConnectAndSync) activeCounts.syncState(relay.url, relay::sendOrConnectAndSync) @@ -337,7 +350,7 @@ class NostrClient( // The reconnect path is debounced and goes through // reconnectIfNeedsTo, which respects each relay's exponential // backoff so we don't hammer dead relays. - if (isActive && relay.url in allRelays.value) { + if (isActive() && relay.url in allRelays.value) { reconnect(onlyIfChanged = true) } } From 4212f38219010ab0ac838815d67d0cd39a5d3711 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:12:51 +0000 Subject: [PATCH 02/39] =?UTF-8?q?revert(relays):=20keep=20the=20120s=20Web?= =?UTF-8?q?Socket=20ping=20on=20mobile=20=E2=80=94=20measured,=20not=20gue?= =?UTF-8?q?ssed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed 122 production relays (top of 1,468 harvested from NIP-65 lists) with idle connections and client pings at 55/110/120/180/240/300s. Findings, detailed in amethyst/plans/2026-07-12-relay-ping-interval-study.md: - 99/122 relays survive 13 min of total client-ping silence; 90 of those send their OWN pings every 30-70s, which OkHttp must answer — so the cellular radio's wake cadence is set by the relays, not by our ping interval. The battery saving of the 240s mobile interval was illusory. - Idle-timeout tiers exist at ~60/~120/~240/~300/~600s, and pings only reliably reset a relay's timer when the interval is well below the tier: 240s pings drop relay.ditto.pub and the nostr1.com hosting tier; 300s pings drop even relay.snort.social. 120s holds every tier >=240s. - Lowering below 120s would only rescue a ~120s tier (6 relays) that already cycles today under 120s pings (110s/120s both fail; 55s works). Net: 120s is the measured sweet spot; the mobile/wifi split is removed and the constant now documents why. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../2026-07-12-relay-ping-interval-study.md | 245 ++++++++++++++++++ .../okhttp/OkHttpClientFactoryForRelays.kt | 34 +-- 2 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 amethyst/plans/2026-07-12-relay-ping-interval-study.md diff --git a/amethyst/plans/2026-07-12-relay-ping-interval-study.md b/amethyst/plans/2026-07-12-relay-ping-interval-study.md new file mode 100644 index 0000000000..66e75b20b9 --- /dev/null +++ b/amethyst/plans/2026-07-12-relay-ping-interval-study.md @@ -0,0 +1,245 @@ +# WebSocket Ping Interval Study — 122 Production Relays + +**Date:** 2026-07-12 +**Question:** Would relays drop Amethyst's connections if the client WebSocket +ping interval were raised (e.g. 120s → 240s on mobile data to save battery)? +Was the long-standing 120s value ever load-bearing, and what is the best +middle ground? + +**Answer (TL;DR):** Keep a single **120s** ping interval on every network. +Raising it to 240s saves almost no battery — 90% of surveyed relays send +their *own* pings every 30–70s, which OkHttp must answer, so the radio's +wake cadence is set by the relays, not by our interval — and it starts +dropping real relay tiers: 240s pings lose `relay.ditto.pub` (~240s idle +timeout) and every `nostr1.com`-hosted relay (~300s tier); 300s pings even +lose `relay.snort.social` (~600s tier). Lowering below 120s would only +rescue a ~120s tier of 6/122 relays that already cycle today, at 2× the +ping traffic on every other connection. 120s is, by measurement, the sweet +spot it was presumably never designed to be. + +--- + +## 1. Motivation + +`OkHttpClientFactoryForRelays` sets `pingInterval(120s)` on every relay +WebSocket. During battery work the interval was tentatively doubled on +mobile data on the theory that each client ping on an otherwise-idle +cellular connection wakes the radio and pays the multi-second tail-energy +cost. The maintainer asked the right question: *do we actually know how +production relays react to different ping intervals?* Nobody had tested +the 120s value. This study answers it empirically. + +Two distinct drop mechanisms are in play: + +1. **Relay/reverse-proxy idle timeouts** — testable from any vantage. +2. **Carrier NAT idle timeouts** — only testable from a real cellular + network (not from this environment; see §7). + +## 2. Relay population + +Production relays were harvested by fetching **600 kind:10002 (NIP-65) +relay-list events** from indexer relays (`indexer.coracle.social`, +`user.kindpag.es`) and counting `r`-tag references: **1,468 distinct +relays**, ranked by how many users actually list them. The **top 140** +(plus all Amethyst default relays) formed the test population. + +- **122 relays accepted a WebSocket** from the test vantage. +- 18 were unreachable *from a datacenter IP* (Cloudflare 403 challenges: + `nostr.wine`, `relay.0xchat.com`; TCP resets: `relay.nostr.band`, + `nostr.bitcoiner.social`, `relayable.org`, `nostr.fmt.wiz.biz`; plus + ordinary 5xx/410s). These blocks are IP-reputation-based, not + ping-related, and don't affect the conclusions — but they mean the + study cannot speak for those relays. + +## 3. Method + +Three experiments, all through the same stack (Python `websocket-client`, +TLS, one REQ per connection whose filter matches nothing, so the relay +answers EOSE and the connection then carries zero application traffic). +Server pings were always answered with pongs automatically (as OkHttp +does) and logged. + +- **Phase A — idle survival.** 140 relays, **zero client pings**, hold + for **780s (13 min)**. Records: drop time, close code, server-ping + timestamps. A relay surviving 780s of total client-ping silence proves + *any* client interval ≤ 780s is safe for it. +- **Phase B — ping efficacy.** Every Phase A dropper re-tested with + client pings at **55 / 110 / 120 / 180 / 240 / 300s** (one connection + per interval, window = observed idle timeout + 2 ping cycles + margin, + capped at 780s). This distinguishes "pings reset the relay's idle + timer" from "only data frames count". +- **Case study —** `relay.ditto.pub` with 60s pings for 420s (it had + dropped an idle connection at 257s while *its own* ping got our pong at + 123s — proving pongs don't reset its timer but client pings do). + +## 4. Phase A results — idle survival with zero client pings + +**99 of 122 relays (81%) survived 13 minutes of complete client-ping +silence.** For four out of five relays, the client ping interval is +irrelevant to connection survival at any plausible value. + +The 23 droppers cluster into clean idle-timeout tiers: + +| Tier | Count | Relays | +|---|---|---| +| < 30s (probe rejected / non-idle close) | 2 | `nostr.petrkr.net/strfry`, `next.nsite.run` | +| **~60s** | 8 | `nostr.pareto.space` (47s), `nostr.vps.satsnode.xyz` (×2), `relay.mostro.network`, `nostr.bond/alpha`, `nostr.sgiath.dev`, `nostr.bitcoinplebs.de`, `nostr.schneimi.de` | +| **~120s** | 8 | `cfrelay.snowcait.workers.dev` (118s), `nostr-verified.wellorder.net` (120.7s), `nostr-pub.wellorder.net` (120.8s), `git.shakespeare.diy` (125.7s), `nostr-relay.irgenius.org` (126.0s), `nostr-verif.slothy.win` (126.1s), `nostr.bit4use.com` (126.6s), `sendit.nosflare.com` (131.4s) | +| **~240s** | 1 | `relay.ditto.pub` (240.9s; 257.1s in an earlier run) | +| **~300s** | 2 | `david.nostr1.com` (300.5s), `dkkc.nostr1.com` (300.7s) — i.e. the **nostr1.com / relay.tools hosting tier** | +| **~600s** | 2 | `nos.lol/` (600.8s), `relay.snort.social` (601.0s) | + +### Server-ping cadence (the finding that reframes the question) + +Among the 99 relays that held an idle connection for the full window: + +| Server→client ping cadence | Relays | +|---|---| +| ≤ 35s | 45 | +| 36–70s | 37 | +| ~300s | 8 | +| no server pings at all | 9 | + +**90 of 99 relays ping the client; 82 of them every ≤ 70s.** OkHttp +answers every server ping with a pong regardless of the client-side +`pingInterval`. So on a connected cellular device the radio is being +woken every 30–70s *per connection* by the relays themselves. Changing +the client interval from 120s to 240s does not change that cadence at +all — the client ping is a rounding error in the connection's keepalive +traffic. **The claimed battery saving of a longer client ping interval +does not exist in practice.** (Corollary: the real mobile-battery lever +is connected time and connection count in the background — which the +app already minimizes by disconnecting 30s after backgrounding — not +the ping schedule.) + +## 5. Phase B results — which client intervals keep the droppers alive + +For every idle-dropper, one connection per candidate interval +(`x@T` = dropped at T seconds despite pinging at that interval; +`skip` = interval ≥ observed idle timeout, unsafe by construction): + +| relay | idle-drop | 55s | 110s | 120s | 180s | 240s | 300s | max safe | +|---|---|---|---|---|---|---|---|---| +| nostr.pareto.space | 46.9s | skip | skip | skip | skip | skip | skip | none | +| nostr.vps.satsnode.xyz | 51.1s | skip | skip | skip | skip | skip | skip | none | +| nostr.vps.satsnode.xyz/… | 51.2s | skip | skip | skip | skip | skip | skip | none | +| relay.mostro.network | 60.5s | x@60.8 | skip | skip | skip | skip | skip | none | +| nostr.bond/alpha | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none | +| nostr.sgiath.dev | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none | +| nostr.bitcoinplebs.de | 60.9s | x@61.1 | skip | skip | skip | skip | skip | none | +| nostr.schneimi.de | 62.2s | x@61.1 | skip | skip | skip | skip | skip | none | +| cfrelay.snowcait.workers.dev | 118.2s | x@85.0 | x@74.5 | skip | skip | skip | skip | none | +| nostr-verified.wellorder.net | 120.7s | **OK** | x@120.7 | x@120.8 | skip | skip | skip | 55s | +| nostr-pub.wellorder.net | 120.8s | **OK** | x@120.8 | x@120.8 | skip | skip | skip | 55s | +| git.shakespeare.diy/… | 125.7s | **OK** | x@125.9 | x@126.1 | skip | skip | skip | 55s | +| nostr-relay.irgenius.org | 126.0s | **OK** | x@125.8 | x@125.9 | skip | skip | skip | 55s | +| nostr-verif.slothy.win | 126.1s | **OK** | x@126.1 | x@126.3 | skip | skip | skip | 55s | +| nostr.bit4use.com | 126.6s | **OK** | x@126.4 | x@126.5 | skip | skip | skip | 55s | +| sendit.nosflare.com | 131.4s | x@81.7 | x@41.9 | x@7.0 | skip | skip | skip | none | +| **relay.ditto.pub** | 240.9s | OK | OK | **OK** | x@673.5 | **x@609.8** | skip | **120s** | +| **david.nostr1.com** | 300.5s | OK | OK | **OK** | x@300.6 | **x@300.7** | x@300.7 | **120s** | +| **dkkc.nostr1.com/…** | 300.7s | OK | OK | **OK** | x@300.7 | **x@301.1** | x@300.6 | **120s** | +| nos.lol/ | 600.8s | OK | OK | OK | OK | OK | x@602.1 | 240s | +| **relay.snort.social** | 601.0s | OK | OK | OK | OK | OK | **x@607.7** | 240s | + +Key observations: + +1. **A client ping interval numerically below the idle timeout is NOT + sufficient.** 180s and 240s pings failed against the ~300s + `nostr1.com` tier, and 300s pings failed against the ~600s + `snort.social` tier, even though each ping "should" have arrived in + time. The empirical rule across every tier: **pings only reliably + reset a relay's idle timer when the interval is at most roughly half + the timeout.** (Likely cause: these stacks check activity in coarse + windows rather than resetting a precise per-frame deadline, so an + interval near the window size loses boundary races.) +2. The **~60s tier is unsalvageable** — even 55s pings didn't help + (their timers count only data frames). These 8 relays drop idle + Amethyst connections *today* under the 120s setting and would under + any setting; the existing reconnect-on-demand path is the correct + handling for them. +3. The **~120s tier is only rescued by ≤55s pings** — meaning + **today's 120s interval never kept them alive either** (110s and + 120s pings both failed). They cycle today; they'd cycle at 240s. + No candidate change affects them. +4. The tiers that DO depend on our ping interval are exactly + **ditto (~240s), nostr1.com (~300s), and snort/nos.lol-haven + (~600s)** — and 120s holds all of them, while 240s loses the first + two and 300s loses all three. + +Connections kept alive (of 122 reachable), by candidate interval: +**55s → 110 · 120s → 104 · 240s → 101 · 300s → 99.** + +The `relay.ditto.pub` case study confirms the mechanism: with an idle +connection its *own* ping at t=123s received our pong and it still +closed at 257s (pongs don't count as activity), but with 60s client +pings it stayed up indefinitely (client pings do count). + +## 6. Why not go lower than 120s? + +55s pings would rescue the ~120s tier (6 relays). But: + +- those relays already cycle today, so the status quo loses nothing; +- 55s pings double the client-ping traffic on all ~100+ connections to + rescue 5% of relays whose operators chose aggressive timeouts; +- the radio is already woken every ≤70s on 82/122 connections by server + pings, so the *incremental* battery cost is modest — but so is the + benefit, and drop/reconnect for those 6 relays is already handled + gracefully by `BasicRelayClient`'s backoff + the keep-alive sweep. + +A per-relay adaptive interval (shorten pings only for relays observed to +drop idle connections) is possible future work, but OkHttp's +`pingInterval` is per-client, not per-socket, so it would require +per-relay client instances — not worth the complexity for 6 relays. + +## 7. Carrier NAT — the part this study cannot measure + +The other purpose of client pings is keeping carrier NAT/firewall +mappings alive on cellular. That is untestable from a datacenter vantage. +Published measurements and platform folklore put aggressive carrier TCP +idle timeouts around 4–5 minutes (most are 15–30 min; FCM survives on +~28 min heartbeats *with OS cooperation Amethyst doesn't get*). 120s +sits comfortably inside even the aggressive bound, so relay-side and +NAT-side constraints agree on the same answer. Anyone wanting to raise +the interval later must first re-run Phase B *and* validate on real +cellular networks — the relay data alone already rules out 240s. + +## 8. Decision + +- **`WEBSOCKET_PING_INTERVAL_SECS = 120`, one value for wifi and mobile.** + The tentative 240s mobile value was reverted in this same branch after + these measurements: it saved ~nothing (server pings dominate radio + wakes) and dropped the ditto and nostr1.com tiers. +- OkHttp's `pingInterval` doubles as the dead-connection detector (a + missed pong fails the socket within one interval), so 120s also keeps + failure detection twice as fast as 240s would — relevant after silent + network path changes. + +## 9. Reproduction + +Vantage caveats: datacenter egress IP (18 relays refused it), all +traffic via an HTTP CONNECT proxy. A control connection with 100s pings +survived every window, ruling out proxy-imposed idle limits ≤ 780s. + +Sketch (Python `websocket-client`): open `wss://` to each relay, send +one REQ whose filter matches nothing (`{"kinds":[1],"authors":["00…01"], +"limit":1}`), auto-pong server pings, and either never ping (Phase A, +780s window) or ping at the candidate interval (Phase B). Log connect / +EOSE / server-ping / close timestamps. Population: top-N relays by +`r`-tag frequency across kind:10002 events fetched from indexer relays. + +## Appendix — Phase A survivor cadences (99 relays) + +Server-ping cadence measured over the 13-minute window. `none` means the +relay sent no pings at all and still held the idle connection. + +| cadence | relays | +|---|---| +| ~25–35s | `articles.layer3.news`, `aegis.relayted.de`, `assistantrelay.rodbishop.nz`, `bots.utxo.one`, `custom.fiatjaf.com`, `dev.calendar-relay.edufeed.org`, `greensoul.space` (×2), `groups.0xchat.com`, `groups.satsdisco.com`, `h.codingarena.top/inbox`, `haven.calva.dev/inbox`, `haven.nostrfreedom.net`, `haven.relayted.de`, `hist.nostr.land`, `lang.relays.land` (×3), `nexus.libernet.app`, `nip17.com`, `nostr-01.uid.ovh`, `nostr-relay.derekross.me` (×2), `nostr.damupi.com/inbox`, `nostr.easydns.ca`, `nostr.kfx.fr` (×2), `nostr.land`, `nostr.nothing.is-lost.org/haven`, and 17 more at ~30s; `nostrelites.org`, `purplepag.es`, `relay.noswhere.com` at ~30s | +| ~55–70s | `nostr.thalheim.io`, `nostr.xmr.rocks`, `offchain.pub`, `relay.mostr.pub`, `relay.nostr.net`, `relay.primal.net`, `relay.damus.io`, `indexer.coracle.social`, `directory.yabu.me`, `user.kindpag.es`, `profiles.nostr1.com`, `nostr.oxtr.dev`, and ~25 more | +| ~300s | `nostr-relay.corb.net`, `nostr.001.j5s9.dev`, `nostr.8777.ch`, `nostr.einundzwanzig.space`, `nostr.mikoshi.de`, `nostr.pbfs.io`, `nostr.sectiontwo.org`, `nostr.wild-vibes.ts.net` | +| none | `nos.lol`, `nostr.mom`, `relay.divine.video`, `relay.fountain.fm`, `koru.bitcointxoko.org`, `nostr-pr02.redscrypt.org`, 2 × Cloudflare-Workers relays, 1 other | + +Raw JSON for both phases (per-relay timestamps, close codes, server-ping +series) was captured during the study session; the tables above are the +complete decision-relevant summary. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index 1f826aad05..1795fcd0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -41,15 +41,19 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 - // Every WebSocket ping on an otherwise-idle cellular connection promotes - // the radio out of its low-power state and pays the multi-second "tail" - // energy cost — per connection. On mobile data the interval is doubled to - // halve those wake-ups while staying under the ~5-minute idle timeout of - // the most aggressive carrier NATs (so connections aren't silently dropped - // between pings). Wifi keeps the tighter interval: pings there are cheap - // and detect dead connections sooner. - const val WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS: Long = 120 - const val WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS: Long = 240 + // 120s is a measured sweet spot, not a guess — see + // amethyst/plans/2026-07-12-relay-ping-interval-study.md (probe of 122 + // production relays). Idle-timeout tiers observed in production are + // ~60s / ~120s / ~240s / ~300s / ~600s, and a client ping only reliably + // resets a relay's idle timer when the interval sits well BELOW the + // tier (240s pings already lose the ~240s and ~300s tiers, incl. every + // nostr1.com-hosted relay; 300s pings lose relay.snort.social). Raising + // the interval also saves almost no battery: 90% of surveyed relays + // send their own server pings every 30-70s, which OkHttp must answer, + // so the radio's wake cadence is set by the relays, not by this value. + // Lowering it would only rescue the ~120s tier (6/122 relays, already + // cycling today) at 2x the ping traffic on every other connection. + const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 } val myDispatcher = @@ -87,7 +91,6 @@ class OkHttpClientFactoryForRelays( fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, - pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS, ): OkHttpClient { if (proxy != lastProxy) { rootClient.connectionPool.evictAll() @@ -101,7 +104,7 @@ class OkHttpClientFactoryForRelays( .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) - .pingInterval(Duration.ofSeconds(pingIntervalSeconds)) + .pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) .build() } @@ -112,14 +115,12 @@ class OkHttpClientFactoryForRelays( buildHttpClient( buildLocalSocksProxy(localSocksProxyPort), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), - buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildHttpClient(isMobile: Boolean?): OkHttpClient = buildHttpClient( null, buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), - buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildTimeout(isMobile: Boolean): Int = @@ -129,12 +130,5 @@ class OkHttpClientFactoryForRelays( DEFAULT_TIMEOUT_ON_WIFI_SECS } - fun buildPingInterval(isMobile: Boolean): Long = - if (isMobile) { - WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS - } else { - WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS - } - fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) } From a760d17eaa2dd34afc8385db2fa83ec40756ba4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 19:32:20 +0000 Subject: [PATCH 03/39] test(battery): behavioral tests for the keep-alive pause and worker gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ping-interval change shipped on reasoning and was overturned by measurement; these tests give the remaining battery changes the same scrutiny where the claims are about OUR code (deterministically testable), instead of leaving them reasoning-only: - NostrClientKeepAliveTest (quartz): a server-closed relay is redialed within one 60s sweep while active; ZERO dials happen while the client is inactive no matter how much time passes; and the sweep provably resumes after connect() — the failure mode a suspend/resume bug in the new isActiveFlow gate would cause. - ScheduledPostWorkGate extracted from AppModules + tests: schedules on the first PENDING post, cancels when the last drains, re-schedules on publishNow-retry, and — the race that motivated the extraction — never emits a spurious cancel from the store flow's empty placeholder before the disk load completes. - CalendarReminderWorker.couldStillFire extracted + tests against LocalCache: future target keeps the chain, past target lets it end, and an unresolved/start-less target keeps it alive so a reminder can't be lost while the target event is still being fetched. The watchdog alarm change (wakeup -> non-wakeup) remains reasoning-only by nature: it asserts Android OS alarm semantics on OEM devices, which no JVM test can exercise; its blast radius is limited to opt-in always-on users with two independent restart layers still active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../com/vitorpamplona/amethyst/AppModules.kt | 35 ++-- .../calendar/CalendarReminderWorker.kt | 52 +++--- .../scheduledposts/ScheduledPostWorkGate.kt | 65 ++++++++ .../CalendarReminderCandidatesTest.kt | 149 +++++++++++++++++ .../ScheduledPostWorkGateTest.kt | 149 +++++++++++++++++ .../relay/client/NostrClientKeepAliveTest.kt | 154 ++++++++++++++++++ 6 files changed, 561 insertions(+), 43 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index fbdd4b444e..0d1a92ff48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -87,8 +87,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.safeCacheDir -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe @@ -893,30 +893,19 @@ class AppModules( notificationDispatcher.start() // Keep the scheduled-posts worker (15-min periodic + one-time catch-up) - // enqueued exactly while the store holds a PENDING post. An always-on - // periodic worker wakes — and often cold-starts — the whole process every - // 15 minutes forever, even for users who never schedule a post. The store - // is durable (JSON on disk) and the single source of truth, so the worker - // is (re-)enqueued from any mutation that produces a PENDING row and - // cancelled when the last one drains. Runs independently of the always-on + // enqueued exactly while the store holds a PENDING post — see + // ScheduledPostWorkGate. Runs independently of the always-on // notification setting so scheduled posts still fire when always-on // notifications are disabled. - applicationIOScope.launch { - // Force the initial disk load; the flow's initial value is an empty - // list until the store is first touched. - scheduledPostStore.list() - scheduledPostStore.flow - .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } - .distinctUntilChanged() - .collect { hasPending -> - if (hasPending) { - ScheduledPostWorker.schedule(appContext) - ScheduledPostWorker.scheduleCatchUp(appContext) - } else { - ScheduledPostWorker.cancelPeriodic(appContext) - } - } - } + ScheduledPostWorkGate( + store = scheduledPostStore, + scope = applicationIOScope, + onPendingWork = { + ScheduledPostWorker.schedule(appContext) + ScheduledPostWorker.scheduleCatchUp(appContext) + }, + onNoPendingWork = { ScheduledPostWorker.cancelPeriodic(appContext) }, + ).start() // "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as // ACCEPTED. The 15-min periodic scanner is only scheduled while it can diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 4560fe86e1..a68089408f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -76,12 +76,7 @@ class CalendarReminderWorker( // multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's // present in cache — the alternative (looking only at the foreground account) would // silently break notifications for account switching during the lead window. - val acceptedRsvps = - LocalCache.addressables - .filterIntoSet { _, note -> - val e = note.event - e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED - }.mapNotNull { it.event as? CalendarRSVPEvent } + val acceptedRsvps = acceptedRsvpsInCache() Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" } @@ -122,22 +117,10 @@ class CalendarReminderWorker( store.forgetBefore(now - PRUNE_AGE_SECONDS) // Nothing left that could ever fire → end the periodic chain instead of - // waking the process every 15 minutes forever. An RSVP whose target event - // hasn't been fetched yet (start unknown) counts as "could still fire" so - // the chain survives until the target resolves. The ACCEPTED-RSVP observer + // waking the process every 15 minutes forever. The ACCEPTED-RSVP observer // in AppModules re-schedules the worker the next time a live session sees // an accepted RSVP. - val couldStillFire = - acceptedRsvps.any { rsvp -> - val targetAddress = rsvp.calendarEventAddress() ?: return@any false - val start = - LocalCache.addressables - .get(targetAddress) - ?.appointmentView() - ?.startSeconds - start == null || start > now - } - if (!couldStillFire) { + if (!couldStillFire(acceptedRsvps, now)) { Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } cancel(applicationContext) } @@ -152,6 +135,35 @@ class CalendarReminderWorker( // more than a day ago; they can't fire again so the entry is pure overhead. private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L + /** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */ + fun acceptedRsvpsInCache(): List = + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED + }.mapNotNull { it.event as? CalendarRSVPEvent } + + /** + * True while at least one accepted RSVP could still produce a reminder: + * its target event either starts in the future, or hasn't been fetched + * yet (start unknown — the chain must survive until the target + * resolves). False means the periodic worker has nothing it could ever + * notify about and may cancel its own chain. + */ + fun couldStillFire( + rsvps: Collection, + now: Long, + ): Boolean = + rsvps.any { rsvp -> + val targetAddress = rsvp.calendarEventAddress() ?: return@any false + val start = + LocalCache.addressables + .get(targetAddress) + ?.appointmentView() + ?.startSeconds + start == null || start > now + } + fun schedule(context: Context) { val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt new file mode 100644 index 0000000000..3cda10674b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt @@ -0,0 +1,65 @@ +/* + * 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.service.scheduledposts + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Keeps [ScheduledPostWorker]'s 15-minute periodic chain enqueued exactly while + * the store holds a PENDING post. An unconditionally-scheduled periodic worker + * wakes — and often cold-starts — the whole process every 15 minutes forever, + * even for users who never schedule a post. + * + * The store is durable (JSON on disk) and the single source of truth, so + * [onPendingWork] fires from any mutation that produces a PENDING row (add, + * publishNow, releaseClaim) and [onNoPendingWork] when the last one drains + * (markSent/markFailed/cancel/removeForAccount). + * + * [start] forces the store's initial disk load BEFORE collecting: the flow's + * initial value is an empty list until the store is first touched, and acting + * on that placeholder would cancel scheduled work that a pending post still + * needs. + */ +class ScheduledPostWorkGate( + private val store: ScheduledPostStore, + private val scope: CoroutineScope, + private val onPendingWork: () -> Unit, + private val onNoPendingWork: () -> Unit, +) { + fun start(): Job = + scope.launch { + store.list() + store.flow + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .distinctUntilChanged() + .collect { hasPending -> + if (hasPending) { + onPendingWork() + } else { + onNoPendingWork() + } + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt new file mode 100644 index 0000000000..74347b6d61 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt @@ -0,0 +1,149 @@ +/* + * 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.calendar + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The predicate that decides whether the CalendarReminderWorker's periodic + * chain may cancel itself. Getting it wrong in either direction is a bug: + * a false "could fire" keeps waking the process forever; a false "can't fire" + * silently kills a reminder the user RSVP'd to. + */ +class CalendarReminderCandidatesTest { + private val now = 2_000_000_000L + + // Unique per-test-class identities so the shared LocalCache singleton + // doesn't collide with other suites running in the same JVM. + private val organizer = "b0b0000000000000000000000000000000000000000000000000000000000001" + private val attendee = "a11ce00000000000000000000000000000000000000000000000000000000002" + + private var eventSeq = 0 + + private fun timeSlot( + dTag: String, + startSeconds: Long?, + ) = CalendarTimeSlotEvent( + id = "c0ffee%058d".format(++eventSeq), + pubKey = organizer, + createdAt = now - 1000, + tags = + if (startSeconds != null) { + arrayOf(arrayOf("d", dTag), arrayOf("start", startSeconds.toString())) + } else { + arrayOf(arrayOf("d", dTag)) + }, + content = "", + sig = "sig", + ) + + private fun rsvp( + dTag: String, + targetDTag: String?, + status: String = "accepted", + ) = CalendarRSVPEvent( + id = "faced%059d".format(++eventSeq), + pubKey = attendee, + createdAt = now - 900, + tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("status", status)) + if (targetDTag != null) { + add(arrayOf("a", "${CalendarTimeSlotEvent.KIND}:$organizer:$targetDTag")) + } + }.toTypedArray(), + content = "", + sig = "sig", + ) + + @Test + fun acceptedRsvpsInCache_returnsAcceptedAndSkipsDeclined() { + val accepted = rsvp("cand-accepted", "cand-target-1") + val declined = rsvp("cand-declined", "cand-target-2", status = "declined") + LocalCache.justConsume(accepted, null, true) + LocalCache.justConsume(declined, null, true) + + val found = CalendarReminderWorker.acceptedRsvpsInCache() + assertTrue("accepted RSVP must be found", found.any { it.dTag() == "cand-accepted" }) + assertFalse("declined RSVP must be skipped", found.any { it.dTag() == "cand-declined" }) + } + + @Test + fun futureTarget_couldStillFire() { + val slot = timeSlot("cand-future", startSeconds = now + 3600) + val r = rsvp("cand-rsvp-future", "cand-future") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun pastTarget_cannotFireAnymore() { + val slot = timeSlot("cand-past", startSeconds = now - 3600) + val r = rsvp("cand-rsvp-past", "cand-past") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun unresolvedTarget_keepsTheChainAlive() { + // The RSVP points at an event the cache hasn't fetched yet: the start + // is unknown, so the worker must NOT cancel its chain. + val r = rsvp("cand-rsvp-unresolved", "cand-never-fetched") + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun targetWithoutStart_keepsTheChainAlive() { + // Target resolved but carries no start tag: still unknown, keep alive. + val slot = timeSlot("cand-no-start", startSeconds = null) + val r = rsvp("cand-rsvp-no-start", "cand-no-start") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun rsvpWithoutTargetAddress_doesNotKeepTheChainAlive() { + val r = rsvp("cand-rsvp-no-a-tag", targetDTag = null) + LocalCache.justConsume(r, null, true) + + assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun emptyCache_doesNotKeepTheChainAlive() { + assertFalse(CalendarReminderWorker.couldStillFire(emptyList(), now)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt new file mode 100644 index 0000000000..f3577d52f1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -0,0 +1,149 @@ +/* + * 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.service.scheduledposts + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The gate must keep the periodic worker enqueued exactly while a PENDING + * post exists — and, critically, must never act on the store flow's empty + * placeholder value before the disk load completes (which would cancel + * scheduled work an existing pending post still needs). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ScheduledPostWorkGateTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + /** Chronological record of gate decisions: true = schedule, false = cancel. */ + private val decisions = mutableListOf() + + @Before + fun setUp() { + file = File(temp.root, "scheduled_posts.json") + decisions.clear() + } + + private fun newStore() = ScheduledPostStore(file) + + private fun TestScope.startGate(store: ScheduledPostStore) = + ScheduledPostWorkGate( + store = store, + scope = backgroundScope, + onPendingWork = { decisions.add(true) }, + onNoPendingWork = { decisions.add(false) }, + ).start() + + private fun samplePost( + id: String = "id-1", + publishAtSec: Long = 4_000_000_000, + ) = ScheduledPost( + id = id, + accountPubkey = "pk1", + signedEventJson = "{}", + relayUrls = listOf("wss://relay.example/"), + extraEventsJson = emptyList(), + publishAtSec = publishAtSec, + createdAtSec = 500, + ) + + @Test + fun emptyStore_cancelsOnceAndOnlyOnce() = + runTest { + startGate(newStore()) + runCurrent() + assertEquals(listOf(false), decisions) + } + + @Test + fun pendingPostOnDisk_schedulesWithoutSpuriousCancelFirst() = + runTest { + // Persist a pending post in a previous "process". + runBlocking { newStore().add(samplePost()) } + + // Fresh store (flow starts as the empty placeholder). The gate must + // load from disk before collecting: the FIRST decision must be + // "schedule", never a cancel from the placeholder value. + startGate(newStore()) + runCurrent() + assertEquals(listOf(true), decisions) + } + + @Test + fun addThenDrainThenAddAgain_togglesTheWorker() = + runTest { + val store = newStore() + startGate(store) + runCurrent() + assertEquals(listOf(false), decisions) + + store.add(samplePost(id = "a")) + runCurrent() + assertEquals(listOf(false, true), decisions) + + // A second pending post must not re-fire (distinctUntilChanged). + store.add(samplePost(id = "b")) + runCurrent() + assertEquals(listOf(false, true), decisions) + + // Draining both pending posts cancels the periodic chain. + store.markSent("a") + runCurrent() + assertEquals(listOf(false, true), decisions) + store.markFailed("b", "boom") + runCurrent() + assertEquals(listOf(false, true, false), decisions) + + // A new post re-schedules. + store.add(samplePost(id = "c")) + runCurrent() + assertEquals(listOf(false, true, false, true), decisions) + } + + @Test + fun publishNowOnFailedPost_reschedulesTheWorker() = + runTest { + val store = newStore() + store.add(samplePost(id = "a")) + startGate(store) + runCurrent() + store.markFailed("a", "relay down") + runCurrent() + assertEquals(listOf(true, false), decisions) + + // Retry flips the post back to PENDING; the worker must come back. + store.publishNow("a") + runCurrent() + assertEquals(listOf(true, false, true), decisions) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt new file mode 100644 index 0000000000..66137b9ff8 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt @@ -0,0 +1,154 @@ +/* + * 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.quartz.nip01Core.relay.client + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Behavioral contract of the keep-alive sweep after it was changed to suspend + * on the client's active-state flow instead of ticking unconditionally: + * + * 1. while ACTIVE, a relay closed by the server is redialed within one 60s + * sweep interval (once the per-relay backoff gate opens); + * 2. while INACTIVE (host called [NostrClient.disconnect], i.e. the app went + * to the background), no amount of elapsed time produces a dial; + * 3. after [NostrClient.connect] reactivates the client, redialing works + * again — a bug in the suspend/resume logic would leave every + * server-dropped relay disconnected forever. + * + * Coroutine timers (keep-alive 60s, reconnect debounce 200ms) run on the test + * scheduler's virtual clock; the per-relay backoff gate compares real wall + * seconds (integer granularity), hence the short real sleeps before each + * expected redial. + * + * Harness note: every socket the client dials must eventually be driven to + * onOpen/onClosed — a [FakeWebsocketBuilder] socket that is never completed + * leaves the relay in "connecting" forever and blocks all later dials, which + * is exactly what a production dial never does. [settleDisconnected] flushes + * pending debounced reconnects and completes any socket they may open. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrClientKeepAliveTest { + private val url = NormalizedRelayUrl("wss://keepalive.example.com") + + /** + * Flushes pending sub-second timers (the 200ms reconnect debounce) and, + * if a flush dialed a new socket, completes its lifecycle so the relay + * ends DISCONNECTED with no pending timers besides the keep-alive sweep. + */ + private fun TestScope.settleDisconnected(builder: FakeWebsocketBuilder) { + repeat(4) { + val before = builder.connectAttempts + advanceTimeBy(500) + runCurrent() + if (builder.connectAttempts == before) return + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "test settle") + } + } + + @Test + fun keepAliveSweepPausesWhileInactiveAndResumesAfterReconnect() = + runTest { + val builder = FakeWebsocketBuilder() + val client = NostrClient(builder, this) + try { + // Flush refreshConnection's initial emission against the + // still-empty pool so it can't dial later. + advanceTimeBy(500) + runCurrent() + + // Subscribing dials the relay immediately. + client.subscribe("sub", mapOf(url to listOf(Filter()))) + assertEquals(1, builder.connectAttempts) + + // Server closes the established connection. While ACTIVE the + // client must redial within one keep-alive interval. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + val beforeActiveSweep = builder.connectAttempts + Thread.sleep(3_500) + advanceTimeBy(61_000) + runCurrent() + assertTrue( + builder.connectAttempts > beforeActiveSweep, + "active client should redial a server-closed relay within one sweep, " + + "got ${builder.connectAttempts} attempts (baseline $beforeActiveSweep)", + ) + + // Leave the relay cleanly disconnected, then deactivate. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + client.disconnect() + val whileInactiveBaseline = builder.connectAttempts + + // The relay's backoff gate is reset by disconnect(), so any + // stray sweep tick WOULD dial — this fails if the sweep keeps + // running (or anything else dials) while inactive. + Thread.sleep(3_500) + advanceTimeBy(30 * 60_000) + runCurrent() + assertEquals( + whileInactiveBaseline, + builder.connectAttempts, + "no dial may happen while the client is inactive", + ) + + // Reactivation dials the pool immediately... + client.connect() + runCurrent() + assertEquals( + whileInactiveBaseline + 1, + builder.connectAttempts, + "connect() should redial the pool immediately", + ) + + // ...and after the pause a server-closed relay must again be + // redialed within one sweep — fails if the sweep never + // resumes after the inactive stretch. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + val beforeResumedSweep = builder.connectAttempts + Thread.sleep(3_500) + advanceTimeBy(61_000) + runCurrent() + assertTrue( + builder.connectAttempts > beforeResumedSweep, + "sweep did not resume after connect(); a server-dropped relay would stay disconnected forever", + ) + } finally { + client.close() + } + } +} From 6415515a5e000fb02c4f89a132d2fada45bca098 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:57:35 +0000 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20resource-usage=20ledger=20?= =?UTF-8?q?=E2=80=94=20on-device=20battery/data=20accounting=20+=20NIP-17?= =?UTF-8?q?=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passive, always-on counters (never transmitted automatically) that make battery/data complaints diagnosable in production, where they actually happen — see amethyst/plans/2026-07-12-resource-usage-ledger.md: - Per-subsystem HTTP bytes (image/video/uploads/money/nip05/preview/push) via a counting interceptor wrapped per role in RoleBasedHttpClientBuilder, split by cellular/wifi and foreground/background. - Relay traffic (RelayConnectionListener) and relay connection-time (Σ connections x time, integrated from connectedRelaysFlow with no timers) — the ping study's best single battery proxy. - Notification wakelock held-time, background worker runs, and app process starts (WorkManager churn detector). - Daily buckets persisted to one small JSON file (ScheduledPostStore idiom), pruned after 30 days; counters are sizes/durations/counts only, no URLs, relay names, or content. Surfaces: - Settings > "App resource usage": today / 7-day summaries and a data-by-feature breakdown, plus an explicit "send report via DM" button that prefills the NIP-17 composer to the developers (same pipeline, recipient, and 30-day expiration as crash reports — nothing sends until the user taps Send). - High-consumption prompt: on app open, if the last day crossed conservative thresholds (>50 MB background cellular, >12 relay-hours backgrounded on cellular, >30 min notification wakelock, >75 process starts), an AlertDialog offers review-and-send — at most once every 7 days, with a persistent "don't ask again". 14 unit tests cover the store, accountant (day rollover, live-merge, pre-flush hooks), connection-time integrator (incl. the long-stable- session case), and alert thresholds/rate limiting. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 120 +++++++ .../com/vitorpamplona/amethyst/Amethyst.kt | 4 + .../com/vitorpamplona/amethyst/AppModules.kt | 56 +++- .../RoleBasedHttpClientBuilder.kt | 27 +- .../calendar/CalendarReminderWorker.kt | 3 + .../EventNotificationConsumer.kt | 5 + .../NotificationCatchUpWorker.kt | 2 + .../notifications/NotificationDispatcher.kt | 4 +- .../DisplayResourceUsageAlert.kt | 159 ++++++++++ .../resourceusage/ForegroundTracker.kt | 69 ++++ .../RelayConnectionTimeIntegrator.kt | 92 ++++++ .../resourceusage/RelayUsageListener.kt | 58 ++++ .../resourceusage/ResourceUsageAccountant.kt | 120 +++++++ .../resourceusage/ResourceUsageAlerts.kt | 96 ++++++ .../ResourceUsageReportAssembler.kt | 114 +++++++ .../resourceusage/ResourceUsageStore.kt | 154 +++++++++ .../resourceusage/UsageCountingInterceptor.kt | 124 ++++++++ .../service/resourceusage/UsageKeys.kt | 96 ++++++ .../service/resourceusage/UsageSummary.kt | 89 ++++++ .../scheduledposts/ScheduledPostWorker.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/settings/ResourceUsageScreen.kt | 221 +++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 39 +++ .../resourceusage/ResourceUsageLedgerTest.kt | 295 ++++++++++++++++++ 26 files changed, 1946 insertions(+), 10 deletions(-) create mode 100644 amethyst/plans/2026-07-12-resource-usage-ledger.md create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md new file mode 100644 index 0000000000..7d460b68bf --- /dev/null +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -0,0 +1,120 @@ +# Resource Usage Ledger — battery/data accounting, user-visible + NIP-17 reportable + +**Date:** 2026-07-12 +**Goal:** Let users (and developers) see how much network, connection time, and +background activity the app consumes, per subsystem — and let a user send that +data to the developers over NIP-17, reusing the crash-report consent pattern. +When consumption crosses "something is wrong" thresholds, proactively ask the +user (rate-limited, opt-out-able) whether they'd like to send a report. + +Background: the 2026-07-12 ping-interval study (see +`2026-07-12-relay-ping-interval-study.md`) showed the dominant energy proxy is +connection-time (relays server-ping every 30–70s while connected) and that +battery bugs are production-only phenomena — so the ledger ships in release, +collects passively, and never transmits anything without an explicit user +action. + +## Survey (existing components reused) + +- **Send path** — the crash-report pipeline: `DisplayCrashMessages` prefills + the NIP-17 DM composer via `routeToMessage(user = , draftMessage, + expiresDays = 30)`; the user taps Send; `Account.sendNip17PrivateMessage` + gift-wraps to the recipient's kind-10050 DM relays. Reused as-is — the + ledger only builds a different draft string. +- **Persistence idiom** — `ScheduledPostStore` (Jackson + Mutex + tmp-rename + + version envelope + StateFlow). Cloned as `ResourceUsageStore`. +- **Relay traffic** — counted by a new `RelayConnectionListener` + (same hook `RelayStats` uses), NOT by modifying quartz. +- **Connection time** — integrated from `INostrClient.connectedRelaysFlow()` + (exact between emissions; no timers). +- **Network class** — `ConnectivityManager.isMobileOrFalse` StateFlow. +- **Foreground** — new tiny `ForegroundTracker` (ActivityLifecycleCallbacks → + StateFlow), registered next to `AppForegroundRecycleHook`; + `MainActivity.isResumed` is not observable and slightly stricter than + process-foreground. +- **HTTP subsystems** — `RoleBasedHttpClientBuilder` already funnels every + role (image/video/uploads/money/nip05/preview/push) through two shared + clients; a cached per-role `newBuilder().addInterceptor(counting)` wrapper + gives per-subsystem byte attribution without touching the shared clients. +- **UI idioms** — `NotificationSettingsScreen` structure (`Scaffold` + + `TopBarWithBackButton` + `SettingsSection` cards), route in `Routes.kt`, + `composableFromEnd` registration, catalog entry via + `SettingsCatalogBuilder.symEntry` (icon: existing `MaterialSymbols.Bolt` — + no font regen). +- **App-open dialog** — `DisplayCrashMessages` pattern, mounted in the same + `AppNavigation` block. + +## Design + +### Counters +Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: +`....[.]`, e.g.: + +- `net.image.mobile.bg.rx` — bytes downloaded by the image subsystem on + cellular while backgrounded (same for video/uploads/money/nip05/preview/push) +- `relay.msg.wifi.fg.rx|tx` — approx relay websocket payload bytes +- `relay.connms.mobile.bg` — relay-connection-milliseconds (Σ relays × time) +- `wakelock.notif.ms` / `wakelock.notif.count` +- `worker.scheduledPost.runs` / `worker.calendarReminder.runs` / + `worker.notificationCatchUp.runs` +- `app.starts` — process starts (detects WorkManager cold-start churn) + +Flat keys keep the store schema-free: new counters need no migration. + +### Components (`amethyst/.../service/resourceusage/`) +- `UsageKeys` — key constants/builders + dimension helpers. +- `ResourceUsageStore` — daily buckets on disk (`resource_usage.json`), + `mergeInto(day, deltas)`, `allDays()`, prune, plus alert state + (lastAlertAtSec, optOut). +- `ResourceUsageAccountant` — in-memory `ConcurrentHashMap` + hot path (`add()` is called per relay frame), debounced flush (30s) into the + store, day-rollover handling, merged read API for UI/report. +- `ForegroundTracker` — startedActivities>0 as StateFlow. +- `RelayUsageListener` — `RelayConnectionListener` counting sent/received + frame sizes with current network/visibility dims. +- `RelayConnectionTimeIntegrator` — combines connectedRelays × isMobile × + isForeground; closes an accounting segment on every change and on + `closeOpenSegment()` (called from accountant flush and reads, so multi-hour + stable background sessions still account without any timer). +- `UsageCountingInterceptor` + counting response body — per-role HTTP bytes; + wrapped clients cached per (role, base client identity). +- `ResourceUsageReportAssembler` — Markdown: device/app header (crash-report + style), human summary (today + 7 days), fenced per-day counter dump. +- `ResourceUsageAlerts` — pure threshold logic (see below) + rate limiting. +- `DisplayResourceUsageAlert` — consent dialog (view details / send / not + now / don't ask again). +- UI: `ResourceUsageScreen` under `ui/screen/loggedIn/settings/`. + +### Wiring (AppModules / Amethyst / hooks) +- store + accountant + integrator constructed in `AppModules`; listener added + via `client.addConnectionListener`. +- `ForegroundTracker` registered in `Amethyst.onCreate` (main process only). +- `RoleBasedHttpClientBuilder` gains an optional usage meter. +- `EventNotificationConsumer.withWakeLock` gains an optional held-duration + callback (threaded through `NotificationDispatcher`). +- Workers increment their run counters via `Amethyst.instance` (guarded). +- `AppModules.trim()` flushes the accountant (backgrounding = natural flush). + +### Alert thresholds (v1, deliberately conservative — tune with real reports) +Evaluated on the last *complete* day, OR today once exceeded: +- background cellular traffic > 50 MB/day +- relay connection time > 12 relay-hours/day while backgrounded on cellular +- notification wakelock held > 30 min/day +- process starts > 75/day +Rate limit: at most one prompt per 7 days; "don't ask again" persisted. +Never auto-sends: every path goes through the DM composer where the user sees +exactly what will be sent and must tap Send. + +### Privacy +Counters are sizes, durations, and counts — no URLs, no relay names, no event +content. The report includes device model fields identical to the crash +report. Everything stays on-device until the user explicitly sends the DM +(NIP-40 30-day expiration, same as crash reports). + +### Explicitly out of scope (v1) +- Layer 1 (Perfetto/ODPM macrobenchmarks) and Layer 2 (`TrafficStats` socket + tags) — add only if the ledger proves blind somewhere (e.g. WS bytes are + payload-approximate; TrafficStats would give exact on-wire bytes). +- Per-relay attribution in the ledger (RelayStats screens already exist). +- Desktop: accountant/store are Android-module for now; extraction to commons + is mechanical if desktop wants it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 31b4ea27e8..c615047be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -110,6 +110,10 @@ class Amethyst : Application() { // kdoc for the threshold rationale. registerActivityLifecycleCallbacks(AppForegroundRecycleHook()) + // Foreground signal for the resource-usage ledger (fg/bg attribution + // of bytes and connection-time). Main process only. + registerActivityLifecycleCallbacks(instance.foregroundTracker) + if (isDebug) { Logging.setup() // Auto-enable the Nests session-trace recorder in debug diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0d1a92ff48..bb2f1700f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -86,6 +86,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator +import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate @@ -279,6 +286,24 @@ class AppModules( // on Tor-enabled clients to transparently redirect to .onion addresses. val onionLocationCache = OnionLocationCache() + // ---- Resource-usage ledger (battery/data accounting) ---- + // Passive on-device counters (bytes per subsystem x network x visibility, + // relay connection-time, wakelock time, worker runs). Never transmitted; + // the user can review them in Settings and explicitly DM a report to the + // developers. See amethyst/plans/2026-07-12-resource-usage-ledger.md. + val foregroundTracker = ForegroundTracker() + + val resourceUsageStore = ResourceUsageStore(File(appContext.filesDir, ResourceUsageStore.FILE_NAME)) + + val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope) + + private val httpUsageMeter = + HttpUsageMeter( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ) + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -301,7 +326,7 @@ class AppModules( ) // Offers easy methods to know when connections are happening through Tor or not - val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value, httpUsageMeter) val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") @@ -605,6 +630,23 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Resource-usage ledger: relay traffic + connection-time collectors. + init { + client.addConnectionListener( + RelayUsageListener( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ), + ) + RelayConnectionTimeIntegrator( + connectedCount = client.connectedRelaysFlow().map { it.size }, + isMobile = connManager.isMobileOrNull, + isForeground = foregroundTracker.isForeground, + accountant = resourceUsage, + ).start(applicationIOScope) + } + // Logs debug messages when needed val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null @@ -740,7 +782,11 @@ class AppModules( // Observes LocalCache for notification-relevant events and routes them to // EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay // subscriptions, and NotificationRelayService. - val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope) + val notificationDispatcher = + NotificationDispatcher(appContext, applicationIOScope) { heldMs -> + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_MS, heldMs) + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_COUNT, 1) + } // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. @@ -824,6 +870,10 @@ class AppModules( fun initiate(appContext: Context) { Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) + // Ledger: count process starts — high counts reveal WorkManager/restart + // churn that cold-starts the whole app graph repeatedly. + resourceUsage.add(UsageKeys.APP_STARTS, 1) + // Restore the persisted DNS cache before any networking starts. Lookups that fire // before this completes fall through to the sync resolver path (existing behavior); // once restored, every previously-seen host hits the stale-while-revalidate path @@ -1005,6 +1055,8 @@ class AppModules( fun trim(level: Int) { _trimLevelEvents.tryEmit(level) + // Backgrounding is a natural moment to flush the usage ledger too. + resourceUsage.flushAsync() applicationIOScope.launch { // Backgrounding is a natural moment to flush the DNS cache. dnsStore.save() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index a332044be6..b2ca4dcafc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.privacyOptions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient @@ -32,7 +34,18 @@ import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, val torSettings: TorSettingsFlow, + /** + * When present, every role's client is wrapped with a byte-counting + * interceptor so the resource-usage ledger can attribute HTTP traffic + * per subsystem. Null keeps the raw shared clients (tests). + */ + val usageMeter: HttpUsageMeter? = null, ) : IRoleBasedHttpClientBuilder { + private fun metered( + role: String, + base: OkHttpClient, + ): OkHttpClient = usageMeter?.counted(role, base) ?: base + fun shouldUseTorForImageDownload(url: String) = shouldUseTorFor( url, @@ -131,19 +144,19 @@ class RoleBasedHttpClientBuilder( override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) + override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url))) - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) + override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url))) - override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)) + override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))) - override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)) + override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))) - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)) + override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))) - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) + override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))) - override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())) /** * Returns a [SocketFactory] that routes through the user's Tor proxy diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index a68089408f..60453031bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -26,9 +26,11 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -60,6 +62,7 @@ class CalendarReminderWorker( params: WorkerParameters, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } val prefs = CalendarReminderPrefs(applicationContext) if (!prefs.isEnabled()) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8b048326b9..123a03e162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,6 +24,7 @@ import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.PowerManager +import android.os.SystemClock import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.asDrawable @@ -108,6 +109,8 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" class EventNotificationConsumer( private val applicationContext: Context, + /** Reports how long each notification-processing wakelock was held (resource-usage ledger). */ + private val onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes @@ -126,6 +129,7 @@ class EventNotificationConsumer( PowerManager.PARTIAL_WAKE_LOCK, "amethyst:notification_processing", ) + val heldSince = SystemClock.elapsedRealtime() wakeLock.acquire(WAKELOCK_TIMEOUT_MS) try { return block() @@ -133,6 +137,7 @@ class EventNotificationConsumer( if (wakeLock.isHeld) { wakeLock.release() } + onWakeLockHeld?.invoke(SystemClock.elapsedRealtime() - heldSince) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt index 610e57a859..d11a88bd00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -120,6 +121,7 @@ class NotificationCatchUpWorker( override suspend fun doWork(): Result { Log.d(TAG, "Starting notification catch-up") + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("notificationCatchUp"), 1) } return try { // If the foreground service should be running but isn't, restart it diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 37e6fb2f96..1f583ded83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -80,6 +80,8 @@ import kotlinx.coroutines.launch class NotificationDispatcher( private val context: Context, private val scope: CoroutineScope, + /** Forwarded to [EventNotificationConsumer]: reports wakelock held-time to the resource-usage ledger. */ + onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val TAG = "NotificationDispatcher" @@ -127,7 +129,7 @@ class NotificationDispatcher( ) } - private val consumer = EventNotificationConsumer(context) + private val consumer = EventNotificationConsumer(context, onWakeLockHeld) private var job: Job? = null fun start() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt new file mode 100644 index 0000000000..f51ec4e2f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -0,0 +1,159 @@ +/* + * 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.service.resourceusage + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.pluralStringResource +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Developer recipient for user-initiated diagnostic DMs — the same account + * the crash-report dialog routes to. + */ +const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + +/** + * On app open, checks the resource-usage ledger against the + * [ResourceUsageAlerts] thresholds and — at most once per week, unless the + * user opted out — asks whether they'd like to review + send a usage report + * to the developers. Confirming only PREFILLS the NIP-17 DM composer (crash + * report pattern): the full report text is visible there and nothing is sent + * until the user taps Send. + */ +@Composable +fun DisplayResourceUsageAlert( + accountViewModel: AccountViewModel, + nav: INav, +) { + val alert = remember { mutableStateOf(null) } + + LaunchedEffect(accountViewModel) { + withContext(Dispatchers.IO) { + val store = Amethyst.instance.resourceUsageStore + val accountant = Amethyst.instance.resourceUsage + if (!ResourceUsageAlerts.shouldPrompt(store.lastAlertAtSec(), store.alertsOptOut(), TimeUtils.now())) { + return@withContext + } + val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today()) + if (found != null) { + // Mark immediately so process restarts can't re-prompt within the window. + store.markAlertPrompted(TimeUtils.now()) + alert.value = found + } + } + } + + alert.value?.let { found -> + AlertDialog( + onDismissRequest = { alert.value = null }, + title = { Text(stringRes(R.string.resource_usage_alert_title)) }, + text = { + Text( + stringRes( + R.string.resource_usage_alert_message, + reasonDescription(found), + ), + ) + }, + dismissButton = { + Row { + TextButton(onClick = { + accountViewModel.runOnIO { + Amethyst.instance.resourceUsageStore.setAlertsOptOut(true) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_opt_out)) + } + TextButton(onClick = { alert.value = null }) { + Text(stringRes(R.string.resource_usage_alert_not_now)) + } + } + }, + confirmButton = { + Button(onClick = { + nav.nav { + val report = + ResourceUsageReportAssembler().buildReport( + Amethyst.instance.resourceUsage.allDaysIncludingLive(), + Amethyst.instance.resourceUsage.today(), + ) + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_send)) + } + }, + ) + } +} + +@Composable +private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String = + when (alert.reason) { + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA -> + stringRes( + R.string.resource_usage_reason_bg_data, + ResourceUsageReportAssembler.formatBytes(alert.value), + ) + + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME -> + stringRes( + R.string.resource_usage_reason_conn_time, + ResourceUsageReportAssembler.formatConnHours(alert.value), + ) + + ResourceUsageAlerts.Reason.WAKELOCK_TIME -> + stringRes( + R.string.resource_usage_reason_wakelock, + ResourceUsageReportAssembler.formatDurationMs(alert.value), + ) + + ResourceUsageAlerts.Reason.PROCESS_CHURN -> + pluralStringResource( + R.plurals.resource_usage_reason_churn, + alert.value.toInt(), + alert.value.toInt(), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt new file mode 100644 index 0000000000..0c4796a6a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt @@ -0,0 +1,69 @@ +/* + * 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.service.resourceusage + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-level foreground signal as an observable StateFlow: true while at + * least one activity is STARTED. Used by the usage ledger to attribute bytes + * and connection-time to foreground vs background buckets. + * + * ([MainActivity.isResumed] is not observable and goes false during PiP / + * in-app dialogs, which would misattribute foreground traffic to background.) + */ +class ForegroundTracker : Application.ActivityLifecycleCallbacks { + private var startedActivities = 0 + + private val _isForeground = MutableStateFlow(false) + val isForeground: StateFlow = _isForeground.asStateFlow() + + override fun onActivityStarted(activity: Activity) { + startedActivities++ + _isForeground.value = startedActivities > 0 + } + + override fun onActivityStopped(activity: Activity) { + startedActivities = (startedActivities - 1).coerceAtLeast(0) + _isForeground.value = startedActivities > 0 + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt new file mode 100644 index 0000000000..7fea7880eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt @@ -0,0 +1,92 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates relay-connection-time (Σ open connections × elapsed time) into + * the ledger, split by network class and visibility. Connection-time is the + * best single battery proxy the ping study found: most relays server-ping + * every 30-70s, so the radio is active for as long as connections are open. + * + * No timers: the integral is exact between state changes (the connection + * count is constant), so a segment is closed only when any input changes — + * plus on [closeOpenSegment], which the accountant calls before every flush + * and read so multi-hour stable sessions still account. + */ +class RelayConnectionTimeIntegrator( + private val connectedCount: Flow, + private val isMobile: Flow, + private val isForeground: Flow, + private val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + private data class SegmentState( + val count: Int, + val mobile: Boolean, + val foreground: Boolean, + ) + + private val lock = Any() + private var current: SegmentState? = null + private var segmentStartMs: Long = 0L + + fun start(scope: CoroutineScope): Job { + accountant.addPreFlushHook(::closeOpenSegment) + return scope.launch { + combine(connectedCount, isMobile, isForeground) { count, mobile, fg -> + SegmentState(count, mobile ?: false, fg) + }.collect { next -> transitionTo(next) } + } + } + + /** Accounts the running segment up to now without changing state. */ + fun closeOpenSegment() { + synchronized(lock) { + val state = current ?: return + val now = nowMs() + account(state, now - segmentStartMs) + segmentStartMs = now + } + } + + private fun transitionTo(next: SegmentState) { + synchronized(lock) { + val now = nowMs() + current?.let { account(it, now - segmentStartMs) } + current = next + segmentStartMs = now + } + } + + private fun account( + state: SegmentState, + elapsedMs: Long, + ) { + if (state.count <= 0 || elapsedMs <= 0) return + accountant.add(UsageKeys.relayConnMs(state.mobile, state.foreground), state.count * elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt new file mode 100644 index 0000000000..190649036f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -0,0 +1,58 @@ +/* + * 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.service.resourceusage + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command + +/** + * Counts relay websocket traffic into the usage ledger. Frame sizes are + * UTF-16 char counts of the JSON payload — a close proxy for on-wire bytes + * (relay JSON is ASCII-dominant), consistent with how RelayStats counts. + * Excludes WS framing/compression; good enough for "which subsystem is + * eating my data plan" comparisons. + */ +class RelayUsageListener( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (success) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong()) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt new file mode 100644 index 0000000000..0b19a1bf1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -0,0 +1,120 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder + +/** + * In-memory hot path for usage counters. [add] is called from network threads + * per relay frame / HTTP response chunk, so it must be allocation-light and + * lock-free: a ConcurrentHashMap of LongAdders, drained into + * [ResourceUsageStore] by a debounced flush (at most one write per + * [flushDebounceMs] while traffic flows; nothing scheduled when idle). + * + * Day attribution: deltas are drained into the bucket of the day they are + * drained on. Counts within one debounce window of midnight may land on the + * neighboring day — irrelevant at the ledger's day-level granularity. + */ +class ResourceUsageAccountant( + private val store: ResourceUsageStore, + private val scope: CoroutineScope, + private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS }, + private val flushDebounceMs: Long = 30_000L, +) { + private val live = ConcurrentHashMap() + private val flushScheduled = AtomicBoolean(false) + + /** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */ + private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>() + + fun addPreFlushHook(hook: () -> Unit) { + preFlushHooks.add(hook) + } + + fun add( + key: String, + amount: Long, + ) { + if (amount <= 0) return + live.computeIfAbsent(key) { LongAdder() }.add(amount) + if (flushScheduled.compareAndSet(false, true)) { + scope.launch { + delay(flushDebounceMs) + flushScheduled.set(false) + flush() + } + } + } + + /** Drains the in-memory counters into today's persisted bucket. */ + suspend fun flush() { + preFlushHooks.forEach { runCatching { it() } } + val deltas = drain() + if (deltas.isNotEmpty()) { + store.mergeInto(epochDay(), deltas) + } + } + + /** Fire-and-forget flush for non-suspending callers (onTrimMemory). */ + fun flushAsync() { + scope.launch { flush() } + } + + fun today(): Long = epochDay() + + /** + * Persisted buckets merged with the not-yet-flushed live counters + * (attributed to today). This is what the UI, the report assembler, + * and the alert evaluator read. + */ + suspend fun allDaysIncludingLive(): Map> { + preFlushHooks.forEach { runCatching { it() } } + val persisted = store.allDays() + val liveSnapshot = live.mapValues { it.value.sum() }.filterValues { it > 0 } + if (liveSnapshot.isEmpty()) return persisted + val today = epochDay() + val merged = persisted.toMutableMap() + val todayBucket = merged[today].orEmpty().toMutableMap() + liveSnapshot.forEach { (key, value) -> todayBucket[key] = (todayBucket[key] ?: 0L) + value } + merged[today] = todayBucket + return merged + } + + private fun drain(): Map { + if (live.isEmpty()) return emptyMap() + val deltas = mutableMapOf() + for (key in live.keys) { + val adder = live.remove(key) ?: continue + val value = adder.sum() + if (value > 0) deltas[key] = value + } + return deltas + } + + companion object { + const val DAY_MS = 24L * 60L * 60L * 1000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt new file mode 100644 index 0000000000..4180ef78cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt @@ -0,0 +1,96 @@ +/* + * 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.service.resourceusage + +/** + * Pure threshold logic for the "this app is consuming too much" prompt. + * Thresholds are deliberately conservative: the prompt should fire for the + * pathological cases (a stuck reconnect loop, process-restart churn, runaway + * background sync) — not for a heavy day of normal use. Tune them as real + * reports come in. + * + * Never auto-sends anything: a positive evaluation only ASKS the user, at + * most once every [MIN_DAYS_BETWEEN_PROMPTS] days, and respects a permanent + * opt-out. Both are persisted in [ResourceUsageStore]. + */ +object ResourceUsageAlerts { + enum class Reason { + BACKGROUND_MOBILE_DATA, + BACKGROUND_MOBILE_CONNECTION_TIME, + WAKELOCK_TIME, + PROCESS_CHURN, + } + + data class Alert( + val reason: Reason, + val day: Long, + val value: Long, + ) + + /** > 50 MB of background traffic on cellular in one day. */ + const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L + + /** > 12 relay-connection-hours while backgrounded on cellular in one day. */ + const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L + + /** > 30 minutes of notification wakelock held in one day. */ + const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L + + /** > 75 process starts in one day (WorkManager/restart churn). */ + const val APP_STARTS_PER_DAY = 75L + + const val MIN_DAYS_BETWEEN_PROMPTS = 7L + + /** + * Checks yesterday (the last complete day) first, then today (so a + * runaway condition surfaces without waiting for midnight). Returns the + * first threshold crossed or null. + */ + fun evaluate( + days: Map>, + today: Long, + ): Alert? { + for (day in longArrayOf(today - 1, today)) { + val counters = days[day] ?: continue + val summary = UsageSummary.from(counters) + + if (summary.mobileBytesBg > BG_MOBILE_BYTES_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_DATA, day, summary.mobileBytesBg) + } + if (summary.relayConnMsMobileBg > BG_MOBILE_RELAY_CONN_MS_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_CONNECTION_TIME, day, summary.relayConnMsMobileBg) + } + if (summary.wakelockMs > WAKELOCK_MS_PER_DAY) { + return Alert(Reason.WAKELOCK_TIME, day, summary.wakelockMs) + } + if (summary.appStarts > APP_STARTS_PER_DAY) { + return Alert(Reason.PROCESS_CHURN, day, summary.appStarts) + } + } + return null + } + + fun shouldPrompt( + lastAlertAtSec: Long, + optOut: Boolean, + nowSec: Long, + ): Boolean = !optOut && nowSec - lastAlertAtSec >= MIN_DAYS_BETWEEN_PROMPTS * 24L * 60L * 60L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt new file mode 100644 index 0000000000..47c8493b8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -0,0 +1,114 @@ +/* + * 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.service.resourceusage + +import android.os.Build +import com.vitorpamplona.amethyst.BuildConfig +import java.util.Locale + +/** + * Assembles the Markdown resource-usage report the user can DM to the + * developers via NIP-17 — same shape as the crash ReportAssembler: a device + * header table, a human-readable summary, then the full per-day counter dump + * as the technical payload. Counters are sizes/durations/counts only; no + * URLs, relay names, or content. + */ +class ResourceUsageReportAssembler { + fun buildReport( + days: Map>, + today: Long, + ): String { + val sb = StringBuilder() + sb.append("Resource Usage Report: ") + sb.append(BuildConfig.VERSION_NAME) + sb.append("-") + sb.append(BuildConfig.FLAVOR.uppercase()) + sb.append("\n\n") + + sb.append("| Prop | Value |\n") + sb.append("| --- | --- |\n") + sb.append("| Manuf | ${Build.MANUFACTURER} |\n") + sb.append("| Model | ${Build.MODEL} |\n") + sb.append("| Android | ${Build.VERSION.RELEASE} |\n") + sb.append("| SDK Int | ${Build.VERSION.SDK_INT} |\n") + sb.append("\n") + + val todayCounters = days[today].orEmpty() + val weekCounters = (today - 6..today).mapNotNull { days[it] } + + sb.append("**Today**\n\n") + sb.append(summaryTable(UsageSummary.from(todayCounters))) + sb.append("\n**Last 7 days**\n\n") + sb.append(summaryTable(UsageSummary.fromDays(weekCounters))) + + sb.append("\nTechnical details (per epoch-day):\n") + sb.append("```\n") + days.toSortedMap().forEach { (day, counters) -> + sb.append("day $day (today=$today)\n") + counters.toSortedMap().forEach { (key, value) -> + sb.append(" $key = $value\n") + } + } + sb.append("```\n") + return sb.toString() + } + + private fun summaryTable(s: UsageSummary): String = + buildString { + append("| Metric | Value |\n") + append("| --- | --- |\n") + append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n") + append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n") + append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n") + append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n") + append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") + append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") + append("| Background worker runs | ${s.workerRuns} |\n") + append("| App process starts | ${s.appStarts} |\n") + val subsystems = + s.bytesPerSubsystem.entries + .sortedByDescending { it.value } + .joinToString(", ") { "${it.key} ${formatBytes(it.value)}" } + if (subsystems.isNotEmpty()) { + append("| By subsystem | $subsystems |\n") + } + } + + companion object { + fun formatBytes(bytes: Long): String = + when { + bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) + bytes >= 1024L * 1024L -> String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) + bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0) + else -> "$bytes B" + } + + /** Relay-connection time: Σ connections x time, so shown as "relay-hours". */ + fun formatConnHours(ms: Long): String = String.format(Locale.US, "%.1f relay-hours", ms / (1000.0 * 60.0 * 60.0)) + + fun formatDurationMs(ms: Long): String = + when { + ms >= 60L * 60L * 1000L -> String.format(Locale.US, "%.1f h", ms / (1000.0 * 60.0 * 60.0)) + ms >= 60L * 1000L -> String.format(Locale.US, "%.1f min", ms / (1000.0 * 60.0)) + else -> String.format(Locale.US, "%.1f s", ms / 1000.0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt new file mode 100644 index 0000000000..7e7824631b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -0,0 +1,154 @@ +/* + * 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.service.resourceusage + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +/** + * Durable daily buckets for the resource-usage ledger, one JSON file in the + * app's private filesDir. Same persistence idiom as ScheduledPostStore: + * Jackson + Mutex + write-to-tmp-then-rename + version envelope. + * + * Day keys are UTC epoch-days (stringified for JSON). Buckets older than + * [keepDays] are pruned on every merge, so the file stays small (a few KB). + * Also carries the high-consumption alert state (last prompt time, opt-out) + * so the whole feature has exactly one file. + */ +class ResourceUsageStore( + private val storageFile: File, + private val keepDays: Long = 30, +) { + data class UsageFile( + val version: Int = 1, + val days: Map> = emptyMap(), + val lastAlertAtSec: Long = 0L, + val alertsOptOut: Boolean = false, + ) + + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + private val mutex = Mutex() + private var loaded = false + private var data = UsageFile() + + suspend fun mergeInto( + day: Long, + deltas: Map, + ) { + if (deltas.isEmpty()) return + mutex.withLock { + ensureLoaded() + val dayKey = day.toString() + val bucket = data.days[dayKey].orEmpty().toMutableMap() + deltas.forEach { (key, amount) -> bucket[key] = (bucket[key] ?: 0L) + amount } + val pruned = + data.days + .filterKeys { (it.toLongOrNull() ?: Long.MAX_VALUE) >= day - keepDays } + .toMutableMap() + pruned[dayKey] = bucket + data = data.copy(days = pruned) + persist() + } + } + + /** All persisted daily buckets, keyed by epoch-day. */ + suspend fun allDays(): Map> = + mutex.withLock { + ensureLoaded() + data.days.mapNotNull { (k, v) -> k.toLongOrNull()?.let { it to v } }.toMap() + } + + suspend fun lastAlertAtSec(): Long = + mutex.withLock { + ensureLoaded() + data.lastAlertAtSec + } + + suspend fun alertsOptOut(): Boolean = + mutex.withLock { + ensureLoaded() + data.alertsOptOut + } + + suspend fun markAlertPrompted(atSec: Long) = + mutex.withLock { + ensureLoaded() + data = data.copy(lastAlertAtSec = atSec) + persist() + } + + suspend fun setAlertsOptOut(optOut: Boolean) = + mutex.withLock { + ensureLoaded() + data = data.copy(alertsOptOut = optOut) + persist() + } + + private fun ensureLoaded() { + if (loaded) return + data = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile) + } else { + UsageFile() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load resource usage from $storageFile", e) + UsageFile() + } + loaded = true + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, data) + if (!tmp.renameTo(storageFile)) { + if (!storageFile.delete() || !tmp.renameTo(storageFile)) { + Log.e(TAG) { "Failed to rename $tmp to $storageFile" } + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist resource usage to $storageFile", e) + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + + companion object { + private const val TAG = "ResourceUsageStore" + const val FILE_NAME = "resource_usage.json" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt new file mode 100644 index 0000000000..296b038e86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -0,0 +1,124 @@ +/* + * 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.service.resourceusage + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.ForwardingSource +import okio.Source +import okio.buffer +import java.util.concurrent.ConcurrentHashMap + +/** + * Application interceptor that attributes HTTP traffic to a ledger subsystem + * (image/video/uploads/money/nip05/preview/push). Upload size comes from the + * request body's contentLength; download size is counted as the app actually + * consumes the streamed response body, so partially-read streams (video seek, + * cancelled image loads) count only what crossed the wire to the app. + * + * Network/visibility dims are sampled when bytes flow, not when the request + * is created — a request issued on wifi that finishes on cellular counts as + * cellular, matching what the radio actually did. + */ +class UsageCountingInterceptor( + private val role: String, + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L + if (requestBytes > 0) { + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes) + } + + val response = chain.proceed(request) + val body = response.body + return response + .newBuilder() + .body( + CountingResponseBody(body) { bytes -> + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) + }, + ).build() + } + + private class CountingResponseBody( + private val delegate: ResponseBody, + private val onBytes: (Long) -> Unit, + ) : ResponseBody() { + override fun contentType() = delegate.contentType() + + override fun contentLength() = delegate.contentLength() + + private val countedSource by lazy { + object : ForwardingSource(delegate.source() as Source) { + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + val read = super.read(sink, byteCount) + if (read > 0) onBytes(read) + return read + } + }.buffer() + } + + override fun source() = countedSource + } +} + +/** + * Caches per-(role, base client) wrapped OkHttp clients so each role gets its + * counting interceptor without rebuilding the shared clients. Base clients + * are rebuilt on proxy/network changes (new identity), so the cache is + * cleared when it grows past a small bound. + */ +class HttpUsageMeter( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) { + private val wrapped = ConcurrentHashMap, OkHttpClient>() + + fun counted( + role: String, + base: OkHttpClient, + ): OkHttpClient { + if (wrapped.size > MAX_CACHED) wrapped.clear() + return wrapped.getOrPut(role to base) { + base + .newBuilder() + .addInterceptor(UsageCountingInterceptor(role, accountant, isMobile, isForeground)) + .build() + } + } + + companion object { + // roles (7) x live base clients (proxy on/off ~2) with headroom for + // network-change rebuilds before the clear kicks in. + private const val MAX_CACHED = 32 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt new file mode 100644 index 0000000000..ef04e0ea55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -0,0 +1,96 @@ +/* + * 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.service.resourceusage + +/** + * Counter-key grammar for the resource-usage ledger. Keys are flat strings so + * the on-disk store is schema-free — adding a counter never needs a migration. + * + * Dimensions: + * - network: `mobile` (cellular/metered) vs `wifi` (everything else) + * - visibility: `fg` (an activity is started) vs `bg` + * - direction: `rx` (downloaded) vs `tx` (uploaded) + * + * Counters are sizes, durations, and counts only — never URLs, relay names, or + * content. See plans/2026-07-12-resource-usage-ledger.md. + */ +object UsageKeys { + const val MOBILE = "mobile" + const val WIFI = "wifi" + const val FG = "fg" + const val BG = "bg" + const val RX = "rx" + const val TX = "tx" + + /** HTTP subsystems, matching IRoleBasedHttpClientBuilder's roles. */ + const val ROLE_IMAGE = "image" + const val ROLE_VIDEO = "video" + const val ROLE_UPLOADS = "uploads" + const val ROLE_MONEY = "money" + const val ROLE_NIP05 = "nip05" + const val ROLE_PREVIEW = "preview" + const val ROLE_PUSH = "push" + + val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH) + + /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ + fun net( + role: String, + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */ + fun relayMsg( + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */ + fun relayConnMs( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connms.${dim(mobile, foreground)}" + + /** `worker.scheduledPost.runs` */ + fun workerRuns(worker: String): String = "worker.$worker.runs" + + const val WAKELOCK_NOTIF_MS = "wakelock.notif.ms" + const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count" + const val APP_STARTS = "app.starts" + + fun dim( + mobile: Boolean, + foreground: Boolean, + ): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}" + + /** Sums every counter whose key matches all the given dot-delimited parts. */ + fun Map.sumMatching(vararg parts: String): Long { + var total = 0L + for ((key, value) in this) { + val segments = key.split('.') + if (parts.all { it in segments }) total += value + } + return total + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt new file mode 100644 index 0000000000..1e6d7e6b38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -0,0 +1,89 @@ +/* + * 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.service.resourceusage + +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys.sumMatching + +/** + * Headline metrics derived from one or more daily counter buckets. Shared by + * the usage screen, the NIP-17 report, and the high-consumption alerts so + * every surface agrees on the numbers. + */ +data class UsageSummary( + val mobileBytesBg: Long, + val mobileBytesFg: Long, + val wifiBytesBg: Long, + val wifiBytesFg: Long, + val relayConnMsMobileBg: Long, + val relayConnMsMobileFg: Long, + val relayConnMsWifiBg: Long, + val relayConnMsWifiFg: Long, + val wakelockMs: Long, + val wakelockCount: Long, + val workerRuns: Long, + val appStarts: Long, + /** total rx+tx bytes per subsystem (net roles + "relay"). */ + val bytesPerSubsystem: Map, +) { + val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg + val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg + val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg + + companion object { + fun from(counters: Map): UsageSummary { + fun traffic( + net: String, + vis: String, + ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) + + val subsystems = mutableMapOf() + for (role in UsageKeys.HTTP_ROLES) { + val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX) + if (bytes > 0) subsystems[role] = bytes + } + val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) + if (relayBytes > 0) subsystems["relay"] = relayBytes + + return UsageSummary( + mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), + mobileBytesFg = traffic(UsageKeys.MOBILE, UsageKeys.FG), + wifiBytesBg = traffic(UsageKeys.WIFI, UsageKeys.BG), + wifiBytesFg = traffic(UsageKeys.WIFI, UsageKeys.FG), + relayConnMsMobileBg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.BG), + relayConnMsMobileFg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.FG), + relayConnMsWifiBg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.BG), + relayConnMsWifiFg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.FG), + wakelockMs = counters[UsageKeys.WAKELOCK_NOTIF_MS] ?: 0L, + wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L, + workerRuns = counters.sumMatching("worker", "runs"), + appStarts = counters[UsageKeys.APP_STARTS] ?: 0L, + bytesPerSubsystem = subsystems, + ) + } + + /** Merges several day buckets and summarizes the total. */ + fun fromDays(days: Collection>): UsageSummary { + val merged = mutableMapOf() + days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } + return from(merged) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index 6663d98463..40d3e4fae8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -132,6 +133,7 @@ class ScheduledPostWorker( override suspend fun doWork(): Result { val nowSec = System.currentTimeMillis() / 1000 Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" } + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("scheduledPost"), 1) } return try { val appModules = Amethyst.instance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0be3941fad..e5e7c77fa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages +import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -228,6 +229,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettin import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen @@ -306,6 +308,7 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) ObserveIncomingCalls(accountViewModel) @@ -481,6 +484,7 @@ fun BuildNavigation( composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } + composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e16b15383d..15be5ed14d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -422,6 +422,8 @@ sealed class Route { @Serializable object CalendarReminderSettings : Route() + @Serializable object ResourceUsage : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt new file mode 100644 index 0000000000..06324565ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs +import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +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.withContext + +/** + * The resource-usage ledger: how much network, relay connection time, and + * background activity the app consumed, per subsystem — with an explicit, + * user-initiated path to DM the numbers to the developers (same NIP-17 flow + * as crash reports). Everything on this screen stays on-device until the + * user sends that DM. + */ +@Composable +fun ResourceUsageScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var days by remember { mutableStateOf>?>(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + days = Amethyst.instance.resourceUsage.allDaysIncludingLive() + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.resource_usage_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + val loaded = days + if (loaded == null) { + Text( + text = stringRes(R.string.resource_usage_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val today = Amethyst.instance.resourceUsage.today() + val todaySummary = UsageSummary.from(loaded[today].orEmpty()) + val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) + + UsageSummarySection(R.string.resource_usage_today, todaySummary) + UsageSummarySection(R.string.resource_usage_week, weekSummary) + SubsystemSection(weekSummary) + SendReportSection(accountViewModel, nav, loaded, today) + } + } + } +} + +@Composable +private fun UsageSummarySection( + @StringRes title: Int, + s: UsageSummary, +) { + SettingsSection(title) { + MetricRow(R.string.resource_usage_cellular_bg, formatBytes(s.mobileBytesBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_cellular_fg, formatBytes(s.mobileBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + } +} + +@Composable +private fun SubsystemSection(week: UsageSummary) { + if (week.bytesPerSubsystem.isEmpty()) return + SettingsSection(R.string.resource_usage_by_subsystem) { + val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } + rows.forEachIndexed { index, (subsystem, bytes) -> + if (index > 0) SettingsDivider() + MetricRow(subsystemLabel(subsystem), formatBytes(bytes)) + } + } +} + +@StringRes +private fun subsystemLabel(subsystem: String): Int = + when (subsystem) { + "relay" -> R.string.resource_usage_subsystem_relay + "image" -> R.string.resource_usage_subsystem_image + "video" -> R.string.resource_usage_subsystem_video + "uploads" -> R.string.resource_usage_subsystem_uploads + "money" -> R.string.resource_usage_subsystem_money + "nip05" -> R.string.resource_usage_subsystem_nip05 + "preview" -> R.string.resource_usage_subsystem_preview + "push" -> R.string.resource_usage_subsystem_push + else -> R.string.resource_usage_subsystem_other + } + +@Composable +private fun MetricRow( + @StringRes label: Int, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SendReportSection( + accountViewModel: AccountViewModel, + nav: INav, + days: Map>, + today: Long, +) { + SettingsSection(R.string.resource_usage_send_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_send_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today) + nav.nav { + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_send_button)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 3ace415fd7..5e85e88c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -103,6 +103,7 @@ fun buildSettingsCatalog( symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), + symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..0239d5a241 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3180,6 +3180,45 @@ Crash Report found Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared Send it + + App resource usage + battery data usage consumption power network diagnostics ledger + Today + Last 7 days + Cellular data (background) + Cellular data (in app) + Wi-Fi data + Relay connection time + \u2026 backgrounded on cellular + Notification processing (CPU awake) + Background jobs run + App process starts + Data by feature (7 days) + Relay sync + Images + Video & audio + Uploads + Wallet & zaps + Address verification + Link previews + Push registration + Other + No usage recorded yet. + Share with the developers + 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. + Send report via DM + High resource usage detected + Amethyst consumed more than expected recently: %1$s. Would you like to send a usage report to the developers in an encrypted DM? You will see the full report before anything is sent. + %1$s of cellular data in the background in one day + %1$s of relay connections while backgrounded on cellular in one day + the CPU was held awake for %1$s processing notifications in one day + + the app process restarted %1$d time in one day + the app process restarted %1$d times in one day + + Review & send + Not now + Don\u2019t ask again This message will disappear in %1$s Select Signer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt new file mode 100644 index 0000000000..7a9c0cf9c1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -0,0 +1,295 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ResourceUsageStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + @Before + fun setUp() { + file = File(temp.root, ResourceUsageStore.FILE_NAME) + } + + @Test + fun mergesAndReloadsFromDisk() = + runTest { + val store = ResourceUsageStore(file) + store.mergeInto(100, mapOf("a" to 5L, "b" to 2L)) + store.mergeInto(100, mapOf("a" to 3L)) + store.mergeInto(101, mapOf("a" to 1L)) + + val reloaded = ResourceUsageStore(file).allDays() + assertEquals(8L, reloaded[100]?.get("a")) + assertEquals(2L, reloaded[100]?.get("b")) + assertEquals(1L, reloaded[101]?.get("a")) + } + + @Test + fun prunesBucketsOlderThanKeepDays() = + runTest { + val store = ResourceUsageStore(file, keepDays = 7) + store.mergeInto(100, mapOf("a" to 1L)) + store.mergeInto(110, mapOf("a" to 1L)) + + val days = store.allDays() + assertNull("day 100 is older than 110-7 and must be pruned", days[100]) + assertEquals(1L, days[110]?.get("a")) + } + + @Test + fun alertStateRoundTrips() = + runTest { + val store = ResourceUsageStore(file) + assertEquals(0L, store.lastAlertAtSec()) + assertFalse(store.alertsOptOut()) + + store.markAlertPrompted(12345L) + store.setAlertsOptOut(true) + + val reloaded = ResourceUsageStore(file) + assertEquals(12345L, reloaded.lastAlertAtSec()) + assertTrue(reloaded.alertsOptOut()) + } +} + +class ResourceUsageAccountantTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accumulatesAndFlushesIntoTheRightDay() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + var day = 200L + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { day }) + + accountant.add("x", 5) + accountant.add("x", 5) + accountant.flush() + day = 201L + accountant.add("x", 7) + accountant.flush() + + val days = store.allDays() + assertEquals(10L, days[200]?.get("x")) + assertEquals(7L, days[201]?.get("x")) + } + + @Test + fun liveCountersAreVisibleBeforeFlush() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + + accountant.add("x", 42) + val days = accountant.allDaysIncludingLive() + assertEquals(42L, days[300]?.get("x")) + // and not yet on disk + assertNull(store.allDays()[300]) + } + + @Test + fun preFlushHooksRunOnFlushAndRead() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + var hookRuns = 0 + accountant.addPreFlushHook { hookRuns++ } + + accountant.flush() + accountant.allDaysIncludingLive() + assertEquals(2, hookRuns) + } +} + +class RelayConnectionTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun integratesConnectionTimeAcrossStateChanges() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(0) + val mobile = MutableStateFlow(false) + val fg = MutableStateFlow(true) + + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = mobile, + isForeground = fg, + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // 5 relays connected on wifi foreground for 10s + count.value = 5 + testScheduler.runCurrent() + now = 10_000L + + // switch to cellular background: closes the wifi segment + mobile.value = true + fg.value = false + testScheduler.runCurrent() + + // 5 relays on cellular background for 20s, then all disconnect + now = 30_000L + count.value = 0 + testScheduler.runCurrent() + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(5 * 10_000L, counters[UsageKeys.relayConnMs(mobile = false, foreground = true)]) + assertEquals(5 * 20_000L, counters[UsageKeys.relayConnMs(mobile = true, foreground = false)]) + job.cancel() + } + + @Test + fun closeOpenSegmentAccountsLongStableSessions() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(3) + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = MutableStateFlow(true), + isForeground = MutableStateFlow(false), + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // hours pass with no state change at all (always-on background) + now = 2 * 60 * 60 * 1000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals( + "reading the ledger must account the still-open segment", + 3 * 2 * 60 * 60 * 1000L, + counters[UsageKeys.relayConnMs(mobile = true, foreground = false)], + ) + job.cancel() + } +} + +class ResourceUsageAlertsTest { + private fun day(vararg counters: Pair) = mapOf(*counters) + + @Test + fun quietUsageDoesNotAlert() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = false, received = true) to 1024L * 1024L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 60L * 60L * 1000L, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun backgroundMobileDataCrossingThresholdAlerts() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = false, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY + 1, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA, alert?.reason) + assertEquals(9L, alert?.day) + } + + @Test + fun foregroundMobileDataDoesNotTripTheBackgroundThreshold() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 10, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun connectionTimeCounterDoesNotLeakIntoByteThreshold() { + // relay.connms keys carry mobile+bg dims but are milliseconds, not + // bytes: they must never count toward the data threshold. + val days = + mapOf( + 9L to + day( + UsageKeys.relayConnMs(mobile = true, foreground = false) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 100, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME, alert?.reason) + } + + @Test + fun todayIsCheckedWhenYesterdayIsQuiet() { + val days = + mapOf( + 10L to day(UsageKeys.APP_STARTS to ResourceUsageAlerts.APP_STARTS_PER_DAY + 1), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.PROCESS_CHURN, alert?.reason) + } + + @Test + fun promptRateLimiting() { + val now = 1_000_000L + val week = ResourceUsageAlerts.MIN_DAYS_BETWEEN_PROMPTS * 24 * 60 * 60 + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week + 10, optOut = false, nowSec = now)) + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week - 10, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = true, nowSec = now)) + } +} From f0f0056be44246460cc1873d584d4058c55d0680 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:16:14 +0000 Subject: [PATCH 05/39] feat(ledger): reconnect churn, process CPU, foreground time, verify metering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the resource-usage ledger with the metrics that answer "what else could be draining": - Relay (re)connection + failed-dial counters per network/visibility via the existing RelayConnectionListener hook. Every completed connect paid a TCP+TLS handshake, so high daily counts are the reconnect-churn signature — new alert threshold at >5000 connects/day. - Whole-process CPU time (Process.getElapsedCpuTime deltas sampled at ledger flush): the honest aggregate of parsing, crypto, coroutines and UI — one syscall per flush, nothing while idle. Answers whether CPU matters at all before any per-subsystem drill-down. - Foreground time (time with UI visible): what display power scales with, and the denominator that makes every other per-day number interpretable. - Signature-verification count + CPU time via a nullable meter hook on LocalCache.justVerify (wired like cache.onchainBackend), settling the "does Schnorr verify cost matter" question with data. Deliberately not tracked (documented in the plan): per-screen time (route names leak behavior patterns into a shareable report — needs its own privacy pass), per-coroutine CPU (needs a thread registry; cpu.ms first), and signing (user-action-rate, negligible). All metrics surface in the usage screen, the NIP-17 report, and UsageSummary; 4 new tests (18 total for the ledger). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 16 ++++ .../com/vitorpamplona/amethyst/AppModules.kt | 14 ++- .../amethyst/model/LocalCache.kt | 20 ++++- .../DisplayResourceUsageAlert.kt | 7 ++ .../resourceusage/ForegroundTimeIntegrator.kt | 73 ++++++++++++++++ .../resourceusage/ProcessCpuSampler.kt | 51 +++++++++++ .../resourceusage/RelayUsageListener.kt | 18 ++++ .../resourceusage/ResourceUsageAlerts.kt | 12 +++ .../ResourceUsageReportAssembler.kt | 4 + .../service/resourceusage/UsageKeys.kt | 22 +++++ .../service/resourceusage/UsageSummary.kt | 12 +++ .../loggedIn/settings/ResourceUsageScreen.kt | 8 ++ amethyst/src/main/res/values/strings.xml | 8 ++ .../resourceusage/ResourceUsageLedgerTest.kt | 87 +++++++++++++++++++ 14 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 7d460b68bf..5c8e8643a5 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -58,6 +58,22 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: - `worker.scheduledPost.runs` / `worker.calendarReminder.runs` / `worker.notificationCatchUp.runs` - `app.starts` — process starts (detects WorkManager cold-start churn) +- `relay.connects..` / `relay.connfails..` — completed + (re)connections and failed dials; each connect paid a TCP+TLS handshake, + so high daily counts are the reconnect-churn signature +- `cpu.ms` — whole-process CPU time deltas ([android.os.Process + .getElapsedCpuTime] sampled at flush): the honest aggregate of parsing, + crypto, coroutines, and UI without per-subsystem guesswork +- `app.fgms` — time with UI visible; display power is proportional to it and + it's the denominator for every per-day comparison +- `crypto.verify.count` / `crypto.verify.us` — event signature verifications + (LocalCache.justVerify hook), settling "does Schnorr verify cost matter" + with data + +Deliberately not tracked (v1): per-screen time (route names leak behavior +patterns into a report — needs its own privacy review), per-coroutine or +per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU +matters at all first), signing (user-action-rate, negligible). Flat keys keep the store schema-free: new counters need no migration. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bb2f1700f9..c1c3fe6d71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -86,8 +86,10 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant @@ -630,7 +632,8 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) - // Resource-usage ledger: relay traffic + connection-time collectors. + // Resource-usage ledger: relay traffic/reconnect + connection-time, + // foreground-time, process-CPU, and signature-verification collectors. init { client.addConnectionListener( RelayUsageListener( @@ -645,6 +648,15 @@ class AppModules( isForeground = foregroundTracker.isForeground, accountant = resourceUsage, ).start(applicationIOScope) + ForegroundTimeIntegrator( + isForeground = foregroundTracker.isForeground, + accountant = resourceUsage, + ).start(applicationIOScope) + ProcessCpuSampler(resourceUsage).register() + cache.verifyMeter = { elapsedNanos, _ -> + resourceUsage.add(UsageKeys.VERIFY_COUNT, 1) + resourceUsage.add(UsageKeys.VERIFY_US, elapsedNanos / 1_000) + } } // Logs debug messages when needed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 58cdad1fc9..2dbba0d893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3216,10 +3216,27 @@ object LocalCache : ILocalCache, ICacheProvider { live.removedNote(newNote) } + /** + * Resource-usage ledger hook: called with (elapsedNanos, valid) for every + * signature verification so the app can account crypto CPU per day. + * Wired by AppModules like [onchainBackend]; null costs nothing. + */ + var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null + fun justVerify(event: Event): Boolean { checkNotInMainThread() - return if (!event.verify()) { + val meter = verifyMeter + if (meter == null) return justVerifyInner(event) + + val start = System.nanoTime() + val valid = justVerifyInner(event) + meter(System.nanoTime() - start, valid) + return valid + } + + private fun justVerifyInner(event: Event): Boolean = + if (!event.verify()) { try { event.checkSignature() } catch (e: Exception) { @@ -3230,7 +3247,6 @@ object LocalCache : ILocalCache, ICacheProvider { } else { true } - } fun consume( event: DraftWrapEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index f51ec4e2f0..73622fb097 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -156,4 +156,11 @@ private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String = alert.value.toInt(), alert.value.toInt(), ) + + ResourceUsageAlerts.Reason.RECONNECT_CHURN -> + pluralStringResource( + R.plurals.resource_usage_reason_reconnects, + alert.value.toInt(), + alert.value.toInt(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt new file mode 100644 index 0000000000..125951ef8a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt @@ -0,0 +1,73 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]). + * Screen-on time is what display power is proportional to, and it's the + * denominator that makes every other counter interpretable (MB per hour in + * app vs MB while backgrounded). Same timer-free segment pattern as + * [RelayConnectionTimeIntegrator]: segments close on transitions and on the + * accountant's pre-flush hook. + */ +class ForegroundTimeIntegrator( + private val isForeground: Flow, + private val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + private val lock = Any() + private var foreground = false + private var segmentStartMs = 0L + + fun start(scope: CoroutineScope): Job { + accountant.addPreFlushHook(::closeOpenSegment) + return scope.launch { + isForeground.collect { fg -> transitionTo(fg) } + } + } + + fun closeOpenSegment() { + synchronized(lock) { + if (!foreground) return + val now = nowMs() + account(now - segmentStartMs) + segmentStartMs = now + } + } + + private fun transitionTo(fg: Boolean) { + synchronized(lock) { + val now = nowMs() + if (foreground) account(now - segmentStartMs) + foreground = fg + segmentStartMs = now + } + } + + private fun account(elapsedMs: Long) { + if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt new file mode 100644 index 0000000000..408101afa4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt @@ -0,0 +1,51 @@ +/* + * 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.service.resourceusage + +import android.os.Process + +/** + * Samples whole-process CPU time (user+system, [Process.getElapsedCpuTime]) + * into the ledger as day deltas. This is the honest aggregate cost of + * everything that runs on the CPU — event parsing, signature verification, + * coroutines, recomposition — without per-subsystem guesswork. Sampled from + * the accountant's pre-flush hook, so it costs one syscall per flush and + * nothing while idle. Per-process monotonic: a fresh process simply starts a + * fresh baseline. + */ +class ProcessCpuSampler( + private val accountant: ResourceUsageAccountant, + private val cpuMs: () -> Long = { Process.getElapsedCpuTime() }, +) { + private var lastSampleMs = cpuMs() + + fun register() { + accountant.addPreFlushHook(::sample) + } + + @Synchronized + fun sample() { + val now = cpuMs() + val delta = now - lastSampleMs + lastSampleMs = now + if (delta > 0) accountant.add(UsageKeys.CPU_MS, delta) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index 190649036f..a8ba773138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -55,4 +55,22 @@ class RelayUsageListener( ) { accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) } + + // Every completed (re)connection paid a TCP+TLS handshake; high daily + // counts are the signature of reconnect churn (flaky network, aggressive + // relay idle timeouts, Tor bootstrap loops). + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1) + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt index 4180ef78cc..b72cbdf173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt @@ -37,6 +37,7 @@ object ResourceUsageAlerts { BACKGROUND_MOBILE_CONNECTION_TIME, WAKELOCK_TIME, PROCESS_CHURN, + RECONNECT_CHURN, } data class Alert( @@ -57,6 +58,14 @@ object ResourceUsageAlerts { /** > 75 process starts in one day (WorkManager/restart churn). */ const val APP_STARTS_PER_DAY = 75L + /** + * > 5000 completed relay (re)connections in one day. A healthy day is a + * few hundred to ~2000 even with a large relay set; sustained thousands + * means something is cycling (a stuck relay tier, a flapping network, a + * Tor bootstrap loop) and every cycle pays a TLS handshake. + */ + const val RELAY_CONNECTS_PER_DAY = 5_000L + const val MIN_DAYS_BETWEEN_PROMPTS = 7L /** @@ -84,6 +93,9 @@ object ResourceUsageAlerts { if (summary.appStarts > APP_STARTS_PER_DAY) { return Alert(Reason.PROCESS_CHURN, day, summary.appStarts) } + if (summary.relayConnects > RELAY_CONNECTS_PER_DAY) { + return Alert(Reason.RECONNECT_CHURN, day, summary.relayConnects) + } } return null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 47c8493b8b..ae96b6f45b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -81,6 +81,10 @@ class ResourceUsageReportAssembler { append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n") append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") + append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") + append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") + append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") + append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") append("| Background worker runs | ${s.workerRuns} |\n") append("| App process starts | ${s.appStarts} |\n") val subsystems = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index ef04e0ea55..93918f5d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -72,6 +72,18 @@ object UsageKeys { foreground: Boolean, ): String = "relay.connms.${dim(mobile, foreground)}" + /** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */ + fun relayConnects( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connects.${dim(mobile, foreground)}" + + /** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */ + fun relayConnectFails( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connfails.${dim(mobile, foreground)}" + /** `worker.scheduledPost.runs` */ fun workerRuns(worker: String): String = "worker.$worker.runs" @@ -79,6 +91,16 @@ object UsageKeys { const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count" const val APP_STARTS = "app.starts" + /** Whole-process CPU time (user+system) — the honest aggregate of parsing, crypto, coroutines, and UI. */ + const val CPU_MS = "cpu.ms" + + /** Time with at least one activity STARTED — the denominator that makes the other counters interpretable. */ + const val APP_FG_MS = "app.fgms" + + /** Event signature verifications (LocalCache.justVerify). */ + const val VERIFY_COUNT = "crypto.verify.count" + const val VERIFY_US = "crypto.verify.us" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 1e6d7e6b38..94faefeec3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -40,6 +40,12 @@ data class UsageSummary( val wakelockCount: Long, val workerRuns: Long, val appStarts: Long, + val relayConnects: Long, + val relayConnectFails: Long, + val cpuMs: Long, + val foregroundMs: Long, + val verifyCount: Long, + val verifyUs: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -75,6 +81,12 @@ data class UsageSummary( wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L, workerRuns = counters.sumMatching("worker", "runs"), appStarts = counters[UsageKeys.APP_STARTS] ?: 0L, + relayConnects = counters.sumMatching("connects"), + relayConnectFails = counters.sumMatching("connfails"), + cpuMs = counters[UsageKeys.CPU_MS] ?: 0L, + foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L, + verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L, + verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L, bytesPerSubsystem = subsystems, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 06324565ab..b29bb1f5ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -129,6 +129,14 @@ private fun UsageSummarySection( SettingsDivider() MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) SettingsDivider() + MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") + SettingsDivider() + MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_fg_time, formatDurationMs(s.foregroundMs)) + SettingsDivider() MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) SettingsDivider() MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0239d5a241..a6f2778da6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3192,6 +3192,10 @@ \u2026 backgrounded on cellular Notification processing (CPU awake) Background jobs run + Relay reconnections (failed) + Signatures verified + Processor time used + Time in app App process starts Data by feature (7 days) Relay sync @@ -3216,6 +3220,10 @@ the app process restarted %1$d time in one day the app process restarted %1$d times in one day + + the app reconnected to relays %1$d time in one day + the app reconnected to relays %1$d times in one day + Review & send Not now Don\u2019t ask again diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 7a9c0cf9c1..677930b284 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -213,6 +213,78 @@ class RelayConnectionTimeIntegratorTest { } } +class ProcessCpuSamplerTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsCpuDeltasAcrossSamples() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var cpu = 1_000L + val sampler = ProcessCpuSampler(accountant) { cpu } + + cpu = 1_500L + sampler.sample() + cpu = 1_800L + sampler.sample() + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(800L, counters[UsageKeys.CPU_MS]) + } +} + +class ForegroundTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsOnlyForegroundTime() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 0L + val fg = MutableStateFlow(false) + val integrator = ForegroundTimeIntegrator(fg, accountant) { now } + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // 60s in background — must not count + now = 60_000L + fg.value = true + testScheduler.runCurrent() + + // 30s in foreground — counts + now = 90_000L + fg.value = false + testScheduler.runCurrent() + + // 60s more in background, then read: still 30s + now = 150_000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(30_000L, counters[UsageKeys.APP_FG_MS]) + job.cancel() + } + + @Test + fun openForegroundSegmentIsAccountedOnRead() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 0L + val fg = MutableStateFlow(true) + val integrator = ForegroundTimeIntegrator(fg, accountant) { now } + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + now = 45_000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(45_000L, counters[UsageKeys.APP_FG_MS]) + job.cancel() + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) @@ -283,6 +355,21 @@ class ResourceUsageAlertsTest { assertEquals(ResourceUsageAlerts.Reason.PROCESS_CHURN, alert?.reason) } + @Test + fun reconnectChurnAlerts() { + val days = + mapOf( + 9L to + day( + UsageKeys.relayConnects(mobile = true, foreground = false) to 3_000L, + UsageKeys.relayConnects(mobile = false, foreground = true) to + ResourceUsageAlerts.RELAY_CONNECTS_PER_DAY - 2_000L, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.RECONNECT_CHURN, alert?.reason) + } + @Test fun promptRateLimiting() { val now = 1_000_000L From 716a7c82cb428a304f3cf9372e21aeeb3fad033b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:24:46 +0000 Subject: [PATCH 06/39] feat(ledger): live memory card on the usage screen; retire the debug-only chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../DisplayResourceUsageAlert.kt | 14 ++- .../ResourceUsageReportAssembler.kt | 14 +++ .../ui/navigation/topbars/MemoryUsageChip.kt | 116 ------------------ .../topbars/UserDrawerSearchTopBar.kt | 1 - .../loggedIn/settings/ResourceUsageScreen.kt | 45 ++++++- amethyst/src/main/res/values/strings.xml | 10 ++ 6 files changed, 77 insertions(+), 123 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index 73622fb097..ddac466f85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -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(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index ae96b6f45b..8e4c9254b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -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>, 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) -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt deleted file mode 100644 index 85a205c84e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt +++ /dev/null @@ -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(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") } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 5ab67c459f..efdcfff438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -75,7 +75,6 @@ fun UserDrawerSearchTopBar( } }, actions = { - MemoryUsageChip() IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index b29bb1f5ec..f7d10737aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -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(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>, 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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a6f2778da6..cd3650cec8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3208,6 +3208,16 @@ Push registration Other No usage recorded yet. + Memory right now + App memory (heap) + Native memory + Image cache (RAM) + Image cache (disk) + Notes in memory + Profiles in memory + Addressable events in memory + Chatroom lists in memory + Device memory class Share with the developers 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. Send report via DM From 64d6c038a72756d17ba955c42b95a9c5f3ec0d22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:15:04 +0000 Subject: [PATCH 07/39] =?UTF-8?q?feat(ledger):=20redesign=20the=20usage=20?= =?UTF-8?q?screen=20=E2=80=94=20stat=20tiles,=20trend=20chart,=20proportio?= =?UTF-8?q?n=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version was a wall of identical label-value rows: correct but unreadable at a glance. The redesign gives the screen visual hierarchy and proportion: - Headline 2x2 stat tiles for today (cellular, wi-fi, relay time, time in app) — the numbers that matter get the visual weight. - A 7-day data-per-day trend chart (Vico, same line/area idioms and weekday axis as the notification summary chart) with a cellular/wi-fi legend. Color pairs validated for CVD separation and lightness on both surfaces: BitcoinOrange+RoyalBlue on light, #C77414+RoyalBlue on dark (a selected dark variant, not an automatic flip). - Data-by-feature as ranked proportion bars, so "video dwarfs relays" is visible without reading numbers. - Live memory as meters: the heap bar keeps the retired debug chip's green/amber/red thresholds; image caches as fill bars. - Activity counters stay as compact rows under one section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../loggedIn/settings/ResourceUsageScreen.kt | 200 ++++++++++++++---- .../loggedIn/settings/UsageTrendChart.kt | 174 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 + 3 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index f7d10737aa..8d685c1a37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -21,14 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.annotation.StringRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -41,6 +48,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -70,6 +79,9 @@ import kotlinx.coroutines.withContext * user-initiated path to DM the numbers to the developers (same NIP-17 flow * as crash reports). Everything on this screen stays on-device until the * user sends that DM. + * + * Layout: headline stat tiles (today) → 7-day trend chart → per-feature + * proportion bars → activity counters → live memory meters → send card. */ @Composable fun ResourceUsageScreen( @@ -117,9 +129,14 @@ fun ResourceUsageScreen( val todaySummary = UsageSummary.from(loaded[today].orEmpty()) val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) - UsageSummarySection(R.string.resource_usage_today, todaySummary) - UsageSummarySection(R.string.resource_usage_week, weekSummary) + TodayTiles(todaySummary) + if (weekSummary.totalBytes > 0) { + SettingsSection(R.string.resource_usage_trend_section) { + UsageTrendChart(loaded, today) + } + } SubsystemSection(weekSummary) + ActivitySection(weekSummary) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -127,46 +144,64 @@ fun ResourceUsageScreen( } } +/** Headline numbers for today as a 2x2 tile grid. */ @Composable -private fun UsageSummarySection( - @StringRes title: Int, - s: UsageSummary, -) { - SettingsSection(title) { - MetricRow(R.string.resource_usage_cellular_bg, formatBytes(s.mobileBytesBg)) - SettingsDivider() - MetricRow(R.string.resource_usage_cellular_fg, formatBytes(s.mobileBytesFg)) - SettingsDivider() - MetricRow(R.string.resource_usage_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg)) - SettingsDivider() - MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) - SettingsDivider() - MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") - SettingsDivider() - MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) - SettingsDivider() - MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_fg_time, formatDurationMs(s.foregroundMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) - SettingsDivider() - MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) +private fun TodayTiles(s: UsageSummary) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile(R.string.resource_usage_tile_cellular, formatBytes(s.mobileBytes), Modifier.weight(1f)) + StatTile(R.string.resource_usage_tile_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg), Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile(R.string.resource_usage_tile_relay, formatConnHours(s.relayConnMs), Modifier.weight(1f)) + StatTile(R.string.resource_usage_tile_in_app, formatDurationMs(s.foregroundMs), Modifier.weight(1f)) + } } } +@Composable +private fun StatTile( + @StringRes label: Int, + value: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +/** Ranked per-feature traffic with proportion bars (7 days). */ @Composable private fun SubsystemSection(week: UsageSummary) { if (week.bytesPerSubsystem.isEmpty()) return + val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } + val max = rows.first().value.coerceAtLeast(1L) SettingsSection(R.string.resource_usage_by_subsystem) { - val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } - rows.forEachIndexed { index, (subsystem, bytes) -> - if (index > 0) SettingsDivider() - MetricRow(subsystemLabel(subsystem), formatBytes(bytes)) + rows.forEach { (subsystem, bytes) -> + BarRow( + label = subsystemLabel(subsystem), + value = formatBytes(bytes), + fraction = bytes.toFloat() / max.toFloat(), + ) } } } @@ -185,6 +220,72 @@ private fun subsystemLabel(subsystem: String): Int = else -> R.string.resource_usage_subsystem_other } +/** Label + value + a thin rounded proportion bar underneath. */ +@Composable +private fun BarRow( + @StringRes label: Int, + value: String, + fraction: Float, + color: Color = MaterialTheme.colorScheme.primary, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(4.dp)) + .background(color), + ) + } + } +} + +/** Background/CPU activity counters for the last 7 days. */ +@Composable +private fun ActivitySection(s: UsageSummary) { + SettingsSection(R.string.resource_usage_activity_section) { + MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") + SettingsDivider() + MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + } +} + @Composable private fun MetricRow( @StringRes label: Int, @@ -207,18 +308,39 @@ private fun MetricRow( } } +/** + * Live memory meters. The heap bar keeps the retired debug chip's status + * semantics: green below 60% of the max heap, amber to 80%, red above. + */ @Composable private fun MemorySection(memory: MemorySnapshot?) { if (memory == null) return + val heapColor = + when { + memory.heapFraction > 0.80f -> Color(0xFFE53935) + memory.heapFraction > 0.60f -> Color(0xFFFFA000) + else -> Color(0xFF43A047) + } SettingsSection(R.string.resource_usage_memory_section) { - MetricRow(R.string.resource_usage_memory_heap, "${memory.heapUsedMb} / ${memory.heapMaxMb} MB") + BarRow( + label = R.string.resource_usage_memory_heap, + value = "${memory.heapUsedMb} / ${memory.heapMaxMb} MB", + fraction = memory.heapFraction, + color = heapColor, + ) + BarRow( + label = R.string.resource_usage_memory_image_cache, + value = "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB", + fraction = memory.imageCacheUsedMb.toFloat() / memory.imageCacheMaxMb.coerceAtLeast(1L).toFloat(), + ) + BarRow( + label = R.string.resource_usage_memory_image_disk, + value = "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB", + fraction = memory.imageDiskUsedMb.toFloat() / memory.imageDiskMaxMb.coerceAtLeast(1L).toFloat(), + ) 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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt new file mode 100644 index 0000000000..fed0882e3b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt @@ -0,0 +1,174 @@ +/* + * 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.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.LastWeekLabelFormatter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.makeLine +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.RoyalBlue +import kotlin.math.roundToInt + +/** + * Cellular-on-dark-surfaces variant of BitcoinOrange. The light/dark pairs + * (#F7931A + #4169E1 on light; #C77414 + #4169E1 on dark) were validated for + * CVD separation, lightness band, and chroma with the dataviz palette checks; + * the light orange's low surface contrast is relieved by the text legend. + */ +private val CellularOnDark = Color(0xFFC77414) + +/** + * 7-day data-per-day trend: one line per network class, MB per day, today at + * the right edge. Reuses the notification chart's Vico idioms ([makeLine], + * [LastWeekLabelFormatter], x = -6..0 with today at 0). + */ +@Composable +fun UsageTrendChart( + days: Map>, + today: Long, +) { + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val cellularColor = if (isDark) CellularOnDark else BitcoinOrange + val wifiColor = RoyalBlue + + val model = + remember(days, today) { + val xs = (-6..0).toList() + val cellular = + xs.map { offset -> + UsageSummary.from(days[today + offset].orEmpty()).mobileBytes.toMb() + } + val wifi = + xs.map { offset -> + val s = UsageSummary.from(days[today + offset].orEmpty()) + (s.wifiBytesBg + s.wifiBytesFg).toMb() + } + CartesianChartModel( + LineCartesianLayerModel.build { + series(xs, cellular) + series(xs, wifi) + }, + ) + } + + val chart = + rememberCartesianChart( + layers = + arrayOf( + LineCartesianLayer( + LineCartesianLayer.LineProvider.series( + makeLine(cellularColor), + makeLine(wifiColor), + ), + ), + ), + startAxis = + VerticalAxis.rememberStart( + valueFormatter = MegabyteAxisFormatter, + itemPlacer = VerticalAxis.ItemPlacer.count({ 5 }), + ), + bottomAxis = + HorizontalAxis.rememberBottom( + valueFormatter = LastWeekLabelFormatter(), + ), + ) + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + CartesianChartHost( + chart = chart, + model = model, + modifier = Modifier.fillMaxWidth().height(170.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LegendEntry(cellularColor, R.string.resource_usage_legend_cellular) + LegendEntry(wifiColor, R.string.resource_usage_legend_wifi) + } + } +} + +@Composable +private fun LegendEntry( + color: Color, + @StringRes label: Int, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(10.dp).background(color, CircleShape)) + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun Long.toMb(): Float = this / 1_048_576f + +private val MegabyteAxisFormatter = + object : CartesianValueFormatter { + override fun format( + context: CartesianMeasuringContext, + value: Double, + verticalAxisPosition: Axis.Position.Vertical?, + ): CharSequence = + if (value >= 1024) { + "%.1fG".format(value / 1024.0) + } else { + "${value.roundToInt()}M" + } + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cd3650cec8..240c09b37c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3185,6 +3185,14 @@ battery data usage consumption power network diagnostics ledger Today Last 7 days + Cellular today + Wi-Fi today + Relay time today + In app today + Data per day + Cellular (MB) + Wi-Fi (MB) + Activity (7 days) Cellular data (background) Cellular data (in app) Wi-Fi data From 718e2cba0bdf1d8a8f437ea08d2c5b73217ba58d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:32:32 +0000 Subject: [PATCH 08/39] feat(ui): move PoW, OTS and location pills to the note header's first line Restyle DisplayPoW, DisplayOts and DisplayLocation as compact squared chips via a new shared HeaderPill composable, matching the relay information pills in the NIP-29 chat headers (which now reuse the same composable). Move the three pills from SecondUserInfoRow to FirstUserInfoRow in NoteCompose, and shrink the secondary markers on that line (boosted, hashtag, community, draft, edited, expiration, pinned, private-rumor) to 12sp/smaller icons since the row now carries more information. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 41 +++++----- .../amethyst/ui/note/elements/BoostedMark.kt | 2 + .../ui/note/elements/DisplayCommunity.kt | 3 + .../ui/note/elements/DisplayEditStatus.kt | 2 + .../ui/note/elements/DisplayHashtags.kt | 3 + .../ui/note/elements/DisplayLocation.kt | 36 +++------ .../amethyst/ui/note/elements/DisplayOts.kt | 53 ++++++------- .../amethyst/ui/note/elements/DisplayPoW.kt | 46 ++--------- .../amethyst/ui/note/elements/HeaderPill.kt | 79 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 37 ++------- 10 files changed, 155 insertions(+), 147 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9939907e47..6286a1a484 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -761,7 +761,6 @@ fun InnerNoteWithReactions( if (showSecondRow) { SecondUserInfoRow( baseNote, - editState, accountViewModel, nav, ) @@ -883,7 +882,6 @@ fun NoteBody( if (showSecondRow) { SecondUserInfoRow( baseNote, - editState, accountViewModel, nav, ) @@ -1737,7 +1735,6 @@ fun ReplyNoteComposition( @Composable fun SecondUserInfoRow( note: Note, - editState: State>, accountViewModel: AccountViewModel, nav: INav, ) { @@ -1756,25 +1753,11 @@ fun SecondUserInfoRow( } } - val geo = remember(noteEvent) { noteEvent.geoHashOrScope() } - if (geo != null) { - Spacer(StdHorzSpacer) - DisplayLocation(geo, accountViewModel, nav) - } - val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { Spacer(StdHorzSpacer) DisplayReward(baseReward, note, accountViewModel, nav) } - - val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } - if (pow != null) { - Spacer(StdHorzSpacer) - DisplayPoW(pow) - } - - DisplayOtsIfInOriginal(note, editState, accountViewModel) } } @@ -1786,13 +1769,14 @@ fun DisplayExpiration(expirationDate: Long) { Icon( symbol = MaterialSymbols.Timer, contentDescription = stringRes(R.string.expiration_date_label), - modifier = Modifier.padding(start = 5.dp).size(15.dp), + modifier = Modifier.padding(start = 5.dp).size(12.dp), tint = MaterialTheme.colorScheme.placeholderText, ) val context = LocalContext.current Text( text = timeAheadNoDot(expirationDate, context), color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, maxLines = 1, modifier = Modifier.padding(start = 3.dp), ) @@ -1819,6 +1803,7 @@ fun DisplayDraft() { Text( "Draft", fontWeight = FontWeight.Bold, + fontSize = Font12SP, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, modifier = HalfStartPadding, @@ -1919,6 +1904,22 @@ fun FirstUserInfoRow( PinnedMark() } + val noteEvent = baseNote.event + + val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } + if (geo != null) { + Spacer(StdHorzSpacer) + DisplayLocation(geo, accountViewModel, nav) + } + + val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } + if (pow != null) { + Spacer(StdHorzSpacer) + DisplayPoW(pow) + } + + DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) + Expiration(baseNote) JumpToParentReplyButton(baseNote, accountViewModel, nav) @@ -1938,7 +1939,7 @@ fun PinnedMark() { Icon( symbol = MaterialSymbols.PushPin, contentDescription = stringRes(R.string.pinned_notes), - modifier = Modifier.padding(start = 5.dp).size(16.dp), + modifier = Modifier.padding(start = 5.dp).size(14.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -1948,7 +1949,7 @@ fun PrivateRumorMark() { Icon( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), - modifier = Modifier.padding(start = 5.dp).size(16.dp), + modifier = Modifier.padding(start = 5.dp).size(14.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index b91bdbeb81..d214b3391c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -34,6 +35,7 @@ fun BoostedMark() { Text( stringRes(id = R.string.boosted), fontWeight = FontWeight.Bold, + fontSize = Font12SP, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, modifier = HalfStartPadding, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 3e32487fc8..404688cc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +35,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress @@ -64,6 +66,7 @@ private fun DisplayCommunity( ClickableTextColor( label, + style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 8ebe574745..ee31e07dc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -50,6 +51,7 @@ fun DisplayEditStatus(editState: EditState) { LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.placeholderText, fontWeight = FontWeight.Bold, + fontSize = Font12SP, ), maxLines = 1, modifier = HalfStartPadding.clickable { editState.nextModification() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cc4f929afb..b9f73c3367 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,6 +39,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -82,6 +84,7 @@ private fun DisplayTagList( ) { ClickableTextColor( "#$firstTag", + style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index e6d942c620..cf240cb7f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -20,20 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withLink +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Font14SP +/** + * Compact pill showing the geohash a note is scoped to, resolved to a city + * name. Tapping it opens the geohash feed. Sits inline in note headers. + */ @Composable fun DisplayLocation( geohashStr: String, @@ -41,25 +38,10 @@ fun DisplayLocation( nav: INav, ) { LoadCityName(geohashStr) { cityName -> - Text( - text = - buildAnnotatedString { - withLink( - LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(geohashStr)) }, - ) { - append(cityName) - } - }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.LocationOn, + text = cityName, + onClick = { nav.nav(Route.Geohash(geohashStr)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index ec5b9bb06b..fdf5f6890c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -20,27 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.components.buildLinkString +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import java.text.SimpleDateFormat import java.util.Date +/** + * Compact pill showing the note's NIP-03 OpenTimestamps proof: how long ago + * the note is proven to have existed. Tapping it explains the proof and shows + * the attested date. Sits inline in note headers. + */ @Composable fun DisplayOts( note: Note, @@ -60,31 +60,26 @@ fun DisplayOts( ) } - Text( - text = - buildLinkString(stringRes(R.string.existed_since, timeStr)) { - accountViewModel.toastManager.toast( - R.string.ots_info_title, - R.string.ots_info_description, - SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)), - ) - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.CheckCircle, + text = stringRes(R.string.existed_since, timeStr), + modifier = HalfStartPadding, + contentDescription = stringRes(R.string.ots_info_title), + onClick = { + accountViewModel.toastManager.toast( + R.string.ots_info_title, + R.string.ots_info_description, + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)), + ) + }, ) }, whenPending = { - Text( - stringRes(id = R.string.timestamp_pending_short), - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.Schedule, + text = stringRes(id = R.string.timestamp_pending_short), + modifier = HalfStartPadding, + contentDescription = stringRes(R.string.ots_info_title), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index e257452632..64aad9648a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -20,24 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -51,34 +36,15 @@ fun DisplayPoWPreview() { } /** - * Compact pill showing the proof of work a received note carries: a bolt plus + * Compact pill showing the proof of work a received note carries: a gear plus * the difficulty in leading zero bits. Sits inline in note headers, so it * stays at text height. */ @Composable fun DisplayPoW(pow: Int) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 5.dp, vertical = 1.dp), - ) { - Icon( - symbol = MaterialSymbols.Manufacturing, - contentDescription = stringRes(R.string.pow_settings_title), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(10.dp), - ) - Text( - text = pow.toString(), - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontSize = 10.sp, - lineHeight = 10.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - } + HeaderPill( + symbol = MaterialSymbols.Manufacturing, + text = pow.toString(), + contentDescription = stringRes(R.string.pow_settings_title), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt new file mode 100644 index 0000000000..2e70a1f398 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt @@ -0,0 +1,79 @@ +/* + * 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.note.elements + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol + +/** + * Compact squared chip for note-header metadata (PoW, OTS, location, relay + * hosts, ...). Matches the relay information pills used in the NIP-29 chat + * headers so all header badges share one look: a small icon plus a short + * label on a secondary-container surface with slightly rounded corners. + */ +@Composable +fun HeaderPill( + symbol: MaterialSymbol, + text: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(11.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 442e49cf2d..976cbe3032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,20 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -78,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent +import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -453,31 +448,11 @@ private fun RelayNameChip( label: String, onClick: () -> Unit, ) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.clickable(onClick = onClick), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Icon( - symbol = MaterialSymbols.Dns, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(11.dp), - ) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + HeaderPill( + symbol = MaterialSymbols.Dns, + text = label, + onClick = onClick, + ) } @Composable From 903f3d9dcd891045e21e83d1a932961f48361773 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:54:38 +0000 Subject: [PATCH 09/39] feat(ledger): catch-all HTTP metering, radio-burst estimator, media play time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gaps the "should we track the non-websocket client?" question exposed, and adds the battery-estimation layer bytes alone can't provide: - Counting moves from per-role wrappers to a single interceptor on the shared base client (OkHttpClientFactory), with role attribution via request tags (role wrappers now only tag, inserted outside the counter). Anything reaching the shared clients untagged lands in a new "other" subsystem instead of escaping the ledger — which the napplet broker did, and which Coil's image traffic did too: setImageLoader called getHttpClient directly, bypassing the metered okHttpClientForImage. The app's largest HTTP consumer was invisible; now it's tagged "image". - Battery-relevant shape of the traffic, not just its size: per-subsystem request counts and active-transfer time, plus a radio-burst estimator (new HTTP activity after >10s of silence ~= one cellular radio ramp+tail). Scattered small requests cost far more energy than one continuous download of the same byte count; bursts/day makes that pattern visible and reportable. - Media playback time (ExoPlayer isPlaying segments via a listener in ExoPlayerBuilder — every player in the app): decoder+screen+streaming at once, turning "video used 800 MB" into "800 MB over 2h" (normal) vs "over 10 min" (a bug). All surfaced in the usage screen, the NIP-17 report, and UsageSummary; 20 ledger tests total (burst gap logic, play-time segments, and the existing suites) all passing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 11 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 26 +++- .../service/okhttp/DualHttpClientManager.kt | 6 +- .../service/okhttp/OkHttpClientFactory.kt | 9 ++ .../playback/playerPool/ExoPlayerBuilder.kt | 1 + .../playerPool/MediaPlayTimeTracker.kt | 54 +++++++ .../ResourceUsageReportAssembler.kt | 2 + .../resourceusage/UsageCountingInterceptor.kt | 138 ++++++++++++++---- .../service/resourceusage/UsageKeys.kt | 28 +++- .../service/resourceusage/UsageSummary.kt | 8 + .../loggedIn/settings/ResourceUsageScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 4 + .../resourceusage/ResourceUsageLedgerTest.kt | 53 +++++++ 13 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 5c8e8643a5..04db249238 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -69,6 +69,17 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: - `crypto.verify.count` / `crypto.verify.us` — event signature verifications (LocalCache.justVerify hook), settling "does Schnorr verify cost matter" with data +- `net....reqs` / `.activems` — HTTP request counts and + active-transfer time per subsystem; counting lives on the shared base + client (OkHttpClientFactory) with tag-based role attribution, so untagged + callers land in `other` instead of escaping the ledger +- `net.bursts..` — estimated radio wake-ups from HTTP burst + patterns (new activity after >10s of HTTP silence): the battery-relevant + measure that bytes alone can't capture, since scattered small requests + each pay the radio ramp+tail +- `media.playms` — actual media playback time (ExoPlayer isPlaying + segments): decoder + screen + streaming at once, the denominator for + video bytes Deliberately not tracked (v1): per-screen time (route names leak behavior patterns into a report — needs its own privacy review), per-coroutine or diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c1c3fe6d71..1c5e65cdf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -90,10 +90,12 @@ import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler +import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore @@ -299,13 +301,28 @@ class AppModules( val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope) - private val httpUsageMeter = - HttpUsageMeter( + // Estimates radio wake-ups from HTTP burst patterns — bytes alone don't + // predict battery; scattered small requests each pay the radio ramp+tail. + private val radioBurstEstimator = + RadioBurstEstimator( accountant = resourceUsage, isMobile = { connManager.isMobileOrFalse.value }, isForeground = { foregroundTracker.isForeground.value }, ) + // Single catch-all counter on the shared non-relay HTTP client: role + // wrappers only relabel via request tags, so no HTTP traffic (including + // direct getHttpClient users like the napplet broker) escapes the ledger. + private val httpUsageInterceptor = + UsageCountingInterceptor( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + bursts = radioBurstEstimator, + ) + + private val httpUsageMeter = HttpUsageMeter() + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -325,6 +342,7 @@ class AppModules( master && !profileOnly && localBlossomCacheProbe.available.value }, onionCache = onionLocationCache, + usageInterceptor = httpUsageInterceptor, ) // Offers easy methods to know when connections are happening through Tor or not @@ -871,7 +889,9 @@ class AppModules( diskCache = { diskCache }, memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, - callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + // Through the role builder (not raw getHttpClient) so Coil's image + // traffic carries the "image" ledger tag. Same Tor decision inside. + callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) }, thumbnailCache = thumbnailDiskCache, backgroundScope = applicationIOScope, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index e012ec2cdd..dec043dd04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import okhttp3.Call +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import java.net.InetSocketAddress @@ -46,8 +47,11 @@ class DualHttpClientManager( // is uniform across image, upload, NIP-05, money, preview, and push roles. // See [OkHttpClientFactory] kdoc. onionCache: OnionLocationCache, + // Resource-usage ledger counter, installed on the shared base client so + // every derived client is accounted. See [OkHttpClientFactory]. + usageInterceptor: Interceptor? = null, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 38efd9eb08..7a3b1f2e2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import okhttp3.ConnectionPool import okhttp3.Dispatcher +import okhttp3.Interceptor import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy @@ -61,6 +62,13 @@ class OkHttpClientFactory( */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, private val onionCache: OnionLocationCache, + /** + * Resource-usage ledger counter, installed OUTERMOST on the shared base + * client so every request through any derived client is accounted — + * per-role tagging wrappers only relabel, they never bypass. Null in + * tests / pre-configuration call sites. + */ + private val usageInterceptor: Interceptor? = null, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -103,6 +111,7 @@ class OkHttpClientFactory( .pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS)) .followRedirects(true) .followSslRedirects(true) + .apply { usageInterceptor?.let { addInterceptor(it) } } .addInterceptor(DefaultContentTypeInterceptor(userAgent)) .apply { blossomCacheRedirect?.let { addInterceptor(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 3a46279de2..7c25825785 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -84,6 +84,7 @@ class ExoPlayerBuilder( addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) + addListener(MediaPlayTimeTracker()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt new file mode 100644 index 0000000000..44acb2d203 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt @@ -0,0 +1,54 @@ +/* + * 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.service.playback.playerPool + +import android.os.SystemClock +import androidx.media3.common.Player +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys + +/** + * Accounts actual media playback time into the resource-usage ledger. + * Playback is one of the most energy-dense things the app does — decoder, + * screen, and streaming all at once — and this turns "video used 800 MB" + * into "800 MB over 2h of playback" (normal) vs "over 10 minutes" (a bug). + * + * Attached once per player in [ExoPlayerBuilder]; a player released + * mid-playback loses at most its final open segment. + */ +class MediaPlayTimeTracker( + private val onPlayed: (elapsedMs: Long) -> Unit = { ms -> + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.MEDIA_PLAY_MS, ms) } + }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : Player.Listener { + private var playingSinceMs = Long.MIN_VALUE + + override fun onIsPlayingChanged(isPlaying: Boolean) { + val now = nowMs() + if (isPlaying) { + playingSinceMs = now + } else if (playingSinceMs != Long.MIN_VALUE) { + onPlayed(now - playingSinceMs) + playingSinceMs = Long.MIN_VALUE + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 8e4c9254b3..73ba5d434e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -96,6 +96,8 @@ class ResourceUsageReportAssembler { append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") + append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n") + append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 296b038e86..37b198d2cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -29,46 +29,110 @@ import okio.ForwardingSource import okio.Source import okio.buffer import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** Request tag carrying the ledger subsystem a request belongs to. */ +class UsageRoleTag( + val role: String, +) /** - * Application interceptor that attributes HTTP traffic to a ledger subsystem - * (image/video/uploads/money/nip05/preview/push). Upload size comes from the - * request body's contentLength; download size is counted as the app actually - * consumes the streamed response body, so partially-read streams (video seek, - * cancelled image loads) count only what crossed the wire to the app. + * Estimates cellular-radio wake-ups caused by HTTP traffic: a new burst is + * counted whenever bytes flow after more than [BURST_GAP_MS] of HTTP silence, + * approximating the radio having dropped to idle in between (LTE/5G inactivity + * timers are typically ~10s). Bytes alone don't predict battery — many small + * scattered requests cost far more than one continuous download of the same + * size, because every burst pays the radio's ramp + tail energy. This counter + * is what makes that pattern visible. * - * Network/visibility dims are sampled when bytes flow, not when the request - * is created — a request issued on wifi that finishes on cellular counts as - * cellular, matching what the radio actually did. + * Caveat: bursts are counted from HTTP activity only. While relays are + * connected their traffic keeps the radio awake anyway — that cost is already + * captured by the relay connection-time counters. */ -class UsageCountingInterceptor( - private val role: String, +class RadioBurstEstimator( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + @Volatile private var lastActivityMs = Long.MIN_VALUE + + fun onHttpActivity() { + val now = nowMs() + val last = lastActivityMs + lastActivityMs = now + if (last == Long.MIN_VALUE || now - last > BURST_GAP_MS) { + accountant.add(UsageKeys.radioBursts(isMobile(), isForeground()), 1) + } + } + + companion object { + const val BURST_GAP_MS = 10_000L + } +} + +/** + * Application interceptor that accounts every HTTP request into the ledger: + * bytes up/down, request count, and active-transfer time, attributed to the + * subsystem named by the request's [UsageRoleTag] (set by the per-role + * clients from RoleBasedHttpClientBuilder) or to [defaultRole] for anything + * that reaches the shared clients untagged — so no HTTP traffic can escape + * the ledger. + * + * Installed FIRST on the base client (outermost), so the tagging interceptor + * of role-wrapped clients must be inserted BEFORE it (see [HttpUsageMeter]). + * Download bytes are counted as the app actually consumes the streamed body, + * so cancelled loads count only what crossed to the app. Network/visibility + * dims are sampled when bytes flow, matching what the radio actually did. + */ +class UsageCountingInterceptor( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, + private val bursts: RadioBurstEstimator? = null, + private val defaultRole: String = UsageKeys.ROLE_OTHER, + private val nowMs: () -> Long = { System.currentTimeMillis() }, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() + val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole + + accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1) + bursts?.onHttpActivity() + val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L if (requestBytes > 0) { accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes) } + val startedAtMs = nowMs() val response = chain.proceed(request) - val body = response.body return response .newBuilder() .body( - CountingResponseBody(body) { bytes -> - accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) - }, + CountingResponseBody( + delegate = response.body, + onBytes = { bytes -> + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) + bursts?.onHttpActivity() + }, + onFinished = { + accountant.add(UsageKeys.netActiveMs(role, isMobile(), isForeground()), nowMs() - startedAtMs) + }, + ), ).build() } private class CountingResponseBody( private val delegate: ResponseBody, private val onBytes: (Long) -> Unit, + onFinished: () -> Unit, ) : ResponseBody() { + private val finished = AtomicBoolean(false) + private val onFinishedOnce = { + if (finished.compareAndSet(false, true)) onFinished() + } + override fun contentType() = delegate.contentType() override fun contentLength() = delegate.contentLength() @@ -80,9 +144,18 @@ class UsageCountingInterceptor( byteCount: Long, ): Long { val read = super.read(sink, byteCount) - if (read > 0) onBytes(read) + if (read > 0) { + onBytes(read) + } else if (read == -1L) { + onFinishedOnce() + } return read } + + override fun close() { + super.close() + onFinishedOnce() + } }.buffer() } @@ -91,16 +164,14 @@ class UsageCountingInterceptor( } /** - * Caches per-(role, base client) wrapped OkHttp clients so each role gets its - * counting interceptor without rebuilding the shared clients. Base clients - * are rebuilt on proxy/network changes (new identity), so the cache is - * cleared when it grows past a small bound. + * Hands out per-subsystem OkHttp clients: the shared base client (which + * carries the single [UsageCountingInterceptor]) wrapped with a tagging + * interceptor inserted at position 0, OUTSIDE the counter, so the tag is + * visible when the counter reads it. Wrapped clients are cached per + * (role, base identity); base clients are rebuilt on proxy/network changes, + * so the cache is cleared when it grows past a small bound. */ -class HttpUsageMeter( - private val accountant: ResourceUsageAccountant, - private val isMobile: () -> Boolean, - private val isForeground: () -> Boolean, -) { +class HttpUsageMeter { private val wrapped = ConcurrentHashMap, OkHttpClient>() fun counted( @@ -111,13 +182,28 @@ class HttpUsageMeter( return wrapped.getOrPut(role to base) { base .newBuilder() - .addInterceptor(UsageCountingInterceptor(role, accountant, isMobile, isForeground)) + .apply { interceptors().add(0, RoleTaggingInterceptor(role)) } .build() } } + private class RoleTaggingInterceptor( + private val role: String, + ) : Interceptor { + private val tag = UsageRoleTag(role) + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed( + chain + .request() + .newBuilder() + .tag(UsageRoleTag::class.java, tag) + .build(), + ) + } + companion object { - // roles (7) x live base clients (proxy on/off ~2) with headroom for + // roles (8) x live base clients (proxy on/off ~2) with headroom for // network-change rebuilds before the clear kicks in. private const val MAX_CACHED = 32 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 93918f5d4b..2b0a799e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -49,7 +49,10 @@ object UsageKeys { const val ROLE_PREVIEW = "preview" const val ROLE_PUSH = "push" - val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH) + /** Catch-all for HTTP requests that reach the shared clients without a role tag. */ + const val ROLE_OTHER = "other" + + val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER) /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ fun net( @@ -59,6 +62,26 @@ object UsageKeys { received: Boolean, ): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}" + /** `net.image.mobile.bg.reqs` — HTTP request count for a subsystem. */ + fun netReqs( + role: String, + mobile: Boolean, + foreground: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.reqs" + + /** `net.image.mobile.bg.activems` — wall time spent actively transferring. */ + fun netActiveMs( + role: String, + mobile: Boolean, + foreground: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.activems" + + /** `net.bursts.mobile.bg` — estimated radio wake-ups caused by HTTP traffic. */ + fun radioBursts( + mobile: Boolean, + foreground: Boolean, + ): String = "net.bursts.${dim(mobile, foreground)}" + /** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */ fun relayMsg( mobile: Boolean, @@ -101,6 +124,9 @@ object UsageKeys { const val VERIFY_COUNT = "crypto.verify.count" const val VERIFY_US = "crypto.verify.us" + /** Media (video/audio) playback time — decoder + screen + streaming all at once. */ + const val MEDIA_PLAY_MS = "media.playms" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 94faefeec3..7a1cd0bb6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -46,6 +46,10 @@ data class UsageSummary( val foregroundMs: Long, val verifyCount: Long, val verifyUs: Long, + val httpRequests: Long, + val radioBursts: Long, + val httpActiveMs: Long, + val mediaPlayMs: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -87,6 +91,10 @@ data class UsageSummary( foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L, verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L, verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L, + httpRequests = counters.sumMatching("reqs"), + radioBursts = counters.sumMatching("bursts"), + httpActiveMs = counters.sumMatching("activems"), + mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, bytesPerSubsystem = subsystems, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 8d685c1a37..0e416b735d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -274,6 +274,14 @@ private fun ActivitySection(s: UsageSummary) { SettingsDivider() MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") SettingsDivider() + MetricRow(R.string.resource_usage_http_requests, s.httpRequests.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_radio_bursts, s.radioBursts.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_http_active, formatDurationMs(s.httpActiveMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_media_play, formatDurationMs(s.mediaPlayMs)) + SettingsDivider() MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) SettingsDivider() MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 240c09b37c..429795778e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3201,6 +3201,10 @@ Notification processing (CPU awake) Background jobs run Relay reconnections (failed) + Web requests + Radio wake-ups for downloads (est.) + Active transfer time + Media playback time Signatures verified Processor time used Time in app diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 677930b284..f99f1dc451 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -285,6 +286,58 @@ class ForegroundTimeIntegratorTest { } } +class RadioBurstEstimatorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun countsBurstsOnlyAfterRadioIdleGaps() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 100_000L + val estimator = + RadioBurstEstimator( + accountant = accountant, + isMobile = { true }, + isForeground = { false }, + nowMs = { now }, + ) + + estimator.onHttpActivity() // first activity = one burst + now += 2_000 + estimator.onHttpActivity() // 2s later: same burst + now += RadioBurstEstimator.BURST_GAP_MS + 1 + estimator.onHttpActivity() // >10s of silence: new burst + now += 500 + estimator.onHttpActivity() // same burst + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(2L, counters[UsageKeys.radioBursts(mobile = true, foreground = false)]) + } +} + +class MediaPlayTimeTrackerTest { + @Test + fun accumulatesOnlyWhilePlaying() { + var now = 0L + var played = 0L + val tracker = MediaPlayTimeTracker(onPlayed = { played += it }, nowMs = { now }) + + tracker.onIsPlayingChanged(true) + now = 30_000L + tracker.onIsPlayingChanged(false) + + now = 100_000L // paused time must not count + tracker.onIsPlayingChanged(true) + now = 105_000L + tracker.onIsPlayingChanged(false) + tracker.onIsPlayingChanged(false) // duplicate stop is a no-op + + assertEquals(35_000L, played) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From 2fefc3a409dcd3a7d14faca73953e4baed87546a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:28:59 +0000 Subject: [PATCH 10/39] feat(ui): unify note-header markers into a two-tier pill/quiet-mark system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (HeaderPill): tappable or verifiable metadata now renders as the squared secondary-container chip — location, PoW, OTS (already pills), plus expiration and the bounty reward (bolt icon + amount, tap opens the pledge dialog; icon turns orange once the user has pledged). Tier 2 (QuietMark, new): passive state markers — Boosted, Draft, Edited/ Original, pinned, private rumor — share one spec: 12sp medium gray text with an optional 12dp icon. Draft is no longer a hardcoded English literal (new R.string.draft). Cross-cutting cleanups: - Header rows (NoteCompose First/SecondUserInfoRow, ThreadFeedView) own spacing via Arrangement.spacedBy(5dp) instead of per-marker paddings. - Hashtag/community/fork links use the existing lessImportantLink theme color instead of ad-hoc primary.copy(alpha = 0.52f) (fork was full primary, louder than everything else on the line). - NIP-05 badge drops hardcoded Color.Yellow/Color.Red for theme-aware placeholderText/error. - Note-header timestamps switch to the Short style at 12sp; other TimeAgo callers keep the dotted default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../ui/note/NIP05VerificationDisplay.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 78 ++++++------------ .../amethyst/ui/note/elements/BoostedMark.kt | 15 +--- .../ui/note/elements/DisplayCommunity.kt | 6 +- .../ui/note/elements/DisplayEditStatus.kt | 40 +++------- .../ui/note/elements/DisplayHashtags.kt | 3 +- .../amethyst/ui/note/elements/DisplayOts.kt | 3 - .../ui/note/elements/DisplayReward.kt | 45 +++-------- .../amethyst/ui/note/elements/ForkInfo.kt | 3 +- .../amethyst/ui/note/elements/HeaderPill.kt | 4 +- .../amethyst/ui/note/elements/QuietMark.kt | 80 +++++++++++++++++++ .../amethyst/ui/note/elements/TimeAgo.kt | 17 +++- .../loggedIn/threadview/ThreadFeedView.kt | 16 +++- amethyst/src/main/res/values/strings.xml | 2 + 14 files changed, 167 insertions(+), 149 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 6bcb102918..b87ed48c44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -472,7 +472,7 @@ fun RenderNIP05VerifiedSymbol( symbol = MaterialSymbols.Downloading, contentDescription = stringRes(id = R.string.nip05_checking), modifier = modifier, - tint = Color.Yellow, + tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -492,7 +492,7 @@ fun RenderNIP05VerifiedSymbol( symbol = MaterialSymbols.Report, contentDescription = stringRes(id = R.string.nip05_failed), modifier = modifier, - tint = Color.Red, + tint = MaterialTheme.colorScheme.error, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6286a1a484..39ef85a00c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -32,7 +33,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -53,7 +53,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -85,11 +84,14 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.QuietMark import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -207,8 +209,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size25dp @@ -216,7 +216,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier @@ -1743,6 +1743,7 @@ fun SecondUserInfoRow( Row( verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), modifier = UserNameMaxRowHeight, ) { Column(modifier = Modifier.weight(1f)) { @@ -1755,32 +1756,19 @@ fun SecondUserInfoRow( val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { - Spacer(StdHorzSpacer) - DisplayReward(baseReward, note, accountViewModel, nav) + DisplayReward(baseReward, note, accountViewModel) } } } @Composable fun DisplayExpiration(expirationDate: Long) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.Timer, - contentDescription = stringRes(R.string.expiration_date_label), - modifier = Modifier.padding(start = 5.dp).size(12.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - val context = LocalContext.current - Text( - text = timeAheadNoDot(expirationDate, context), - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - maxLines = 1, - modifier = Modifier.padding(start = 3.dp), - ) - } + val context = LocalContext.current + HeaderPill( + symbol = MaterialSymbols.Timer, + text = timeAheadNoDot(expirationDate, context), + contentDescription = stringRes(R.string.expiration_date_label), + ) } @Composable @@ -1800,26 +1788,12 @@ fun DisplayOtsIfInOriginal( @Composable fun DisplayDraft() { - Text( - "Draft", - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) + QuietMark(text = stringRes(R.string.draft)) } @Composable fun DisplayDraftChat() { - Text( - "Draft", - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier, - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - maxLines = 1, - ) + QuietMark(text = stringRes(R.string.draft)) } @Composable @@ -1832,7 +1806,11 @@ fun FirstUserInfoRow( nav: INav, moreOptions: (@Composable () -> Unit)? = null, ) { - Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = UserNameRowHeight, + ) { val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent val isDraft = baseNote.isDraft() val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified @@ -1849,12 +1827,10 @@ fun FirstUserInfoRow( if (zapSender != null) { if (showAuthorPicture) { UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav) - Spacer(HalfPadding) } UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } else if (showAuthorPicture) { NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav) - Spacer(HalfPadding) NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } else { NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) @@ -1908,13 +1884,11 @@ fun FirstUserInfoRow( val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } if (geo != null) { - Spacer(StdHorzSpacer) DisplayLocation(geo, accountViewModel, nav) } val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } if (pow != null) { - Spacer(StdHorzSpacer) DisplayPoW(pow) } @@ -1924,7 +1898,7 @@ fun FirstUserInfoRow( JumpToParentReplyButton(baseNote, accountViewModel, nav) - TimeAgo(baseNote) + TimeAgo(baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) if (moreOptions == null) { MoreOptionsButton(baseNote, editState, accountViewModel, nav) @@ -1936,21 +1910,17 @@ fun FirstUserInfoRow( @Composable fun PinnedMark() { - Icon( + QuietMark( symbol = MaterialSymbols.PushPin, contentDescription = stringRes(R.string.pinned_notes), - modifier = Modifier.padding(start = 5.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, ) } @Composable fun PrivateRumorMark() { - Icon( + QuietMark( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), - modifier = Modifier.padding(start = 5.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -1976,7 +1946,7 @@ fun JumpToParentReplyButton( } ?: return ClickableBox( - modifier = Modifier.padding(start = 5.dp).size(20.dp), + modifier = Modifier.size(20.dp), onClick = { nav.nav { routeFor(parentNote, accountViewModel.account) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index d214b3391c..7b81088adc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -20,24 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun BoostedMark() { - Text( - stringRes(id = R.string.boosted), - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) + QuietMark(text = stringRes(id = R.string.boosted)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 404688cc03..64b27c68a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -47,7 +47,7 @@ fun DisplayFollowingCommunityInPost( accountViewModel: AccountViewModel, nav: INav, ) { - Column(HalfStartPadding) { + Column { Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) } } } @@ -67,7 +67,7 @@ private fun DisplayCommunity( ClickableTextColor( label, style = LocalTextStyle.current.copy(fontSize = Font12SP), - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index ee31e07dc3..48a2e6b60f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -20,40 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.clickable -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun DisplayEditStatus(editState: EditState) { - Text( - text = - buildAnnotatedString { - if (editState.showingVersion.value == editState.originalVersionId()) { - append(stringRes(id = R.string.original)) - } else if (editState.showingVersion.value == editState.lastVersionId()) { - append(stringRes(id = R.string.edited)) - } else { - append(stringRes(id = R.string.edited_number, editState.versionId())) - } - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - ), - maxLines = 1, - modifier = HalfStartPadding.clickable { editState.nextModification() }, + val label = + if (editState.showingVersion.value == editState.originalVersionId()) { + stringRes(id = R.string.original) + } else if (editState.showingVersion.value == editState.lastVersionId()) { + stringRes(id = R.string.edited) + } else { + stringRes(id = R.string.edited_number, editState.versionId()) + } + + QuietMark( + text = label, + onClick = { editState.nextModification() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index b9f73c3367..cc48d6d08e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -85,7 +86,7 @@ private fun DisplayTagList( ClickableTextColor( "#$firstTag", style = LocalTextStyle.current.copy(fontSize = Font12SP), - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index fdf5f6890c..c5cf453138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import java.text.SimpleDateFormat import java.util.Date @@ -63,7 +62,6 @@ fun DisplayOts( HeaderPill( symbol = MaterialSymbols.CheckCircle, text = stringRes(R.string.existed_since, timeStr), - modifier = HalfStartPadding, contentDescription = stringRes(R.string.ots_info_title), onClick = { accountViewModel.toastManager.toast( @@ -78,7 +76,6 @@ fun DisplayOts( HeaderPill( symbol = MaterialSymbols.Schedule, text = stringRes(id = R.string.timestamp_pending_short), - modifier = HalfStartPadding, contentDescription = stringRes(R.string.ots_info_title), ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index b392b6a50c..0b36780bd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -55,20 +54,16 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.note.ZapIcon -import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -83,28 +78,13 @@ fun DisplayReward( baseReward: Reward, baseNote: Note, accountViewModel: AccountViewModel, - nav: INav, ) { var popupExpanded by remember { mutableStateOf(false) } - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { popupExpanded = true }, - ) { - ClickableTextColor( - "#bounty", - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), - ) { - nav.nav(Route.Hashtag("bounty")) - } + RenderPledgeAmount(baseNote, baseReward, accountViewModel) { popupExpanded = true } - RenderPledgeAmount(baseNote, baseReward, accountViewModel) - } - - if (popupExpanded) { - AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } - } + if (popupExpanded) { + AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } } } @@ -113,6 +93,7 @@ private fun RenderPledgeAmount( baseNote: Note, baseReward: Reward, accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { val repliesState by observeNoteReplies(baseNote, accountViewModel) var reward by remember { @@ -142,16 +123,12 @@ private fun RenderPledgeAmount( } } - if (hasPledge) { - ZappedIcon(modifier = Size20Modifier) - } else { - ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) - } - - Text( + HeaderPill( + symbol = MaterialSymbols.Bolt, text = reward, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, + contentDescription = stringRes(R.string.bounty_label), + iconTint = if (hasPledge) BitcoinOrange else null, + onClick = onClick, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index e39303df66..7b6486bfd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.experimental.forks.IForkableEvent @Composable @@ -85,7 +86,7 @@ fun ForkInformationRowLightColor( clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()), maxLines = 1, route = route, - overrideColor = MaterialTheme.colorScheme.primary, + overrideColor = MaterialTheme.colorScheme.lessImportantLink, fontSize = Font14SP, nav = nav, tags = userState?.tags, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt index 2e70a1f398..6661391c8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -49,6 +50,7 @@ fun HeaderPill( text: String, modifier: Modifier = Modifier, contentDescription: String? = null, + iconTint: Color? = null, onClick: (() -> Unit)? = null, ) { Surface( @@ -64,7 +66,7 @@ fun HeaderPill( Icon( symbol = symbol, contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onSecondaryContainer, + tint = iconTint ?: MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(11.dp), ) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt new file mode 100644 index 0000000000..acd339471e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt @@ -0,0 +1,80 @@ +/* + * 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.note.elements + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, + * private rumor, ... One spec for all of them so the header reads as a single + * system: 12sp medium gray text with an optional 12dp icon. Contrast with + * [HeaderPill], which is the loud tier for tappable/verifiable metadata. + * + * Spacing between markers is owned by the parent row (`Arrangement.spacedBy`), + * not baked in here. + */ +@Composable +fun QuietMark( + text: String? = null, + symbol: MaterialSymbol? = null, + contentDescription: String? = null, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + ) { + if (symbol != null) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(12.dp), + ) + } + if (text != null) { + Text( + text = text, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + lineHeight = Font12SP, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index c23fd2d41c..af77ed2b82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -125,16 +125,25 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo(note: Note) { +fun TimeAgo( + note: Note, + style: TimeAgoStyle = TimeAgoStyle.Dotted, + fontSize: TextUnit = TextUnit.Unspecified, +) { val time = note.createdAt() ?: return - TimeAgo(time) + TimeAgo(time, style, fontSize) } @Composable -fun TimeAgo(time: Long) { +fun TimeAgo( + time: Long, + style: TimeAgoStyle = TimeAgoStyle.Dotted, + fontSize: TextUnit = TextUnit.Unspecified, +) { ToggleableTimeAgoText( timestamp = time, - style = TimeAgoStyle.Dotted, + style = style, + fontSize = fontSize, color = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index a2ccc498b7..11c461fee7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -231,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -703,7 +705,10 @@ private fun FullBleedNoteCompose( } Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { if (zapSender != null) { UsernameDisplay(zapSender, Modifier.weight(1f), accountViewModel = accountViewModel) } else { @@ -738,12 +743,15 @@ private fun FullBleedNoteCompose( Expiration(baseNote) - TimeAgo(note = baseNote) + TimeAgo(note = baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) MoreOptionsButton(baseNote, editState, accountViewModel, nav) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { Column( Modifier.weight(1f), ) { @@ -765,7 +773,7 @@ private fun FullBleedNoteCompose( val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { - DisplayReward(baseReward, baseNote, accountViewModel, nav) + DisplayReward(baseReward, baseNote, accountViewModel) } val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..a3ef6e6b7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3041,6 +3041,8 @@ It\'s not possible to react a draft note It\'s not possible to zap a draft note Draft Note + Draft + Bounty From Msg From b81116865c5bd55c3bb22f618cc5a0a55ed0559b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:32:01 +0000 Subject: [PATCH 11/39] feat(ui): add density previews for the note-header marker system NoteHeaderFirstRowDensityPreview stacks first-row samples from bare (username + time + options) to fully loaded (hashtag, edited, draft, lock, pin, location/PoW/OTS/expiration pills, jump-to-parent) under a long username, in dark and light themes. Uses the real QuietMark/ HeaderPill components with static stand-ins only for the data-loading markers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../note/elements/NoteHeaderMarkersPreview.kt | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt new file mode 100644 index 0000000000..584c725b2a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -0,0 +1,188 @@ +/* + * 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.note.elements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.DisplayDraft +import com.vitorpamplona.amethyst.ui.note.DisplayExpiration +import com.vitorpamplona.amethyst.ui.note.PinnedMark +import com.vitorpamplona.amethyst.ui.note.PrivateRumorMark +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Design-system preview for the note-header first row: how the two marker + * tiers ([HeaderPill] and [QuietMark]) compose next to the username and + * timestamp as the line fills up. Uses the same layout contract as + * `FirstUserInfoRow` (weight-1 username, `Arrangement.spacedBy(5.dp)`), + * with static stand-ins only for the data-loading markers (location, OTS, + * hashtag). + */ +@Preview(widthDp = 400) +@Composable +fun NoteHeaderFirstRowDensityPreview() { + ThemeComparisonColumn { + Column(Modifier.padding(vertical = 6.dp)) { + // Bare minimum: username + time + options + PreviewHeaderRow {} + + // One quiet mark: repost + PreviewHeaderRow { BoostedMark() } + + // Quiet text marks: edited + draft + PreviewHeaderRow { + QuietMark(text = stringRes(R.string.edited), onClick = {}) + DisplayDraft() + } + + // Quiet icon marks: private rumor + pinned + PreviewHeaderRow { + PrivateRumorMark() + PinnedMark() + } + + // Soft link + one pill + PreviewHeaderRow { + PreviewHashtag() + DisplayPoW(24) + } + + // Pill cluster: location + PoW + OTS + PreviewHeaderRow { + PreviewLocationPill() + DisplayPoW(28) + PreviewOtsPill() + } + + // Expiring note with a parent to jump to + PreviewHeaderRow { + DisplayExpiration(TimeUtils.now() + 7200) + PreviewJumpToParent() + } + + // Kitchen sink under a long username: everything competes for space + PreviewHeaderRow(username = "A user with a very long display name") { + PreviewHashtag() + QuietMark(text = stringRes(R.string.edited), onClick = {}) + DisplayDraft() + PrivateRumorMark() + PinnedMark() + PreviewLocationPill() + DisplayPoW(32) + PreviewOtsPill() + DisplayExpiration(TimeUtils.now() + 7200) + PreviewJumpToParent() + } + } + } +} + +/** Same layout contract as `FirstUserInfoRow`: weight-1 bold username, 5dp gaps, time + options at the end. */ +@Composable +private fun PreviewHeaderRow( + username: String = "Vitor", + markers: @Composable RowScope.() -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + text = username, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + markers() + + TimeAgo(TimeUtils.now() - 300, style = TimeAgoStyle.Short, fontSize = Font12SP) + + VerticalDotsIcon() + } +} + +/** Static stand-in for `DisplayFollowingHashtagsInPost` (which needs account state). */ +@Composable +private fun PreviewHashtag() { + Text( + text = "#nostr", + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font12SP, + maxLines = 1, + ) +} + +/** Static stand-in for `DisplayLocation` (which resolves the geohash to a city name). */ +@Composable +private fun PreviewLocationPill() { + HeaderPill( + symbol = MaterialSymbols.LocationOn, + text = "Belo Horizonte", + onClick = {}, + ) +} + +/** Static stand-in for `DisplayOts` (which loads and verifies the attestation). */ +@Composable +private fun PreviewOtsPill() { + HeaderPill( + symbol = MaterialSymbols.CheckCircle, + text = stringRes(R.string.existed_since, "2y"), + onClick = {}, + ) +} + +/** Static stand-in for `JumpToParentReplyButton` (which needs the parent note). */ +@Composable +private fun PreviewJumpToParent() { + Icon( + symbol = MaterialSymbols.KeyboardArrowUp, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} From 3cba5d3b65348744a7a05994b3e04f9fb6577aea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:53:03 +0000 Subject: [PATCH 12/39] refactor(ui): drive header-marker previews through the real FirstUserInfoRow Replace the hand-built preview rows and static stand-ins with the actual FirstUserInfoRow over notes seeded into LocalCache (the ZapPollNote/Poll preview pattern): metadata for usernames, a repost, an empty-sig rumor, a draft wrap, a community post, and PoW ids with committed nonces plus geohash/expiration tags. The geohash resolves through a seeded CachedReversedGeoLocations entry and the OTS pill renders its real pending branch via account.settings.pendingAttestations. Only the followed-hashtag label and the confirmed OTS state stay out - both are computed in effects that static previews never run, as documented in the file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../note/elements/NoteHeaderMarkersPreview.kt | 319 ++++++++++-------- 1 file changed, 186 insertions(+), 133 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 584c725b2a..ea1c4287df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -20,169 +20,222 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.note.DisplayDraft -import com.vitorpamplona.amethyst.ui.note.DisplayExpiration -import com.vitorpamplona.amethyst.ui.note.PinnedMark -import com.vitorpamplona.amethyst.ui.note.PrivateRumorMark -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.FirstUserInfoRow +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.EmptyState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private val AUTHOR = "a".repeat(64) +private val LONG_NAME_AUTHOR = "b".repeat(64) + +private val AUTHOR_METADATA_ID = "e1".repeat(32) +private val LONG_NAME_METADATA_ID = "e2".repeat(32) + +private val PLAIN_ID = "1".repeat(64) +private val REPOST_ID = "2".repeat(64) +private val RUMOR_ID = "3".repeat(64) +private val EDITED_ID = "4".repeat(64) +private val EDIT_VERSION_ID = "5".repeat(64) +private val DRAFT_ID = "6".repeat(64) +private val COMMUNITY_POST_ID = "7".repeat(64) + +// 24 leading zero bits so strongPoWOrNull(min = 20) accepts the committed nonce. +private val POW_ID = "0".repeat(6) + "8".repeat(58) +private val KITCHEN_SINK_ID = "0".repeat(6) + "9".repeat(58) + +private const val GEOHASH = "u1x1" +private val COMMUNITY_ADDRESS = "34550:" + "c".repeat(64) + ":amethyst-users" /** - * Design-system preview for the note-header first row: how the two marker - * tiers ([HeaderPill] and [QuietMark]) compose next to the username and - * timestamp as the line fills up. Uses the same layout contract as - * `FirstUserInfoRow` (weight-1 username, `Arrangement.spacedBy(5.dp)`), - * with static stand-ins only for the data-loading markers (location, OTS, - * hashtag). + * Design-system preview for the note-header first row, rendered by the REAL + * `FirstUserInfoRow` over notes seeded into [LocalCache] — the same pattern + * the ZapPollNote/Poll previews use. Rows go from bare to fully loaded so the + * two marker tiers ([HeaderPill] and [QuietMark]) can be reviewed together. + * + * Two markers cannot appear in a static preview because their visibility is + * computed inside effects that previews never run: the followed-hashtag label + * (needs the account follow-list flows) and the *confirmed* OTS pill (needs + * attestation verification). The community label and the *pending* OTS pill + * shown here exercise the same two visual styles through real code. */ @Preview(widthDp = 400) @Composable fun NoteHeaderFirstRowDensityPreview() { + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav() + + // The jump-to-parent arrow only renders in complete UI mode. + accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE + + // Let DisplayLocation resolve the geohash synchronously from the cache. + CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte") + + val now = TimeUtils.now() + val expiresAt = (now + 7200).toString() + + val plain: Note + val repost: Note + val rumorPinned: Note + val edited: Note + val editVersion: Note + val draft: Note + val communityPost: Note + val geoPowExpiring: Note + val kitchenSink: Note + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume( + MetadataEvent(AUTHOR_METADATA_ID, AUTHOR, now, emptyArray(), """{"name":"Vitor"}""", "x"), + null, + false, + ) + LocalCache.justConsume( + MetadataEvent(LONG_NAME_METADATA_ID, LONG_NAME_AUTHOR, now, emptyArray(), """{"name":"A user with a very long display name"}""", "x"), + null, + false, + ) + + LocalCache.justConsume(TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x"), null, false) + LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, false) + LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), "", "x"), null, false) + // An empty sig is what marks a note as a private rumor. + LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, false) + LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, false) + LocalCache.justConsume(DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, emptyArray(), "", "x"), null, false) + LocalCache.justConsume( + TextNoteEvent(COMMUNITY_POST_ID, AUTHOR, now - 300, arrayOf(arrayOf("a", COMMUNITY_ADDRESS)), "hello community", "x"), + null, + false, + ) + LocalCache.justConsume( + TextNoteEvent( + POW_ID, + AUTHOR, + now - 300, + arrayOf( + arrayOf("g", GEOHASH), + arrayOf("nonce", "776", "24"), + arrayOf("expiration", expiresAt), + ), + "mined and placed", + "x", + ), + null, + false, + ) + LocalCache.justConsume( + TextNoteEvent( + KITCHEN_SINK_ID, + LONG_NAME_AUTHOR, + now - 300, + arrayOf( + arrayOf("e", PLAIN_ID), + arrayOf("a", COMMUNITY_ADDRESS), + arrayOf("g", GEOHASH), + arrayOf("nonce", "776", "24"), + arrayOf("expiration", expiresAt), + ), + "everything everywhere all at once", + // Empty sig: also a private rumor. + "", + ), + null, + false, + ) + + plain = LocalCache.getOrCreateNote(PLAIN_ID) + repost = LocalCache.getOrCreateNote(REPOST_ID) + rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) + edited = LocalCache.getOrCreateNote(EDITED_ID) + editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) + draft = LocalCache.getOrCreateNote(DRAFT_ID) + communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) + geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) + kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) + } + } + + // LoadOts renders the pending pill synchronously from this map; the + // confirmed pill needs async verification a static preview can't run. + accountViewModel.account.settings.pendingAttestations + .update { it + (POW_ID to "stamp") + (KITCHEN_SINK_ID to "stamp") } + + val editedState = + remember { + mutableStateOf>( + GenericLoadable.Loaded(EditState().apply { updateModifications(listOf(editVersion)) }), + ) + } + ThemeComparisonColumn { - Column(Modifier.padding(vertical = 6.dp)) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { // Bare minimum: username + time + options - PreviewHeaderRow {} + HeaderRowSample(plain, accountViewModel) // One quiet mark: repost - PreviewHeaderRow { BoostedMark() } - - // Quiet text marks: edited + draft - PreviewHeaderRow { - QuietMark(text = stringRes(R.string.edited), onClick = {}) - DisplayDraft() - } + HeaderRowSample(repost, accountViewModel) // Quiet icon marks: private rumor + pinned - PreviewHeaderRow { - PrivateRumorMark() - PinnedMark() - } + HeaderRowSample(rumorPinned, accountViewModel, isPinned = true) - // Soft link + one pill - PreviewHeaderRow { - PreviewHashtag() - DisplayPoW(24) - } + // Quiet text mark, tappable: edited + HeaderRowSample(edited, accountViewModel, editState = editedState) - // Pill cluster: location + PoW + OTS - PreviewHeaderRow { - PreviewLocationPill() - DisplayPoW(28) - PreviewOtsPill() - } + // Quiet text mark: draft + HeaderRowSample(draft, accountViewModel) - // Expiring note with a parent to jump to - PreviewHeaderRow { - DisplayExpiration(TimeUtils.now() + 7200) - PreviewJumpToParent() - } + // Soft link: community label + HeaderRowSample(communityPost, accountViewModel) + + // Pill cluster: location + PoW + pending OTS + expiration + HeaderRowSample(geoPowExpiring, accountViewModel) // Kitchen sink under a long username: everything competes for space - PreviewHeaderRow(username = "A user with a very long display name") { - PreviewHashtag() - QuietMark(text = stringRes(R.string.edited), onClick = {}) - DisplayDraft() - PrivateRumorMark() - PinnedMark() - PreviewLocationPill() - DisplayPoW(32) - PreviewOtsPill() - DisplayExpiration(TimeUtils.now() + 7200) - PreviewJumpToParent() - } + HeaderRowSample(kitchenSink, accountViewModel, isPinned = true, editState = editedState) } } } -/** Same layout contract as `FirstUserInfoRow`: weight-1 bold username, 5dp gaps, time + options at the end. */ @Composable -private fun PreviewHeaderRow( - username: String = "Vitor", - markers: @Composable RowScope.() -> Unit, +private fun HeaderRowSample( + note: Note, + accountViewModel: AccountViewModel, + isPinned: Boolean = false, + editState: State> = EmptyState, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), - ) { - Text( - text = username, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - markers() - - TimeAgo(TimeUtils.now() - 300, style = TimeAgoStyle.Short, fontSize = Font12SP) - - VerticalDotsIcon() - } -} - -/** Static stand-in for `DisplayFollowingHashtagsInPost` (which needs account state). */ -@Composable -private fun PreviewHashtag() { - Text( - text = "#nostr", - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font12SP, - maxLines = 1, - ) -} - -/** Static stand-in for `DisplayLocation` (which resolves the geohash to a city name). */ -@Composable -private fun PreviewLocationPill() { - HeaderPill( - symbol = MaterialSymbols.LocationOn, - text = "Belo Horizonte", - onClick = {}, - ) -} - -/** Static stand-in for `DisplayOts` (which loads and verifies the attestation). */ -@Composable -private fun PreviewOtsPill() { - HeaderPill( - symbol = MaterialSymbols.CheckCircle, - text = stringRes(R.string.existed_since, "2y"), - onClick = {}, - ) -} - -/** Static stand-in for `JumpToParentReplyButton` (which needs the parent note). */ -@Composable -private fun PreviewJumpToParent() { - Icon( - symbol = MaterialSymbols.KeyboardArrowUp, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.placeholderText, + FirstUserInfoRow( + baseNote = note, + showAuthorPicture = false, + isPinned = isPinned, + editState = editState, + accountViewModel = accountViewModel, + nav = EmptyNav(), ) } From aa34f9cb0e1f1589e1f6b83f680caa6a42a92caa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:56:03 +0000 Subject: [PATCH 13/39] feat: track PoW, Tor, calls, decrypts, remote signs, and battery drain in the resource ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New counters for the app's remaining heavy battery consumers, all transition-based so tracking never adds work to the activities measured: - pow.ms/sessions: NIP-13 mining time from the PoW queue's isMining flow — the largest attributable CPU burner, previously an unattributed cpu.ms mystery - tor.ms/starts: in-app Arti uptime from the raw TorService status (not TorManager.status, whose WhileSubscribed upstream would keep Tor's control flow alive if the ledger subscribed to it) - service.alwayson.ms, call.ms/sessions, nests.ms/sessions: foreground service lifecycles for the always-on relay service, calls, and NIP-53 audio rooms - crypto.decrypt/encrypt.count+us and sign.local/nip46/nip55.count via a MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at account load; crypto durations only metered for local-key signers so IPC/relay waits never poison the CPU numbers - location.ms: GPS listening segments around LocationFlow collection - battery.drain.fg/bg: measured percent-while-discharging sampled at each flush — the ground truth to correlate the other counters against Ledger infrastructure hardening the new counters build on: - all duration math now uses SystemClock.elapsedRealtime; wall-clock jumps (NTP, timezone) no longer fabricate or delete accounted time - pre-flush-hook adds are drained in place and no longer re-arm the debounced flush, killing a self-perpetuating 30s wake-and-write loop - drain is now AtomicLong.getAndSet(0) instead of remove+sum, closing a lost-update race that undercounted the hottest counters - shared TimeSegmentIntegrator base extracted; relay/foreground integrators and the new SessionTimeIntegrator all use it - @Volatile on LocalCache.verifyMeter (matching the onchainBackend precedent) New counters surface in the usage screen (zero rows hidden), the NIP-17 report tables, and the per-day dump. 9 new unit tests including a regression test for the flush loop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 25 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 63 ++++- .../amethyst/model/LocalCache.kt | 1 + .../model/accountsCache/AccountCacheState.kt | 4 +- .../service/call/CallForegroundService.kt | 3 + .../service/location/LocationState.kt | 6 + .../service/nests/NestForegroundService.kt | 3 + .../notifications/NotificationRelayService.kt | 2 + .../resourceusage/BatteryDrainSampler.kt | 66 +++++ .../resourceusage/ForegroundTimeIntegrator.kt | 42 +-- .../resourceusage/MeteringNostrSigner.kt | 138 +++++++++ .../RelayConnectionTimeIntegrator.kt | 41 +-- .../resourceusage/ResourceUsageAccountant.kt | 46 ++- .../ResourceUsageReportAssembler.kt | 8 + .../resourceusage/TimeSegmentIntegrator.kt | 106 +++++++ .../resourceusage/UsageCountingInterceptor.kt | 5 +- .../service/resourceusage/UsageKeys.kt | 45 +++ .../service/resourceusage/UsageSummary.kt | 24 ++ .../ui/screen/loggedIn/LoggedInPage.kt | 17 +- .../loggedIn/settings/ResourceUsageScreen.kt | 36 +++ amethyst/src/main/res/values/strings.xml | 9 + .../resourceusage/ResourceUsageLedgerTest.kt | 266 ++++++++++++++++++ 22 files changed, 859 insertions(+), 97 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 04db249238..9c7e641739 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -81,6 +81,31 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: segments): decoder + screen + streaming at once, the denominator for video bytes +- `pow.ms` / `pow.sessions` — NIP-13 mining time (any job mining in the + PoW queue): full-core CPU, the largest attributable CPU consumer +- `tor.ms` / `tor.starts` — in-app (Arti) Tor uptime and bootstraps, from the + raw TorService status (NOT TorManager.status, whose WhileSubscribed + upstream calls service.start() when collected). External Tor (Orbot) is + deliberately untracked — its battery belongs to Orbot +- `service.alwayson.ms` — NotificationRelayService uptime: the mode context + that explains a device's relay connection-time +- `call.ms`/`call.sessions`, `nests.ms`/`nests.sessions` — calls and NIP-53 + audio rooms (mic + Opus + live media connection), from the foreground + services' lifecycles +- `location.ms` — time actively listening for GPS updates (geohash tagging); + mostly a tripwire for a leaked location subscription +- `crypto.decrypt.count/us`, `crypto.encrypt.count/us` — NIP-04/44 work via a + MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at + account load; durations metered only for local-key signers (external/ + remote waits are IPC/network, not CPU) +- `sign.local|nip46|nip55.count` — signatures by signer kind: NIP-46 is a + relay round-trip and NIP-55 an Amber IPC wake, so the kind is the + battery-relevant dimension (this supersedes "signing is negligible", which + is only true for local keys) +- `battery.drain.fg|bg` — measured battery percent while discharging, sampled + at flush from BatteryManager: NOT app-isolated, but the ground truth that + report corpora can correlate the other counters against + Deliberately not tracked (v1): per-screen time (route names leak behavior patterns into a report — needs its own privacy review), per-coroutine or per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 1c5e65cdf7..b36593c4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst import android.content.ComponentCallbacks2 import android.content.Context +import android.os.BatteryManager import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache @@ -86,15 +87,18 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir @@ -110,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorService +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -226,7 +231,7 @@ class AppModules( // App services that should be run as soon as there are subscribers to their flows val locationManager by lazy { Log.d("AppModules", "LocationManager Init") - LocationState(appContext, applicationIOScope) + LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) } val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -235,7 +240,8 @@ class AppModules( UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } - val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope) + private val torService = TorService(appContext) + val torManager = TorManager(torPrefs, torService, applicationIOScope) // Network identity change (wifi↔cellular, regained from offline, captive portal // cleared) — the old network's guards/circuits are dead, and Arti's in-memory @@ -323,6 +329,45 @@ class AppModules( private val httpUsageMeter = HttpUsageMeter() + // Session-time counters for the app's long-running battery consumers. + // All timer-free segment integrators: services and status flows flip them + // on/off, so tracking costs one counter write per transition. + val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS).also { it.registerFlushHook() } + val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() } + val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() } + private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() } + private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } + private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + + // In-app (Arti) Tor uptime. Watches the raw TorService status — NOT + // TorManager.status, whose upstream is WhileSubscribed and calls + // service.start() when collected, so a permanent ledger subscription + // there would keep Tor's control flow alive on its own. External Tor + // (Orbot) is deliberately untracked: its battery belongs to Orbot. + init { + applicationIOScope.launch { + torService.status + .map { it is TorServiceStatus.Active } + .distinctUntilChanged() + .collect { torSession.setActive(it) } + } + } + + // Measured battery drain (percent while discharging, fg/bg) — the ground + // truth the app counters get correlated against. One binder read per + // ledger flush, nothing while idle. + init { + val batteryManager = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + if (batteryManager != null) { + BatteryDrainSampler( + accountant = resourceUsage, + capacityPct = { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it in 1..100 } }, + isCharging = { batteryManager.isCharging }, + isForeground = { foregroundTracker.isForeground.value }, + ).register() + } + } + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -713,7 +758,18 @@ class AppModules( minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()), persistence = powJobStore, onQueueActive = { PowMiningForegroundService.start(appContext) }, - ) + ).also { queue -> + // Resource ledger: mining burns half the cores flat-out for as + // long as it runs — without this, PoW shows up in cpu.ms as an + // unattributed mystery. Wired inside the lazy so the ledger never + // forces the queue to initialize. + applicationIOScope.launch { + queue.jobs + .map { jobs -> jobs.any { it.isMining } } + .distinctUntilChanged() + .collect { powSession.setActive(it) } + } + } } val powJobRestorer by lazy { @@ -734,6 +790,7 @@ class AppModules( client = client, rootFilesDir = { appContext.filesDir }, powQueue = { powPublishQueue }, + meterSigner = { MeteringNostrSigner(it, resourceUsage) }, ) val sessionManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2dbba0d893..33b97556fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3221,6 +3221,7 @@ object LocalCache : ILocalCache, ICacheProvider { * signature verification so the app can account crypto CPU per day. * Wired by AppModules like [onchainBackend]; null costs nothing. */ + @Volatile var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null fun justVerify(event: Event): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..9db6a326c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -63,6 +63,8 @@ class AccountCacheState( val client: INostrClient, val rootFilesDir: () -> File = { File("") }, val powQueue: () -> PoWPublishQueue? = { null }, + /** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */ + val meterSigner: (NostrSigner) -> NostrSigner = { it }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -175,7 +177,7 @@ class AccountCacheState( val signerWithClientTag = NostrSignerWithClientTag( - inner = signer, + inner = meterSigner(signer), clientName = CLIENT_TAG_NAME, disabled = { !accountSettings.syncedSettings.security.addClientTag.value }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 17d22eea59..37107a5ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -34,6 +34,7 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.ui.call.CallActivity @@ -61,6 +62,7 @@ class CallForegroundService : Service() { override fun onCreate() { super.onCreate() + Amethyst.instance.callSession.setActive(true) createNotificationChannel() } @@ -151,6 +153,7 @@ class CallForegroundService : Service() { // because CallManager.hangup() transitions to Ended and a second // hangup() from Ended state returns immediately. publishHangupBlocking() + Amethyst.instance.callSession.setActive(false) super.onDestroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 298799e119..a5a330ae61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest class LocationState( context: Context, scope: CoroutineScope, + /** Resource-ledger hook: true while GPS/location updates are actively requested. */ + private val onListening: ((Boolean) -> Unit)? = null, ) { companion object { const val MIN_TIME: Long = 10000L @@ -72,6 +76,8 @@ class LocationState( val result = LocationFlow(context) .get(MIN_TIME, MIN_DISTANCE) + .onStart { onListening?.invoke(true) } + .onCompletion { onListening?.invoke(false) } .map { LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult }.onEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt index cb6ac03f08..b629c41546 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt @@ -39,6 +39,7 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState @@ -106,6 +107,7 @@ class NestForegroundService : Service() { override fun onCreate() { super.onCreate() + Amethyst.instance.nestsSession.setActive(true) createNotificationChannel() wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) @@ -421,6 +423,7 @@ class NestForegroundService : Service() { } override fun onDestroy() { + Amethyst.instance.nestsSession.setActive(false) wakeLock?.takeIf { it.isHeld }?.release() wakeLock = null abandonAudioFocus() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index de2b6c2694..c255ea4b36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -138,6 +138,7 @@ class NotificationRelayService : Service() { override fun onCreate() { super.onCreate() Log.d(TAG, "Service created") + Amethyst.instance.alwaysOnSession.setActive(true) createNotificationChannel() ensureForeground() } @@ -197,6 +198,7 @@ class NotificationRelayService : Service() { */ override fun onDestroy() { Log.d(TAG, "Service destroyed") + Amethyst.instance.alwaysOnSession.setActive(false) relayServiceCollectorJob?.cancel() scope.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt new file mode 100644 index 0000000000..5487055071 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt @@ -0,0 +1,66 @@ +/* + * 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.service.resourceusage + +/** + * Samples the device battery level at each ledger flush and accumulates the + * drops that happen while discharging into [UsageKeys.BATTERY_DRAIN_FG] / + * [UsageKeys.BATTERY_DRAIN_BG] (percent points, attributed by visibility at + * sample time). This is deliberately NOT app-isolated — it's the measured + * ground truth that lets the developers correlate the app's own counters + * against real drain across many reports, which is how the alert thresholds + * get tuned. + * + * Intervals touching a charging state (at either endpoint) are skipped, and + * a level that went UP just resets the baseline. Piggybacks on the pre-flush + * hook — one BatteryManager binder read per flush, nothing while idle. + */ +class BatteryDrainSampler( + private val accountant: ResourceUsageAccountant, + private val capacityPct: () -> Int?, + private val isCharging: () -> Boolean, + private val isForeground: () -> Boolean, +) { + private var lastPct: Int? = null + private var lastCharging = true + + fun register() { + accountant.addPreFlushHook(::sample) + } + + @Synchronized + fun sample() { + val pct = capacityPct() ?: return + val charging = isCharging() + val prevPct = lastPct + val prevCharging = lastCharging + lastPct = pct + lastCharging = charging + + if (prevPct == null || prevCharging || charging) return + val drop = prevPct - pct + if (drop <= 0) return + accountant.add( + if (isForeground()) UsageKeys.BATTERY_DRAIN_FG else UsageKeys.BATTERY_DRAIN_BG, + drop.toLong(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt index 125951ef8a..0e1bb3200c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -29,45 +30,24 @@ import kotlinx.coroutines.launch * Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]). * Screen-on time is what display power is proportional to, and it's the * denominator that makes every other counter interpretable (MB per hour in - * app vs MB while backgrounded). Same timer-free segment pattern as - * [RelayConnectionTimeIntegrator]: segments close on transitions and on the - * accountant's pre-flush hook. + * app vs MB while backgrounded). */ class ForegroundTimeIntegrator( private val isForeground: Flow, - private val accountant: ResourceUsageAccountant, - private val nowMs: () -> Long = { System.currentTimeMillis() }, -) { - private val lock = Any() - private var foreground = false - private var segmentStartMs = 0L - + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { fun start(scope: CoroutineScope): Job { - accountant.addPreFlushHook(::closeOpenSegment) + registerFlushHook() return scope.launch { - isForeground.collect { fg -> transitionTo(fg) } + isForeground.collect { fg -> transitionTo(if (fg) Unit else null) } } } - fun closeOpenSegment() { - synchronized(lock) { - if (!foreground) return - val now = nowMs() - account(now - segmentStartMs) - segmentStartMs = now - } - } - - private fun transitionTo(fg: Boolean) { - synchronized(lock) { - val now = nowMs() - if (foreground) account(now - segmentStartMs) - foreground = fg - segmentStartMs = now - } - } - - private fun account(elapsedMs: Long) { + override fun account( + state: Unit, + elapsedMs: Long, + ) { if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt new file mode 100644 index 0000000000..93bb31f4a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt @@ -0,0 +1,138 @@ +/* + * 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.service.resourceusage + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag + +/** + * A [NostrSigner] decorator (same pattern as [NostrSignerWithClientTag]) that + * accounts signing and encryption work into the resource-usage ledger: + * + * - signature counts by signer kind — a local-key signature is ~free, a + * NIP-55 one wakes the Amber process over IPC, and a NIP-46 one is a full + * relay round-trip, so the *kind* is the battery-relevant dimension; + * - NIP-04/44 decrypt/encrypt counts, with CPU time metered only when the + * wrapped signer is a local key ([NostrSignerInternal]) — for external and + * remote signers the elapsed time would be IPC/network wait, not crypto + * cost, and would poison the CPU numbers. + * + * Overhead per operation: one counter increment (plus two [System.nanoTime] + * calls for local-key crypto, nanoseconds against a millisecond-scale ECDH) — + * nothing here can slow the operations being measured. + * + * Wrapped INSIDE [NostrSignerWithClientTag] (metering the raw signer), so the + * existing `signer is NostrSignerWithClientTag` call sites keep working; + * anything that needs the raw signer should unwrap via [innermostSigner]. + */ +class MeteringNostrSigner( + val inner: NostrSigner, + private val accountant: ResourceUsageAccountant, +) : NostrSigner(inner.pubKey) { + private val signKey = UsageKeys.signs(signerKind(inner)) + private val meterCryptoTime = inner is NostrSignerInternal + + override fun isWriteable(): Boolean = inner.isWriteable() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + accountant.add(signKey, 1) + return inner.sign(createdAt, kind, tags, content) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip04Encrypt(plaintext, toPublicKey) } + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip04Decrypt(ciphertext, fromPublicKey) } + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip44Encrypt(plaintext, toPublicKey) } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip44Decrypt(ciphertext, fromPublicKey) } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) } + + override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String): String { + accountant.add(signKey, 1) + return inner.signPsbt(psbtHex) + } + + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() + + private inline fun metered( + countKey: String, + usKey: String, + op: () -> T, + ): T { + accountant.add(countKey, 1) + if (!meterCryptoTime) return op() + val start = System.nanoTime() + try { + return op() + } finally { + accountant.add(usKey, (System.nanoTime() - start) / 1_000) + } + } + + companion object { + fun signerKind(signer: NostrSigner): String = + when (signer) { + is NostrSignerExternal -> UsageKeys.SIGNER_NIP55 + is NostrSignerRemote -> UsageKeys.SIGNER_NIP46 + else -> UsageKeys.SIGNER_LOCAL + } + } +} + +/** + * Strips the app's signer decorators (client tag, metering) to reach the + * concrete signer — for call sites that need the real type, like the NIP-55 + * activity-launcher registration. + */ +fun NostrSigner.innermostSigner(): NostrSigner = + when (this) { + is NostrSignerWithClientTag -> inner.innermostSigner() + is MeteringNostrSigner -> inner.innermostSigner() + else -> this + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt index 7fea7880eb..b3afc9c6e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -31,31 +32,22 @@ import kotlinx.coroutines.launch * the ledger, split by network class and visibility. Connection-time is the * best single battery proxy the ping study found: most relays server-ping * every 30-70s, so the radio is active for as long as connections are open. - * - * No timers: the integral is exact between state changes (the connection - * count is constant), so a segment is closed only when any input changes — - * plus on [closeOpenSegment], which the accountant calls before every flush - * and read so multi-hour stable sessions still account. */ class RelayConnectionTimeIntegrator( private val connectedCount: Flow, private val isMobile: Flow, private val isForeground: Flow, - private val accountant: ResourceUsageAccountant, - private val nowMs: () -> Long = { System.currentTimeMillis() }, -) { - private data class SegmentState( + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + data class SegmentState( val count: Int, val mobile: Boolean, val foreground: Boolean, ) - private val lock = Any() - private var current: SegmentState? = null - private var segmentStartMs: Long = 0L - fun start(scope: CoroutineScope): Job { - accountant.addPreFlushHook(::closeOpenSegment) + registerFlushHook() return scope.launch { combine(connectedCount, isMobile, isForeground) { count, mobile, fg -> SegmentState(count, mobile ?: false, fg) @@ -63,26 +55,7 @@ class RelayConnectionTimeIntegrator( } } - /** Accounts the running segment up to now without changing state. */ - fun closeOpenSegment() { - synchronized(lock) { - val state = current ?: return - val now = nowMs() - account(state, now - segmentStartMs) - segmentStartMs = now - } - } - - private fun transitionTo(next: SegmentState) { - synchronized(lock) { - val now = nowMs() - current?.let { account(it, now - segmentStartMs) } - current = next - segmentStartMs = now - } - } - - private fun account( + override fun account( state: SegmentState, elapsedMs: Long, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index 0b19a1bf1d..778532c29a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -25,15 +25,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.LongAdder +import java.util.concurrent.atomic.AtomicLong /** * In-memory hot path for usage counters. [add] is called from network threads * per relay frame / HTTP response chunk, so it must be allocation-light and - * lock-free: a ConcurrentHashMap of LongAdders, drained into + * lock-free: a ConcurrentHashMap of AtomicLongs, drained into * [ResourceUsageStore] by a debounced flush (at most one write per * [flushDebounceMs] while traffic flows; nothing scheduled when idle). * + * AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0) + * hands off the accumulated value atomically, whereas remove+sum on a + * LongAdder can strand a racing increment on an orphaned cell. Entries stay + * in the map after a drain — the key space is small and fixed (dims x areas), + * so this costs a few hundred boxed zeros at most. + * + * Counters added from inside a pre-flush hook (the CPU sampler, the segment + * integrators closing an open segment) never re-arm the debounce: they are + * drained by the very flush that invoked the hook, and letting them schedule + * would turn every flush into a perpetual 30s wake-and-write loop — the + * battery ledger becoming its own battery drain. + * * Day attribution: deltas are drained into the bucket of the day they are * drained on. Counts within one debounce window of midnight may land on the * neighboring day — irrelevant at the ledger's day-level granularity. @@ -44,9 +56,12 @@ class ResourceUsageAccountant( private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS }, private val flushDebounceMs: Long = 30_000L, ) { - private val live = ConcurrentHashMap() + private val live = ConcurrentHashMap() private val flushScheduled = AtomicBoolean(false) + /** True only on the thread currently running the pre-flush hooks. */ + private val inHookRun = ThreadLocal.withInitial { false } + /** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */ private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>() @@ -59,7 +74,8 @@ class ResourceUsageAccountant( amount: Long, ) { if (amount <= 0) return - live.computeIfAbsent(key) { LongAdder() }.add(amount) + live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount) + if (inHookRun.get()) return if (flushScheduled.compareAndSet(false, true)) { scope.launch { delay(flushDebounceMs) @@ -69,9 +85,18 @@ class ResourceUsageAccountant( } } + private fun runPreFlushHooks() { + inHookRun.set(true) + try { + preFlushHooks.forEach { runCatching { it() } } + } finally { + inHookRun.set(false) + } + } + /** Drains the in-memory counters into today's persisted bucket. */ suspend fun flush() { - preFlushHooks.forEach { runCatching { it() } } + runPreFlushHooks() val deltas = drain() if (deltas.isNotEmpty()) { store.mergeInto(epochDay(), deltas) @@ -88,12 +113,12 @@ class ResourceUsageAccountant( /** * Persisted buckets merged with the not-yet-flushed live counters * (attributed to today). This is what the UI, the report assembler, - * and the alert evaluator read. + * and the alert evaluator read. Reading never schedules a flush. */ suspend fun allDaysIncludingLive(): Map> { - preFlushHooks.forEach { runCatching { it() } } + runPreFlushHooks() val persisted = store.allDays() - val liveSnapshot = live.mapValues { it.value.sum() }.filterValues { it > 0 } + val liveSnapshot = live.mapValues { it.value.get() }.filterValues { it > 0 } if (liveSnapshot.isEmpty()) return persisted val today = epochDay() val merged = persisted.toMutableMap() @@ -106,9 +131,8 @@ class ResourceUsageAccountant( private fun drain(): Map { if (live.isEmpty()) return emptyMap() val deltas = mutableMapOf() - for (key in live.keys) { - val adder = live.remove(key) ?: continue - val value = adder.sum() + for ((key, counter) in live) { + val value = counter.getAndSet(0L) if (value > 0) deltas[key] = value } return deltas diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 73ba5d434e..6c224b9f1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -98,7 +98,15 @@ class ResourceUsageReportAssembler { append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n") append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") + append("| PoW mining | ${formatDurationMs(s.powMs)} |\n") + append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n") + append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} |\n") + append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n") + append("| Location listening | ${formatDurationMs(s.locationMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") + append("| Decryptions | ${s.decryptCount} (${formatDurationMs(s.decryptUs / 1_000)} CPU) |\n") + append("| Remote signatures | ${s.signNip46} NIP-46, ${s.signNip55} NIP-55 |\n") + append("| Battery drain (measured, whole device) | ${s.batteryDrainFg}% in app, ${s.batteryDrainBg}% background |\n") append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") append("| Background worker runs | ${s.workerRuns} |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt new file mode 100644 index 0000000000..c70ae6032c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt @@ -0,0 +1,106 @@ +/* + * 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.service.resourceusage + +import android.os.SystemClock + +/** + * Timer-free duration integrator: the elapsed time of the current state is + * exact between transitions, so a segment is accounted only when the state + * changes — plus on the accountant's pre-flush hook, so multi-hour stable + * segments (a call, a backgrounded night with relays connected) still land in + * the right day bucket without any periodic timer. + * + * Durations are measured with [SystemClock.elapsedRealtime] — the monotonic + * clock — never the wall clock: an NTP/timezone correction mid-segment must + * not fabricate or delete accounted time. (Wall time is only ever used for + * choosing the epoch-day bucket, which happens in the accountant.) + * + * A `null` state means "nothing to account" (idle); subclasses decide what a + * non-null state costs per elapsed millisecond. + */ +abstract class TimeSegmentIntegrator( + protected val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) { + private val lock = Any() + private var current: S? = null + private var segmentStartMs = 0L + + /** Registers [closeOpenSegment] so flushes and reads see up-to-date totals. */ + fun registerFlushHook() { + accountant.addPreFlushHook(::closeOpenSegment) + } + + /** Accounts the running segment up to now without changing state. */ + fun closeOpenSegment() { + synchronized(lock) { + val state = current ?: return + val now = nowMs() + account(state, now - segmentStartMs) + segmentStartMs = now + } + } + + /** + * Accounts the previous segment and starts a new one. Returns the previous + * state so callers can detect activations (null -> non-null). + */ + fun transitionTo(next: S?): S? = + synchronized(lock) { + val now = nowMs() + val prev = current + prev?.let { account(it, now - segmentStartMs) } + current = next + segmentStartMs = now + prev + } + + protected abstract fun account( + state: S, + elapsedMs: Long, + ) +} + +/** + * The simplest integrator: total time a boolean condition is active (Tor + * running, a call in progress, GPS listening), written to [msKey] — plus an + * optional [startsKey] counting activations, since starts are often the + * expensive part (a Tor bootstrap, a service cold start). + */ +class SessionTimeIntegrator( + accountant: ResourceUsageAccountant, + private val msKey: String, + private val startsKey: String? = null, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + fun setActive(active: Boolean) { + val prev = transitionTo(if (active) Unit else null) + if (active && prev == null && startsKey != null) accountant.add(startsKey, 1) + } + + override fun account( + state: Unit, + elapsedMs: Long, + ) { + if (elapsedMs > 0) accountant.add(msKey, elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 37b198d2cf..3931d3d8e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response @@ -53,7 +54,7 @@ class RadioBurstEstimator( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, - private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) { @Volatile private var lastActivityMs = Long.MIN_VALUE @@ -91,7 +92,7 @@ class UsageCountingInterceptor( private val isForeground: () -> Boolean, private val bursts: RadioBurstEstimator? = null, private val defaultRole: String = UsageKeys.ROLE_OTHER, - private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 2b0a799e21..0f8a93aa4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -127,6 +127,51 @@ object UsageKeys { /** Media (video/audio) playback time — decoder + screen + streaming all at once. */ const val MEDIA_PLAY_MS = "media.playms" + /** NIP-13 proof-of-work mining: full-core CPU for as long as it runs. */ + const val POW_MS = "pow.ms" + const val POW_SESSIONS = "pow.sessions" + + /** In-app (Arti) Tor: circuit crypto + directory/guard keep-alives while up; each start pays a bootstrap. */ + const val TOR_MS = "tor.ms" + const val TOR_STARTS = "tor.starts" + + /** Always-on notification relay service uptime — the mode context for its relay connections. */ + const val ALWAYS_ON_MS = "service.alwayson.ms" + + /** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */ + const val CALL_MS = "call.ms" + const val CALL_SESSIONS = "call.sessions" + const val NESTS_MS = "nests.ms" + const val NESTS_SESSIONS = "nests.sessions" + + /** Time spent actively listening for GPS/location updates (geohash tagging). */ + const val LOCATION_MS = "location.ms" + + /** + * NIP-04/44 decryptions and encryptions through account signers. Durations + * are only metered for local-key signers (CPU cost); external/remote + * signer waits are IPC/network, tracked by the sign/decrypt counts alone. + */ + const val DECRYPT_COUNT = "crypto.decrypt.count" + const val DECRYPT_US = "crypto.decrypt.us" + const val ENCRYPT_COUNT = "crypto.encrypt.count" + const val ENCRYPT_US = "crypto.encrypt.us" + + /** `sign.nip46.count` — signatures by signer kind: local key, NIP-55 (Amber IPC), NIP-46 (relay round-trip). */ + fun signs(kind: String): String = "sign.$kind.count" + + const val SIGNER_LOCAL = "local" + const val SIGNER_NIP46 = "nip46" + const val SIGNER_NIP55 = "nip55" + + /** + * Measured battery drain (percent points while discharging), split by + * visibility. Not app-isolated — it's the ground truth the other counters + * get correlated against across reports. + */ + const val BATTERY_DRAIN_FG = "battery.drain.fg" + const val BATTERY_DRAIN_BG = "battery.drain.bg" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 7a1cd0bb6a..72cb38117c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -50,6 +50,18 @@ data class UsageSummary( val radioBursts: Long, val httpActiveMs: Long, val mediaPlayMs: Long, + val powMs: Long, + val torMs: Long, + val alwaysOnMs: Long, + val callMs: Long, + val nestsMs: Long, + val locationMs: Long, + val decryptCount: Long, + val decryptUs: Long, + val signNip46: Long, + val signNip55: Long, + val batteryDrainFg: Long, + val batteryDrainBg: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -95,6 +107,18 @@ data class UsageSummary( radioBursts = counters.sumMatching("bursts"), httpActiveMs = counters.sumMatching("activems"), mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, + powMs = counters[UsageKeys.POW_MS] ?: 0L, + torMs = counters[UsageKeys.TOR_MS] ?: 0L, + alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, + callMs = counters[UsageKeys.CALL_MS] ?: 0L, + nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L, + locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L, + decryptCount = counters[UsageKeys.DECRYPT_COUNT] ?: 0L, + decryptUs = counters[UsageKeys.DECRYPT_US] ?: 0L, + signNip46 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP46)] ?: 0L, + signNip55 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP55)] ?: 0L, + batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L, + batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L, bytesPerSubsystem = subsystems, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 49704a9575..79bb11bde3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayA import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher -import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -172,20 +172,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - val externalSignerLauncher = - when (val signer = accountViewModel.account.signer) { - is IActivityLauncher -> { - signer - } - - is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> { - signer.inner as IActivityLauncher - } - - else -> { - null - } - } + val externalSignerLauncher = accountViewModel.account.signer.innermostSigner() as? IActivityLauncher if (externalSignerLauncher != null) { val launcher = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 0e416b735d..aac8e93e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -291,6 +291,42 @@ private fun ActivitySection(s: UsageSummary) { MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) SettingsDivider() MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + if (s.decryptCount > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_decrypts, s.decryptCount.toString()) + } + if (s.signNip46 + s.signNip55 > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_remote_signs, (s.signNip46 + s.signNip55).toString()) + } + if (s.powMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_pow, formatDurationMs(s.powMs)) + } + if (s.torMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_tor, formatDurationMs(s.torMs)) + } + if (s.alwaysOnMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_always_on, formatDurationMs(s.alwaysOnMs)) + } + if (s.callMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_calls, formatDurationMs(s.callMs)) + } + if (s.nestsMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_nests, formatDurationMs(s.nestsMs)) + } + if (s.locationMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_location, formatDurationMs(s.locationMs)) + } + if (s.batteryDrainFg + s.batteryDrainBg > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_battery_drain, "${s.batteryDrainFg}% / ${s.batteryDrainBg}%") + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 429795778e..e3e5e34e8e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3209,6 +3209,15 @@ Processor time used Time in app App process starts + Messages decrypted + Remote signatures + Proof-of-work mining + Tor uptime + Always-on notification service + Time in calls + Time in audio rooms + Location listening + Battery used (in app / background) Data by feature (7 days) Relay sync Images diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index f99f1dc451..3a09f2064f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -21,6 +21,12 @@ package com.vitorpamplona.amethyst.service.resourceusage import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -122,6 +128,46 @@ class ResourceUsageAccountantTest { assertNull(store.allDays()[300]) } + @Test + fun countersKeepAccumulatingAfterADrain() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 200L }) + + accountant.add("x", 5) + accountant.flush() + accountant.add("x", 7) + accountant.flush() + + assertEquals(12L, store.allDays()[200]?.get("x")) + } + + @Test + fun hookAddsAreDrainedInPlaceAndNeverRearmTheFlushLoop() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + var hookRuns = 0 + accountant.addPreFlushHook { + hookRuns++ + accountant.add("hook.counter", 1) + } + + accountant.add("x", 1) + testScheduler.advanceTimeBy(31_000) + testScheduler.runCurrent() + + assertEquals("the one debounced flush ran its hooks once", 1, hookRuns) + assertEquals(1L, store.allDays()[300]?.get("hook.counter")) + + // If hook adds re-armed the debounce, more flushes would fire and + // the hook counter would keep growing without any real activity. + testScheduler.advanceTimeBy(300_000) + testScheduler.runCurrent() + assertEquals("no self-perpetuating flush loop", 1, hookRuns) + assertEquals(1L, store.allDays()[300]?.get("hook.counter")) + } + @Test fun preFlushHooksRunOnFlushAndRead() = runTest { @@ -338,6 +384,226 @@ class MediaPlayTimeTrackerTest { } } +class SessionTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsActiveTimeAndCountsActivations() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock }) + + session.setActive(true) + clock += 5_000 + session.setActive(false) + clock += 60_000 // idle time must not count + session.setActive(true) + clock += 1_000 + session.setActive(false) + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(6_000L, today["s.ms"]) + assertEquals(2L, today["s.starts"]) + } + + @Test + fun repeatedActivationsDoNotDoubleCountStarts() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock }) + + session.setActive(true) + clock += 1_000 + session.setActive(true) + clock += 1_000 + session.setActive(false) + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(2_000L, today["s.ms"]) + assertEquals(1L, today["s.starts"]) + } + + @Test + fun openSegmentIsAccountedOnFlushWithoutClosing() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", nowMs = { clock }) + session.registerFlushHook() + + session.setActive(true) + clock += 120_000 + val mid = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals("multi-hour stable sessions account without a transition", 120_000L, mid["s.ms"]) + + clock += 30_000 + session.setActive(false) + val end = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(150_000L, end["s.ms"]) + } +} + +class BatteryDrainSamplerTest { + @get:Rule + val temp = TemporaryFolder() + + private fun harness(scope: CoroutineScope): Triple { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, scope, epochDay = { 100L }) + val controls = Controls() + val sampler = + BatteryDrainSampler( + accountant = accountant, + capacityPct = { controls.pct }, + isCharging = { controls.charging }, + isForeground = { controls.foreground }, + ) + return Triple(accountant, sampler, controls) + } + + private class Controls { + var pct: Int? = 90 + var charging = false + var foreground = true + } + + @Test + fun firstSampleOnlyEstablishesTheBaseline() = + runTest { + val (accountant, sampler, _) = harness(backgroundScope) + sampler.sample() + assertNull(accountant.allDaysIncludingLive()[100]?.get(UsageKeys.BATTERY_DRAIN_FG)) + } + + @Test + fun dischargeDropsAreAccountedByVisibility() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90, discharging + controls.pct = 87 + sampler.sample() // -3 while foreground + controls.foreground = false + controls.pct = 85 + sampler.sample() // -2 while background + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(3L, today[UsageKeys.BATTERY_DRAIN_FG]) + assertEquals(2L, today[UsageKeys.BATTERY_DRAIN_BG]) + } + + @Test + fun intervalsTouchingAChargerAreSkipped() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90, discharging + controls.charging = true + controls.pct = 80 // weird drop while charging: ignore + sampler.sample() + controls.charging = false + controls.pct = 78 // first discharging interval after charging: baseline only + sampler.sample() + controls.pct = 77 + sampler.sample() // clean discharging interval: -1 + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG]) + } + + @Test + fun aLevelIncreaseResetsTheBaselineWithoutAccounting() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90 + controls.pct = 95 // e.g. charged while the process was frozen + sampler.sample() + controls.pct = 94 + sampler.sample() // -1 + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG]) + } +} + +class MeteringNostrSignerTest { + @get:Rule + val temp = TemporaryFolder() + + /** Pure-Kotlin fake so the test never touches secp256k1 JNI. */ + private class FakeSigner : NostrSigner("aa".repeat(32)) { + override fun isWriteable() = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = throw UnsupportedOperationException("fake") + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "enc04" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "dec04" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "enc44" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "dec44" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw UnsupportedOperationException("fake") + + override suspend fun deriveKey(nonce: HexKey): HexKey = "bb".repeat(32) + + override suspend fun signPsbt(psbtHex: String): String = psbtHex + + override fun hasForegroundSupport() = true + } + + @Test + fun countsSignsAndCryptoOpsWithoutChangingResults() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + val metered = MeteringNostrSigner(FakeSigner(), accountant) + + assertEquals("dec44", metered.nip44Decrypt("x", "aa".repeat(32))) + assertEquals("dec04", metered.nip04Decrypt("x", "aa".repeat(32))) + assertEquals("enc44", metered.nip44Encrypt("x", "aa".repeat(32))) + runCatching { metered.sign(0L, 1, arrayOf(), "hello") } + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(2L, today[UsageKeys.DECRYPT_COUNT]) + assertEquals(1L, today[UsageKeys.ENCRYPT_COUNT]) + assertEquals(1L, today[UsageKeys.signs(UsageKeys.SIGNER_LOCAL)]) + assertNull("non-local signers must not pollute crypto CPU time", today[UsageKeys.DECRYPT_US]) + } + + @Test + fun innermostSignerUnwrapsTheMeteringDecorator() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + val raw = FakeSigner() + val metered = MeteringNostrSigner(raw, accountant) + assertEquals(raw, metered.innermostSigner()) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From 1d385cae0f01c131cbb5befc7fc3d64c82214446 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:04:36 +0000 Subject: [PATCH 14/39] refactor(ui): move HeaderPill and QuietMark to commons/ui/note The two note-header marker primitives are pure Compose with no Android dependencies, so they belong in the shared layer where the desktop app (and future iOS) can render the same design system. QuietMark's gray comes from a new commons ColorScheme.placeholderText extension (onSurface at 42%, matching the Android theme's value). The thin app-side wrappers (BoostedMark, DisplayPoW, DisplayDraft, DisplayOts, DisplayLocation, DisplayReward, ...) stay in amethyst: they bind the primitives to Android string resources (with existing translations that commons' compose-resources catalog does not have) and to app state (LocalCache loaders, AccountViewModel, navigation). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 ++-- .../amethyst/ui/note/elements/BoostedMark.kt | 1 + .../amethyst/ui/note/elements/DisplayEditStatus.kt | 1 + .../amethyst/ui/note/elements/DisplayLocation.kt | 1 + .../amethyst/ui/note/elements/DisplayOts.kt | 1 + .../amethyst/ui/note/elements/DisplayPoW.kt | 1 + .../amethyst/ui/note/elements/DisplayReward.kt | 1 + .../ui/note/elements/NoteHeaderMarkersPreview.kt | 3 ++- .../loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 2 +- .../amethyst/commons/ui/note}/HeaderPill.kt | 2 +- .../amethyst/commons/ui/note}/QuietMark.kt | 12 +++++++----- .../amethyst/commons/ui/theme/ThemeExtensions.kt | 8 ++++++++ 12 files changed, 27 insertions(+), 10 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note}/HeaderPill.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note}/QuietMark.kt (91%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 39ef85a00c..579844d990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -84,9 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton -import com.vitorpamplona.amethyst.ui.note.elements.QuietMark import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index 7b81088adc..c48531aaca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.stringRes @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 48a2e6b60f..0ffae65a1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index cf240cb7f2..a32e65d1b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index c5cf453138..319293d69c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 64aad9648a..e6fc7b772e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 0b36780bd1..fbd4270e07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -55,6 +55,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index ea1c4287df..02438cf59a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -76,7 +76,8 @@ private val COMMUNITY_ADDRESS = "34550:" + "c".repeat(64) + ":amethyst-users" * Design-system preview for the note-header first row, rendered by the REAL * `FirstUserInfoRow` over notes seeded into [LocalCache] — the same pattern * the ZapPollNote/Poll previews use. Rows go from bare to fully loaded so the - * two marker tiers ([HeaderPill] and [QuietMark]) can be reviewed together. + * two marker tiers (`HeaderPill` and `QuietMark`, now in `commons/ui/note`) + * can be reviewed together. * * Two markers cannot appear in a static preview because their visibility is * computed inside effects that previews never run: the followed-hashtag label diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 976cbe3032..52aec3f3c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent -import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 6661391c8b..6c4dc4726b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -18,7 +18,7 @@ * 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.note.elements +package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt index acd339471e..8f3cea79a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt @@ -18,7 +18,7 @@ * 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.note.elements +package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText + +private val QuietMarkFontSize = 12.sp /** * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, @@ -70,8 +72,8 @@ fun QuietMark( Text( text = text, color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - lineHeight = Font12SP, + fontSize = QuietMarkFontSize, + lineHeight = QuietMarkFontSize, fontWeight = FontWeight.Medium, maxLines = 1, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt index 0e8e0866fd..ad4488e405 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.ui.theme import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -36,3 +37,10 @@ val ColorScheme.isLight: Boolean */ val ColorScheme.onBackgroundColorFilter: ColorFilter get() = ColorFilter.tint(onBackground) + +/** + * De-emphasized text/icon color for secondary markers and hints. Matches the + * Android app's placeholderText, which is the palette's onSurface at 42%. + */ +val ColorScheme.placeholderText: Color + get() = onSurface.copy(alpha = 0.42f) From bd4d80160e27882f6323400f3995efc61247a2be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:14:41 +0000 Subject: [PATCH 15/39] fix: resolve remaining audit findings in worker gates, ledger accuracy, and alert flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduledPostWorkGate now treats PUBLISHING as pending work: claiming the last PENDING post no longer cancels the periodic worker mid-publish (which stranded the post in PUBLISHING with no recovery). ScheduledPostStore also releases stale PUBLISHING claims at initial load — a claim found on disk belongs to a dead process and is reset to PENDING for retry. - CalendarReminderWorker decides its self-cancel on a FRESH cache snapshot, closing the race where an RSVP accepted mid-run was lost forever (the observer's KEEP schedule no-ops while the chain exists). A second observer on kind-31922/31923 re-arms the chain when the organizer reschedules the target event — the existing RSVP never re-fires observeNewEvents, so the moved appointment previously produced no reminder. The rescan is throttled (conflate + 30s) and gated on the feature being enabled. - UsageCountingInterceptor skips loopback hosts: local Blossom cache hits (127.0.0.1) never touch the radio and were inflating mobile rx bytes, radio-burst estimates, and potentially the background-data alert. - The usage alert's 7-day rate limit is now consumed when the user acts on the dialog (any button or dismissal) instead of before the first frame, so a config change or process death no longer burns the prompt budget on a dialog nobody saw. - DEV_REPORT_PUBKEY is defined once in service/crashreports and imported by both the crash and resource-usage send paths. - ResourceUsageScreen remembers its summaries keyed on the loaded data — the 2s memory ticker was recomputing the full multi-day counter walk on every recomposition. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../com/vitorpamplona/amethyst/AppModules.kt | 28 ++++++++++++++++++ .../calendar/CalendarReminderWorker.kt | 15 +++++++--- .../service/crashreports/DevReportContact.kt | 28 ++++++++++++++++++ .../crashreports/DisplayCrashMessages.kt | 2 +- .../DisplayResourceUsageAlert.kt | 29 +++++++++++-------- .../resourceusage/UsageCountingInterceptor.kt | 12 ++++++++ .../scheduledposts/ScheduledPostStore.kt | 21 +++++++++++++- .../scheduledposts/ScheduledPostWorkGate.kt | 7 ++++- .../loggedIn/settings/ResourceUsageScreen.kt | 11 ++++--- .../resourceusage/ResourceUsageLedgerTest.kt | 13 +++++++++ .../scheduledposts/ScheduledPostStoreTest.kt | 16 ++++++++++ .../ScheduledPostWorkGateTest.kt | 22 ++++++++++++++ 12 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index b36593c4aa..c0974104f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager @@ -116,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorService import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger @@ -143,12 +145,15 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -161,6 +166,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull @@ -1063,6 +1069,28 @@ class AppModules( } } + // A rescheduled appointment must also re-arm the chain: the worker + // cancels itself when every known target is in the past, and a + // kind-31922/31923 update (the organizer moving the event) arrives + // WITHOUT any new RSVP — the user's existing RSVP still points at the + // same address, and observeNewEvents never re-fires for it. conflate + + // delay bounds the cache rescan to one per 30s while event feeds + // stream slot events; the scan only runs for users with reminders on. + applicationIOScope.launch { + LocalCache + .observeNewEvents( + Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), + ).conflate() + .collect { + if (CalendarReminderPrefs(appContext).isEnabled() && + CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) + ) { + CalendarReminderWorker.schedule(appContext) + } + delay(30_000) + } + } + // Watch for account login and start/stop always-on notification service applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 60453031bd..fb65cb09fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -120,10 +120,17 @@ class CalendarReminderWorker( store.forgetBefore(now - PRUNE_AGE_SECONDS) // Nothing left that could ever fire → end the periodic chain instead of - // waking the process every 15 minutes forever. The ACCEPTED-RSVP observer - // in AppModules re-schedules the worker the next time a live session sees - // an accepted RSVP. - if (!couldStillFire(acceptedRsvps, now)) { + // waking the process every 15 minutes forever. The observers in + // AppModules re-schedule the worker the next time a live session sees + // an accepted RSVP or a calendar-event update. + // + // Decide on a FRESH cache snapshot, not the one from the start of the + // run: an RSVP accepted while this run was scanning already fired the + // observer, whose schedule() uses KEEP and no-ops while this chain + // still exists — cancelling on the stale snapshot would kill the chain + // with that RSVP's reminder permanently lost (observeNewEvents never + // re-fires for an event that is already in cache). + if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) { Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } cancel(applicationContext) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt new file mode 100644 index 0000000000..8cf5d02441 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt @@ -0,0 +1,28 @@ +/* + * 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.service.crashreports + +/** + * Developer recipient for every user-initiated diagnostic NIP-17 DM — crash + * reports and resource-usage reports both route here. Single definition so a + * key rotation can never leave one path DMing the old key. + */ +const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt index 073b4a49f0..b5fcfc4b83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt @@ -80,7 +80,7 @@ fun DisplayCrashMessages( onClick = { nav.nav { routeToMessage( - user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"), + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), draftMessage = stack, accountViewModel = accountViewModel, expiresDays = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index ddac466f85..733f06f392 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -35,6 +35,7 @@ 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.service.crashreports.DEV_REPORT_PUBKEY import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,12 +44,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Developer recipient for user-initiated diagnostic DMs — the same account - * the crash-report dialog routes to. - */ -const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" - /** * On app open, checks the resource-usage ledger against the * [ResourceUsageAlerts] thresholds and — at most once per week, unless the @@ -74,16 +69,26 @@ fun DisplayResourceUsageAlert( } val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today()) if (found != null) { - // Mark immediately so process restarts can't re-prompt within the window. - store.markAlertPrompted(TimeUtils.now()) alert.value = found } } } + // The 7-day rate limit is consumed when the user ACTS on the dialog (any + // button or dismissal), not when it is shown: marking before the first + // frame let a config change or process death burn the whole prompt budget + // on a dialog nobody ever saw, silencing an active battery problem for a + // week. Re-showing after an unhandled recreation is exactly what we want. + val dismiss = { + accountViewModel.runOnIO { + Amethyst.instance.resourceUsageStore.markAlertPrompted(TimeUtils.now()) + } + alert.value = null + } + alert.value?.let { found -> AlertDialog( - onDismissRequest = { alert.value = null }, + onDismissRequest = dismiss, title = { Text(stringRes(R.string.resource_usage_alert_title)) }, text = { Text( @@ -99,11 +104,11 @@ fun DisplayResourceUsageAlert( accountViewModel.runOnIO { Amethyst.instance.resourceUsageStore.setAlertsOptOut(true) } - alert.value = null + dismiss() }) { Text(stringRes(R.string.resource_usage_alert_opt_out)) } - TextButton(onClick = { alert.value = null }) { + TextButton(onClick = dismiss) { Text(stringRes(R.string.resource_usage_alert_not_now)) } } @@ -126,7 +131,7 @@ fun DisplayResourceUsageAlert( expiresDays = 30, ) } - alert.value = null + dismiss() }) { Text(stringRes(R.string.resource_usage_alert_send)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 3931d3d8e0..83e8cafe21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -96,6 +96,13 @@ class UsageCountingInterceptor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() + + // Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache + // hits to 127.0.0.1) never touches the radio: counting it would inflate + // the network numbers — and could trip the background-data alert — for + // exactly the users who cache aggressively to SAVE data. + if (isLoopback(request.url.host)) return chain.proceed(request) + val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1) @@ -124,6 +131,11 @@ class UsageCountingInterceptor( ).build() } + companion object { + /** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */ + fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]" + } + private class CountingResponseBody( private val delegate: ResponseBody, private val onBytes: (Long) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt index d5898b181b..7d86510ed3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -206,9 +206,28 @@ class ScheduledPostStore( mutableListOf() } loaded = true + val recovered = releaseStaleClaims() val purged = purgeStale(nowSec()) _flow.value = posts.toList() - if (purged) persist() + if (purged || recovered) persist() + } + + /** + * A PUBLISHING row found at initial disk load is a claim from a previous + * process — the worker that held it died (crash, cancellation) before + * markSent/markFailed/releaseClaim ran. No worker in THIS process can + * hold a claim before the first load, so reset them to PENDING for the + * next cycle to retry. Returns true if any row was recovered. + */ + private fun releaseStaleClaims(): Boolean { + var recovered = false + posts.forEachIndexed { idx, post -> + if (post.status == ScheduledPostStatus.PUBLISHING) { + posts[idx] = post.copy(status = ScheduledPostStatus.PENDING) + recovered = true + } + } + return recovered } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt index 3cda10674b..dd0542050a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt @@ -37,6 +37,11 @@ import kotlinx.coroutines.launch * publishNow, releaseClaim) and [onNoPendingWork] when the last one drains * (markSent/markFailed/cancel/removeForAccount). * + * PUBLISHING counts as pending work: claimDuePosts flips PENDING → PUBLISHING + * (and emits) BEFORE the worker publishes, so gating on PENDING alone would + * cancel the periodic worker mid-publish the moment it claims the last post — + * stranding the post in PUBLISHING with nothing left to finish or retry it. + * * [start] forces the store's initial disk load BEFORE collecting: the flow's * initial value is an empty list until the store is first touched, and acting * on that placeholder would cancel scheduled work that a pending post still @@ -52,7 +57,7 @@ class ScheduledPostWorkGate( scope.launch { store.list() store.flow - .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING || it.status == ScheduledPostStatus.PUBLISHING } } .distinctUntilChanged() .collect { hasPending -> if (hasPending) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index aac8e93e84..d76c0a1ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -58,7 +58,7 @@ 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.crashreports.DEV_REPORT_PUBKEY import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours @@ -125,9 +125,12 @@ fun ResourceUsageScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - val today = Amethyst.instance.resourceUsage.today() - val todaySummary = UsageSummary.from(loaded[today].orEmpty()) - val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) + // remember(loaded): the memory produceState recomposes this + // scope every 2s, and the summaries walk every counter of + // every retained day — recompute only when the data reloads. + val today = remember(loaded) { Amethyst.instance.resourceUsage.today() } + val todaySummary = remember(loaded) { UsageSummary.from(loaded[today].orEmpty()) } + val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) } TodayTiles(todaySummary) if (weekSummary.totalBytes > 0) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 3a09f2064f..6a2e216392 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -363,6 +363,19 @@ class RadioBurstEstimatorTest { } } +class LoopbackExclusionTest { + @Test + fun loopbackHostsAreRecognizedAndRealHostsAreNot() { + assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1")) + assertTrue(UsageCountingInterceptor.isLoopback("127.4.5.6")) + assertTrue(UsageCountingInterceptor.isLoopback("localhost")) + assertTrue(UsageCountingInterceptor.isLoopback("::1")) + assertFalse(UsageCountingInterceptor.isLoopback("relay.example.com")) + assertFalse(UsageCountingInterceptor.isLoopback("192.168.1.10")) + assertFalse(UsageCountingInterceptor.isLoopback("128.0.0.1")) + } +} + class MediaPlayTimeTrackerTest { @Test fun accumulatesOnlyWhilePlaying() { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt index ea6e0a09a3..4389ea6b78 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -60,6 +60,22 @@ class ScheduledPostStoreTest { createdAtSec = 500, ) + @Test + fun stalePublishingClaimsAreReleasedOnLoad() = + runTest { + val store = newStore() + store.add(samplePost(id = "a")) + val claimed = store.claimDuePosts(nowSec = 2_000) + assertEquals(1, claimed.size) + assertEquals(ScheduledPostStatus.PUBLISHING, store.list().single().status) + + // A fresh store (new process) finds the on-disk PUBLISHING row: the + // worker that claimed it died with the old process, so the claim + // must be released for the next cycle to retry. + val reloaded = newStore().list().single() + assertEquals(ScheduledPostStatus.PENDING, reloaded.status) + } + @Test fun add_persists_to_disk() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index f3577d52f1..85f85d0515 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -130,6 +130,28 @@ class ScheduledPostWorkGateTest { assertEquals(listOf(false, true, false, true), decisions) } + @Test + fun claimingTheLastPendingPost_doesNotCancelTheRunningWorker() = + runTest { + val store = newStore() + store.add(samplePost(id = "a", publishAtSec = 1_000)) + startGate(store) + runCurrent() + assertEquals(listOf(true), decisions) + + // The periodic worker claims the post (PENDING -> PUBLISHING) and + // is now mid-publish. Cancelling here would kill the very worker + // holding the claim and strand the post in PUBLISHING forever. + store.claimDuePosts(nowSec = 2_000) + runCurrent() + assertEquals("a claim must never cancel the chain", listOf(true), decisions) + + // Only the publish actually finishing drains the gate. + store.markSent("a") + runCurrent() + assertEquals(listOf(true, false), decisions) + } + @Test fun publishNowOnFailedPost_reschedulesTheWorker() = runTest { From b2914fbc4bf6afc8925a6dcae4e15f226368a9b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:21:20 +0000 Subject: [PATCH 16/39] feat: count always-on notification service starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uptime alone can't distinguish a stable 24h service from one the watchdog restarts every hour — each restart is a process wake plus a full round of relay reconnects. Reported alongside uptime in the usage report. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../src/main/java/com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../service/resourceusage/ResourceUsageReportAssembler.kt | 2 +- .../amethyst/service/resourceusage/UsageKeys.kt | 7 ++++++- .../amethyst/service/resourceusage/UsageSummary.kt | 2 ++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c0974104f2..04f711baba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -338,7 +338,7 @@ class AppModules( // Session-time counters for the app's long-running battery consumers. // All timer-free segment integrators: services and status flows flip them // on/off, so tracking costs one counter write per transition. - val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS).also { it.registerFlushHook() } + val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS, UsageKeys.ALWAYS_ON_STARTS).also { it.registerFlushHook() } val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() } val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() } private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 6c224b9f1b..cd23077473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -100,7 +100,7 @@ class ResourceUsageReportAssembler { append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") append("| PoW mining | ${formatDurationMs(s.powMs)} |\n") append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n") - append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} |\n") + append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} (${s.alwaysOnStarts} starts) |\n") append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n") append("| Location listening | ${formatDurationMs(s.locationMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 0f8a93aa4f..357dfd644d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -135,8 +135,13 @@ object UsageKeys { const val TOR_MS = "tor.ms" const val TOR_STARTS = "tor.starts" - /** Always-on notification relay service uptime — the mode context for its relay connections. */ + /** + * Always-on notification relay service: uptime (the mode context for its + * relay connections) and starts — each start beyond the first is churn + * (watchdog alarm or auto-restart re-launching a killed service). + */ const val ALWAYS_ON_MS = "service.alwayson.ms" + const val ALWAYS_ON_STARTS = "service.alwayson.starts" /** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */ const val CALL_MS = "call.ms" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 72cb38117c..f28f851efa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -53,6 +53,7 @@ data class UsageSummary( val powMs: Long, val torMs: Long, val alwaysOnMs: Long, + val alwaysOnStarts: Long, val callMs: Long, val nestsMs: Long, val locationMs: Long, @@ -110,6 +111,7 @@ data class UsageSummary( powMs = counters[UsageKeys.POW_MS] ?: 0L, torMs = counters[UsageKeys.TOR_MS] ?: 0L, alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, + alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L, callMs = counters[UsageKeys.CALL_MS] ?: 0L, nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L, locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L, From 53e8f8df09413b5893f2662dbf7d9ebd49df42e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:30:07 +0000 Subject: [PATCH 17/39] feat: always-on service cost card with a link to notification settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uptime row told users the service ran, not what it cost — and the setting to turn it off lives three screens away. The resource screen now shows a dedicated card (only while the service has run this week) that frames the cost in decision terms: running time and restarts, the relay connections it held while the app was closed, and the measured background battery drain — with a button straight to notification settings where the delivery method can be changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../loggedIn/settings/ResourceUsageScreen.kt | 57 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 11 +++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index d76c0a1ae3..2b972a6d14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst @@ -65,6 +67,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -140,6 +143,7 @@ fun ResourceUsageScreen( } SubsystemSection(weekSummary) ActivitySection(weekSummary) + AlwaysOnServiceSection(weekSummary, nav) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -310,10 +314,6 @@ private fun ActivitySection(s: UsageSummary) { SettingsDivider() MetricRow(R.string.resource_usage_tor, formatDurationMs(s.torMs)) } - if (s.alwaysOnMs > 0) { - SettingsDivider() - MetricRow(R.string.resource_usage_always_on, formatDurationMs(s.alwaysOnMs)) - } if (s.callMs > 0) { SettingsDivider() MetricRow(R.string.resource_usage_calls, formatDurationMs(s.callMs)) @@ -400,6 +400,55 @@ private fun MemorySection(memory: MemorySnapshot?) { } } +/** + * Decision-support card for the always-on notification service — the one + * background consumer users can directly turn off. Shows what the service + * actually costs in the last 7 days (uptime, the relay connections it holds + * while the app is closed, and the measured background battery drain) and + * links straight to the setting that controls it. + */ +@Composable +private fun AlwaysOnServiceSection( + s: UsageSummary, + nav: INav, +) { + if (s.alwaysOnMs <= 0) return + val starts = s.alwaysOnStarts.toInt() + SettingsSection(R.string.resource_usage_alwayson_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_alwayson_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MetricRow( + R.string.resource_usage_alwayson_uptime, + "${formatDurationMs(s.alwaysOnMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}", + ) + SettingsDivider() + MetricRow( + R.string.resource_usage_alwayson_bg_relay, + formatConnHours(s.relayConnMsMobileBg + s.relayConnMsWifiBg), + ) + if (s.batteryDrainBg > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_alwayson_battery, "${s.batteryDrainBg}%") + } + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + TextButton( + onClick = { nav.nav(Route.NotificationSettings) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_alwayson_settings_button)) + } + } + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e3e5e34e8e..df93f3236c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3213,11 +3213,20 @@ Remote signatures Proof-of-work mining Tor uptime - Always-on notification service Time in calls Time in audio rooms Location listening Battery used (in app / background) + Background notification service + Keeps connections to your relays open while the app is closed, so notifications arrive without Google’s push servers. While enabled, this is usually the largest background battery cost. If battery matters more to you than instant notifications, you can change the delivery method or turn it off. + Running time + + %1$d start + %1$d starts + + Relay connections held while closed + Battery drained meanwhile (whole device) + Change notification settings Data by feature (7 days) Relay sync Images From 8a98ed3d15c037828a4f244b96f6d2850bbbc062 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 18:07:59 +0000 Subject: [PATCH 18/39] feat: actionable usage insights, rates, cellular-first bars, Tor card, and per-screen time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen presented evidence and left the user to be the analyst; now it draws the conclusions and links each one to the setting that acts on it: - Insights: a UsageInsights rules layer (unit-tested, thresholds informed by the ping study and normalized per observed day) renders up to three plain-language recommendations, each deep-linking to notification settings, media settings, the relay list, or privacy options. - Rates: battery %/hour in app (from the measured drain sampler), data/hour in app, and average simultaneous relay connections — totals aren't judgeable, rates are, and avg relays is directly actionable. - Subsystem bars rank by CELLULAR bytes when any exist (with totals as secondary context) — Wi-Fi bytes are nearly free, so ranking by totals pointed users at the wrong feature. - Built-in Tor gets the same cost card as the always-on service: uptime, bootstraps, and a link to privacy options. - Per-screen time (screen..ms): a ScreenTimeIntegrator fed by the nav controller's destination listener, gated on foreground. PRIVACY: only the route's base name is recorded — screenNameOf strips every navigation argument before the value leaves the navigation layer, so the ledger says 'Profile', never whose profile. Shown as proportion bars and included in the report's summary table. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 14 +- .../com/vitorpamplona/amethyst/AppModules.kt | 10 + .../ResourceUsageReportAssembler.kt | 8 + .../resourceusage/ScreenTimeIntegrator.kt | 85 ++++++++ .../service/resourceusage/UsageInsights.kt | 98 +++++++++ .../service/resourceusage/UsageKeys.kt | 10 + .../service/resourceusage/UsageSummary.kt | 35 +++- .../amethyst/ui/navigation/AppNavigation.kt | 25 +++ .../loggedIn/settings/ResourceUsageScreen.kt | 192 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 16 ++ .../resourceusage/ResourceUsageLedgerTest.kt | 158 ++++++++++++++ 11 files changed, 642 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 9c7e641739..eecf3161a1 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -106,10 +106,16 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: at flush from BatteryManager: NOT app-isolated, but the ground truth that report corpora can correlate the other counters against -Deliberately not tracked (v1): per-screen time (route names leak behavior -patterns into a report — needs its own privacy review), per-coroutine or -per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU -matters at all first), signing (user-action-rate, negligible). +- `screen..ms` — foreground time per screen, added after the original + privacy review: only the route's base NAME is recorded (screenNameOf strips + every navigation argument before the value leaves the nav layer), so the + ledger can say "Profile" but never whose profile + +Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a +thread registry; `cpu.ms` answers whether CPU matters at all first). +(Two earlier v1 exclusions were later revisited: per-screen time ships with +names-only privacy as above, and signing is now counted per signer kind +because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.) Flat keys keep the store schema-free: new counters need no migration. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 04f711baba..d8cba9a64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeInteg import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys @@ -345,6 +346,15 @@ class AppModules( private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + // Time-per-screen (route base names only — arguments never reach the + // ledger). Fed by the navigation listener in AppNavigation; foreground + // gating means backgrounding on a screen closes its segment. + val screenTime = ScreenTimeIntegrator(resourceUsage) + + init { + screenTime.start(applicationIOScope, foregroundTracker.isForeground) + } + // In-app (Arti) Tor uptime. Watches the raw TorService status — NOT // TorManager.status, whose upstream is WhileSubscribed and calls // service.start() when collected, so a permanent ledger subscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index cd23077473..e26eb98678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -118,6 +118,14 @@ class ResourceUsageReportAssembler { if (subsystems.isNotEmpty()) { append("| By subsystem | $subsystems |\n") } + val screens = + s.screenTimeMs.entries + .sortedByDescending { it.value } + .take(8) + .joinToString(", ") { "${it.key} ${formatDurationMs(it.value)}" } + if (screens.isNotEmpty()) { + append("| Screen time | $screens |\n") + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt new file mode 100644 index 0000000000..b995054ace --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt @@ -0,0 +1,85 @@ +/* + * 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.service.resourceusage + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates time-per-screen into the ledger (`screen..ms`): which + * parts of the app the display/CPU time actually goes to, so a report can + * distinguish "8 h of video" from "8 h of feeds". + * + * PRIVACY: only the route's base name is recorded ([screenNameOf] strips + * navigation arguments before anything reaches the ledger) — "Profile" is + * tracked, whose profile never is. Time only accrues while the app is in the + * foreground: the current-route × foreground combination is the segment + * state, so backgrounding on a screen closes its segment. + */ +class ScreenTimeIntegrator( + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + private val currentScreen = MutableStateFlow(null) + + /** Called from the navigation listener with an already-sanitized name (or null when unknown). */ + fun onScreen(name: String?) { + currentScreen.value = name + } + + fun start( + scope: CoroutineScope, + isForeground: Flow, + ): Job { + registerFlushHook() + return scope.launch { + combine(currentScreen, isForeground) { screen, fg -> if (fg) screen else null } + .collect { transitionTo(it) } + } + } + + override fun account( + state: String, + elapsedMs: Long, + ) { + if (elapsedMs > 0) accountant.add(UsageKeys.screenMs(state), elapsedMs) + } + + companion object { + /** + * Reduces a Navigation Compose route pattern to its bare screen name: + * `com...routes.Route.Profile/{userId}?tab={tab}` becomes `Profile`. + * Everything after `/` or `?` — where the arguments live — is dropped + * BEFORE the value leaves the navigation layer. + */ + fun screenNameOf(route: String?): String? = + route + ?.substringBefore('/') + ?.substringBefore('?') + ?.substringAfterLast('.') + ?.takeIf { it.isNotBlank() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt new file mode 100644 index 0000000000..87811e2b30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt @@ -0,0 +1,98 @@ +/* + * 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.service.resourceusage + +/** + * Turns a week of counters into at most [MAX_INSIGHTS] actionable + * recommendations, each mapping to a setting the user can actually change. + * The numbers alone make the user the analyst; these rules encode the + * analysis (thresholds informed by the 2026-07-12 ping study) and leave the + * user only the decision. + * + * Rules are ordered by typical battery impact; unlike [ResourceUsageAlerts] + * (which detects "something is wrong" and interrupts), insights render + * passively on the usage screen and use much lower thresholds — "worth + * knowing", not "pathological". + */ +object UsageInsights { + /** Each target names the settings surface that can act on the insight. */ + enum class Target { + NOTIFICATION_SETTINGS, + MEDIA_SETTINGS, + RELAY_SETTINGS, + PRIVACY_SETTINGS, + } + + data class Insight( + val target: Target, + /** ms for time-based insights, bytes for data, count for relays. */ + val value: Long, + ) + + /** + * Evaluates a multi-day summary ([UsageSummary.dayCount] normalizes the + * thresholds, so a 2-day-old install is judged on 2 days, not 7). + */ + fun evaluate(s: UsageSummary): List { + val days = s.dayCount.coerceAtLeast(1) + val insights = mutableListOf() + + // Background relay connections dominate drain while the always-on + // service is in use — the one consumer with a dedicated off switch. + val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg + if (s.alwaysOnMs > 0 && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { + insights += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs) + } + + // Cellular media: images/video/previews can be limited to Wi-Fi. + val cellularMediaBytes = + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) + if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { + insights += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes) + } + + // Average simultaneous relay connections across the whole period: + // every open connection is server-pinged every 30-70s, so the radio + // never sleeps while they're up. Fewer relays = less radio time. + val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) + if (avgRelays > AVG_RELAYS) { + insights += Insight(Target.RELAY_SETTINGS, avgRelays) + } + + // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { + insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + } + + return insights.take(MAX_INSIGHTS) + } + + const val MAX_INSIGHTS = 3 + private const val MS_PER_HOUR = 60L * 60L * 1000L + + // Per-day thresholds — deliberately well below the alert levels. + const val BG_RELAY_HOURS_PER_DAY = 3L + const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L + const val AVG_RELAYS = 25L + const val TOR_HOURS_PER_DAY = 4L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 357dfd644d..e43cde73a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -152,6 +152,16 @@ object UsageKeys { /** Time spent actively listening for GPS/location updates (geohash tagging). */ const val LOCATION_MS = "location.ms" + /** + * `screen.Home.ms` — time a screen was visible while the app was in the + * foreground. PRIVACY: only the route's base NAME is ever recorded, never + * its navigation arguments — "Profile" is tracked, whose profile is not + * (see ScreenTimeIntegrator.screenNameOf, which strips them). + */ + fun screenMs(screen: String): String = "$SCREEN_PREFIX$screen.ms" + + const val SCREEN_PREFIX = "screen." + /** * NIP-04/44 decryptions and encryptions through account signers. Durations * are only metered for local-key signers (CPU cost); external/remote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index f28f851efa..1fd24f085d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -52,6 +52,7 @@ data class UsageSummary( val mediaPlayMs: Long, val powMs: Long, val torMs: Long, + val torStarts: Long, val alwaysOnMs: Long, val alwaysOnStarts: Long, val callMs: Long, @@ -65,25 +66,51 @@ data class UsageSummary( val batteryDrainBg: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, + /** cellular-only rx+tx bytes per subsystem — the scarce resource. */ + val mobileBytesPerSubsystem: Map, + /** foreground time per screen NAME (arguments are never recorded). */ + val screenTimeMs: Map, + /** how many day buckets this summary was built from (>= 1). */ + val dayCount: Int, ) { val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg companion object { - fun from(counters: Map): UsageSummary { + fun from( + counters: Map, + dayCount: Int = 1, + ): UsageSummary { fun traffic( net: String, vis: String, ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) val subsystems = mutableMapOf() + val mobileSubsystems = mutableMapOf() for (role in UsageKeys.HTTP_ROLES) { val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX) if (bytes > 0) subsystems[role] = bytes + val mobileBytes = + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.TX) + if (mobileBytes > 0) mobileSubsystems[role] = mobileBytes } val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) if (relayBytes > 0) subsystems["relay"] = relayBytes + val mobileRelayBytes = + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.TX) + if (mobileRelayBytes > 0) mobileSubsystems["relay"] = mobileRelayBytes + + val screens = mutableMapOf() + for ((key, value) in counters) { + if (key.startsWith(UsageKeys.SCREEN_PREFIX) && key.endsWith(".ms") && value > 0) { + val name = key.removePrefix(UsageKeys.SCREEN_PREFIX).removeSuffix(".ms") + if (name.isNotBlank()) screens[name] = (screens[name] ?: 0L) + value + } + } return UsageSummary( mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), @@ -110,6 +137,7 @@ data class UsageSummary( mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, powMs = counters[UsageKeys.POW_MS] ?: 0L, torMs = counters[UsageKeys.TOR_MS] ?: 0L, + torStarts = counters[UsageKeys.TOR_STARTS] ?: 0L, alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L, callMs = counters[UsageKeys.CALL_MS] ?: 0L, @@ -122,6 +150,9 @@ data class UsageSummary( batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L, batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L, bytesPerSubsystem = subsystems, + mobileBytesPerSubsystem = mobileSubsystems, + screenTimeMs = screens, + dayCount = dayCount.coerceAtLeast(1), ) } @@ -129,7 +160,7 @@ data class UsageSummary( fun fromDays(days: Collection>): UsageSummary { val merged = mutableMapOf() days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } - return from(merged) + return from(merged, dayCount = days.size) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e5e7c77fa2..d4866a71b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -42,13 +42,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.util.Consumer import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -303,6 +306,7 @@ fun AppNavigation( } } + TrackScreenTime(nav) NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) @@ -327,6 +331,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) { } } +/** + * Feeds the resource-usage ledger with time-per-screen. Only the route's + * base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf] + * strips every navigation argument first, so the ledger can say "Profile" + * but never which profile. + */ +@Composable +private fun TrackScreenTime(nav: Nav) { + DisposableEffect(nav.controller) { + val listener = + NavController.OnDestinationChangedListener { _, destination, _ -> + Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route)) + } + nav.controller.addOnDestinationChangedListener(listener) + onDispose { + nav.controller.removeOnDestinationChangedListener(listener) + Amethyst.instance.screenTime.onScreen(null) + } + } +} + @Composable fun BuildNavigation( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 2b972a6d14..2e9f97683b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -61,10 +61,12 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs +import com.vitorpamplona.amethyst.service.resourceusage.UsageInsights import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -75,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import java.util.Locale /** * The resource-usage ledger: how much network, relay connection time, and @@ -136,14 +139,18 @@ fun ResourceUsageScreen( val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) } TodayTiles(todaySummary) + WeekRatesTiles(weekSummary) + InsightsSection(weekSummary, nav) if (weekSummary.totalBytes > 0) { SettingsSection(R.string.resource_usage_trend_section) { UsageTrendChart(loaded, today) } } SubsystemSection(weekSummary) + ScreenTimeSection(weekSummary) ActivitySection(weekSummary) AlwaysOnServiceSection(weekSummary, nav) + TorServiceSection(weekSummary, nav) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -196,9 +203,120 @@ private fun StatTile( } } -/** Ranked per-feature traffic with proportion bars (7 days). */ +/** + * 7-day rates: totals aren't judgeable, rates are. Battery %/hour of use is + * the most tangible battery number we can show; average simultaneous relay + * connections is the number a user can act on by trimming their relay list. + */ +@Composable +private fun WeekRatesTiles(s: UsageSummary) { + val fgHours = s.foregroundMs / 3_600_000.0 + val tiles = + buildList { + if (fgHours >= 0.5 && s.batteryDrainFg > 0) { + add(R.string.resource_usage_tile_battery_rate to String.format(Locale.US, "%.1f%%", s.batteryDrainFg / fgHours)) + } + if (fgHours >= 0.5) { + add(R.string.resource_usage_tile_data_rate to formatBytes(((s.mobileBytesFg + s.wifiBytesFg) / fgHours).toLong())) + } + val avgRelays = s.relayConnMs.toDouble() / (s.dayCount * ResourceUsageAccountant.DAY_MS) + if (avgRelays >= 0.5) { + add(R.string.resource_usage_tile_avg_relays to String.format(Locale.US, "%.1f", avgRelays)) + } + } + if (tiles.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + tiles.forEach { (label, value) -> StatTile(label, value, Modifier.weight(1f)) } + } +} + +/** + * Up to three plain-language recommendations, each deep-linking to the + * setting that acts on it — the rules live in [UsageInsights] so they are + * unit-testable and shared with nothing UI-bound. + */ +@Composable +private fun InsightsSection( + week: UsageSummary, + nav: INav, +) { + val insights = remember(week) { UsageInsights.evaluate(week) } + if (insights.isEmpty()) return + SettingsSection(R.string.resource_usage_insights_section) { + insights.forEachIndexed { index, insight -> + if (index > 0) SettingsDivider() + Column( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = insightText(insight), + style = MaterialTheme.typography.bodyMedium, + ) + TextButton( + onClick = { nav.nav(insightRoute(insight.target)) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } + } + } + } +} + +@Composable +private fun insightText(insight: UsageInsights.Insight): String = + when (insight.target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> + stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) + UsageInsights.Target.MEDIA_SETTINGS -> + stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) + UsageInsights.Target.RELAY_SETTINGS -> + stringRes(R.string.resource_usage_insight_relays, insight.value.toString()) + UsageInsights.Target.PRIVACY_SETTINGS -> + stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) + } + +private fun insightRoute(target: UsageInsights.Target): Route = + when (target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings + UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings + UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions + } + +@StringRes +private fun insightButton(target: UsageInsights.Target): Int = + when (target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button + UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media + UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy + } + +/** + * Ranked per-feature traffic with proportion bars (7 days). Cellular is the + * scarce resource (battery and often money), so when any cellular traffic + * exists the ranking, bars, and headline value are cellular — with the total + * as secondary context. Wi-Fi-only devices fall back to totals. + */ @Composable private fun SubsystemSection(week: UsageSummary) { + val cellular = week.mobileBytesPerSubsystem + if (cellular.isNotEmpty()) { + val rows = cellular.entries.sortedByDescending { it.value } + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_by_subsystem_cellular) { + rows.forEach { (subsystem, bytes) -> + BarRow( + label = subsystemLabel(subsystem), + value = stringRes(R.string.resource_usage_cell_of_total, formatBytes(bytes), formatBytes(week.bytesPerSubsystem[subsystem] ?: bytes)), + fraction = bytes.toFloat() / max.toFloat(), + ) + } + } + return + } if (week.bytesPerSubsystem.isEmpty()) return val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } val max = rows.first().value.coerceAtLeast(1L) @@ -213,6 +331,29 @@ private fun SubsystemSection(week: UsageSummary) { } } +/** + * Where the screen-on time went (7 days). Route base names only — the + * ledger never records which profile/hashtag/thread a screen showed. + */ +@Composable +private fun ScreenTimeSection(week: UsageSummary) { + if (week.screenTimeMs.isEmpty()) return + val rows = + week.screenTimeMs.entries + .sortedByDescending { it.value } + .take(6) + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_screen_time_section) { + rows.forEach { (name, ms) -> + BarRow( + label = name, + value = formatDurationMs(ms), + fraction = ms.toFloat() / max.toFloat(), + ) + } + } +} + @StringRes private fun subsystemLabel(subsystem: String): Int = when (subsystem) { @@ -227,13 +368,21 @@ private fun subsystemLabel(subsystem: String): Int = else -> R.string.resource_usage_subsystem_other } -/** Label + value + a thin rounded proportion bar underneath. */ @Composable private fun BarRow( @StringRes label: Int, value: String, fraction: Float, color: Color = MaterialTheme.colorScheme.primary, +) = BarRow(stringRes(label), value, fraction, color) + +/** Label + value + a thin rounded proportion bar underneath. */ +@Composable +private fun BarRow( + label: String, + value: String, + fraction: Float, + color: Color = MaterialTheme.colorScheme.primary, ) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), @@ -241,7 +390,7 @@ private fun BarRow( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringRes(label), + text = label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -449,6 +598,43 @@ private fun AlwaysOnServiceSection( } } +/** + * Cost card for in-app Tor — like the always-on card: what it cost this + * week and the settings surface that controls it. + */ +@Composable +private fun TorServiceSection( + s: UsageSummary, + nav: INav, +) { + if (s.torMs <= 0) return + val starts = s.torStarts.toInt() + SettingsSection(R.string.resource_usage_tor_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_tor_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MetricRow( + R.string.resource_usage_alwayson_uptime, + "${formatDurationMs(s.torMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}", + ) + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + TextButton( + onClick = { nav.nav(Route.PrivacyOptions) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_insight_button_privacy)) + } + } + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index df93f3236c..53ad796d07 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3227,6 +3227,22 @@ Relay connections held while closed Battery drained meanwhile (whole device) Change notification settings + What’s using your battery + Relay connections held while the app was closed are your largest background cost (%1$s). + %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. + The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. + Media settings + Edit relays + Privacy options + Battery / hour in app + Data / hour in app + Avg. relay connections + Cellular data by feature (7 days) + %1$s · %2$s total + Time by screen (7 days) + Built-in Tor + Routes the app’s traffic through the Tor network for privacy. While running it spends extra battery on encryption and keep-alive traffic, and every start pays a bootstrap. If your threat model allows, Tor use can be adjusted or turned off. Data by feature (7 days) Relay sync Images diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 6a2e216392..a4ae9b092a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -617,6 +617,164 @@ class MeteringNostrSignerTest { } } +class ScreenTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun routeNamesLoseTheirArgumentsBeforeAnythingIsRecorded() { + assertEquals("Profile", ScreenTimeIntegrator.screenNameOf("com.vitorpamplona.amethyst.ui.navigation.routes.Route.Profile/{userId}")) + assertEquals("Hashtag", ScreenTimeIntegrator.screenNameOf("routes.Route.Hashtag/{tag}?extra={extra}")) + assertEquals("Home", ScreenTimeIntegrator.screenNameOf("routes.Route.Home")) + assertNull(ScreenTimeIntegrator.screenNameOf(null)) + assertNull(ScreenTimeIntegrator.screenNameOf("")) + } + + @Test + fun accountsScreenTimeOnlyWhileForeground() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val isForeground = MutableStateFlow(true) + val integrator = ScreenTimeIntegrator(accountant, nowMs = { clock }) + integrator.start(backgroundScope, isForeground) + testScheduler.runCurrent() + + integrator.onScreen("Home") + testScheduler.runCurrent() + clock += 5_000 + integrator.onScreen("Video") + testScheduler.runCurrent() + clock += 3_000 + isForeground.value = false // backgrounded on Video: segment closes + testScheduler.runCurrent() + clock += 60_000 // background time must not count + isForeground.value = true + testScheduler.runCurrent() + clock += 2_000 + integrator.onScreen(null) + testScheduler.runCurrent() + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(5_000L, today[UsageKeys.screenMs("Home")]) + assertEquals(5_000L, today[UsageKeys.screenMs("Video")]) + } +} + +class UsageInsightsTest { + private fun summary( + counters: Map, + days: Int, + ) = UsageSummary.fromDays(List(days) { if (it == 0) counters else emptyMap() }) + + @Test + fun quietWeekYieldsNoInsights() { + val s = summary(mapOf(UsageKeys.APP_FG_MS to 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).isEmpty()) + } + + @Test + fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) + } + + @Test + fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { + val s = + summary( + mapOf(UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) + } + + @Test + fun cellularMediaSuggestsMediaSettingsButWifiDoesNot() { + val cellular = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertEquals(UsageInsights.Target.MEDIA_SETTINGS, UsageInsights.evaluate(cellular).first().target) + + val wifi = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(wifi).isEmpty()) + } + + @Test + fun manySimultaneousRelaysSuggestsEditingTheRelayList() { + // 40 relays open around the clock for a week. + val s = + summary( + mapOf(UsageKeys.relayConnMs(mobile = false, foreground = true) to 40L * 7L * 24L * 3_600_000L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_SETTINGS }) + } + + @Test + fun longTorUptimeSuggestsPrivacyOptions() { + val s = summary(mapOf(UsageKeys.TOR_MS to 7L * 5L * 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun thresholdsScaleWithTheNumberOfObservedDays() { + // 5 tor-hours looks fine over a week but heavy over a single day. + val counters = mapOf(UsageKeys.TOR_MS to 5L * 3_600_000L) + assertTrue(UsageInsights.evaluate(summary(counters, days = 7)).isEmpty()) + assertTrue(UsageInsights.evaluate(summary(counters, days = 1)).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun neverMoreThanThreeInsights() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, + UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, + ), + days = 7, + ) + assertEquals(UsageInsights.MAX_INSIGHTS, UsageInsights.evaluate(s).size) + } +} + +class UsageSummaryMapsTest { + @Test + fun screenTimeAndCellularMapsAreExtractedFromCounters() { + val s = + UsageSummary.from( + mapOf( + UsageKeys.screenMs("Home") to 10_000L, + UsageKeys.screenMs("Video") to 5_000L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 100L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 900L, + ), + ) + assertEquals(10_000L, s.screenTimeMs["Home"]) + assertEquals(5_000L, s.screenTimeMs["Video"]) + assertEquals(100L, s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + assertEquals(1_000L, s.bytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From 9d176edd1c278d771bae98151e4f1d91fb54656c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 20:23:16 +0000 Subject: [PATCH 19/39] =?UTF-8?q?fix:=20make=20usage=20insights=20accurate?= =?UTF-8?q?=20=E2=80=94=20impact=20ranking,=20honest=20attribution,=20miss?= =?UTF-8?q?ing=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accuracy fixes: - Insights are ranked by an estimated-impact score (rough hours of extra radio/CPU activity, ranking only, never displayed) before the cut to three — declaration order no longer decides which consumer gets dropped. - The notification insight states the measurement ('held connections open for N relay-hours in the background') instead of an unverified 'largest background cost' superlative, and only fires when the service's uptime covers at least half the background window — a service that ran 20 minutes can no longer be blamed for 30 hours of background connections. - When the measured drain split shows foreground use dominating (>=3x the background share with enough signal), an informational note leads the list saying most battery went to screen-on use — so nobody flips a setting expecting savings it cannot deliver. Completeness — new rules, each with a deep link: - PoW mining time -> compose settings (default difficulty), scored as full-tilt CPU. - Reconnect churn -> relay list (a flaky relay burns a handshake plus a radio wake-up per retry) — distinct diagnosis from 'too many relays'. - Push-processing wakelock time / process-start churn -> notification settings. Count strings converted to plurals per res/CLAUDE.md. Insight test suite extended to 15 cases covering the attribution gate, score ordering, the honesty note, and each new rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../service/resourceusage/UsageInsights.kt | 104 ++++++++++++++---- .../loggedIn/settings/ResourceUsageScreen.kt | 39 +++++-- amethyst/src/main/res/values/strings.xml | 14 ++- .../resourceusage/ResourceUsageLedgerTest.kt | 85 +++++++++++++- 4 files changed, 211 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt index 87811e2b30..c60e095cf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt @@ -21,30 +21,47 @@ package com.vitorpamplona.amethyst.service.resourceusage /** - * Turns a week of counters into at most [MAX_INSIGHTS] actionable + * Turns a multi-day summary into at most [MAX_INSIGHTS] actionable * recommendations, each mapping to a setting the user can actually change. * The numbers alone make the user the analyst; these rules encode the * analysis (thresholds informed by the 2026-07-12 ping study) and leave the * user only the decision. * - * Rules are ordered by typical battery impact; unlike [ResourceUsageAlerts] - * (which detects "something is wrong" and interrupts), insights render - * passively on the usage screen and use much lower thresholds — "worth - * knowing", not "pathological". + * Candidates that fire are ranked by an estimated-impact [score] — a rough + * conversion of each signal to "hours of extra radio/CPU activity" — so the + * cut to [MAX_INSIGHTS] drops the smallest consumers, not whichever rule + * happened to be declared last. Scores are order-of-magnitude heuristics for + * RANKING ONLY and are never shown to the user. + * + * When the measured battery split says foreground use dominates, a non-action + * [Target.FOREGROUND_INFO] note leads the list: most drain being screen-on + * time is the honest headline, and without it users would act on a minor + * insight expecting savings the settings can't deliver. + * + * Unlike [ResourceUsageAlerts] (which detects "something is wrong" and + * interrupts), insights render passively on the usage screen and use much + * lower thresholds — "worth knowing", not "pathological". */ object UsageInsights { /** Each target names the settings surface that can act on the insight. */ enum class Target { + /** Informational only — no button. Foreground/screen use dominates the measured drain. */ + FOREGROUND_INFO, NOTIFICATION_SETTINGS, MEDIA_SETTINGS, RELAY_SETTINGS, + RELAY_CHURN, + POW_SETTINGS, + PUSH_PROCESSING, PRIVACY_SETTINGS, } data class Insight( val target: Target, - /** ms for time-based insights, bytes for data, count for relays. */ + /** ms for time-based insights, bytes for data, counts for relays/restarts, percent for FOREGROUND_INFO. */ val value: Long, + /** Estimated impact in "hours of extra radio/CPU activity" — ranking only, never displayed. */ + val score: Double, ) /** @@ -53,46 +70,95 @@ object UsageInsights { */ fun evaluate(s: UsageSummary): List { val days = s.dayCount.coerceAtLeast(1) - val insights = mutableListOf() + val candidates = mutableListOf() - // Background relay connections dominate drain while the always-on - // service is in use — the one consumer with a dedicated off switch. + // Background relay connections, attributed to the always-on service + // ONLY when its uptime actually covers most of the background window — + // a service that ran 20 minutes cannot own 30 hours of background + // connections (long background audio sessions also hold relays). val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg - if (s.alwaysOnMs > 0 && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs) + val bgWallMs = (days * ResourceUsageAccountant.DAY_MS - s.foregroundMs).coerceAtLeast(1L) + val serviceCoversBackground = s.alwaysOnMs * 2 >= bgWallMs + if (serviceCoversBackground && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { + candidates += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs, score = hours(s.alwaysOnMs)) } // Cellular media: images/video/previews can be limited to Wi-Fi. + // Score: scattered mobile downloads pay radio ramp+tail per burst; + // ~60 MB of feed media ≈ an hour of radio-active time. val cellularMediaBytes = (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { - insights += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes) + candidates += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes, score = cellularMediaBytes / (60.0 * 1024 * 1024)) } - // Average simultaneous relay connections across the whole period: - // every open connection is server-pinged every 30-70s, so the radio - // never sleeps while they're up. Fewer relays = less radio time. + // Average simultaneous relay connections: every open connection is + // server-pinged every 30-70s. Score: marginal — the radio is often up + // anyway; extra relays add pings, duplicates, and handshakes. val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) if (avgRelays > AVG_RELAYS) { - insights += Insight(Target.RELAY_SETTINGS, avgRelays) + candidates += Insight(Target.RELAY_SETTINGS, avgRelays, score = hours(s.relayConnMs) / 100.0) } - // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + // Reconnect churn: a flaky relay in the list burns a TCP+TLS + // handshake plus a radio burst per retry. Score: ~15s of radio each. + val reconnects = s.relayConnects + s.relayConnectFails + if (reconnects > days * RECONNECTS_PER_DAY) { + candidates += Insight(Target.RELAY_CHURN, reconnects, score = reconnects * 15.0 / 3600.0) + } + + // NIP-13 mining: multi-core CPU flat out — roughly 3x the drain rate + // of an ordinary radio-active hour. + if (s.powMs > days * POW_MINUTES_PER_DAY * MS_PER_MINUTE) { + candidates += Insight(Target.POW_SETTINGS, s.powMs, score = hours(s.powMs) * 3.0) + } + + // Push processing: wakelock time plus process cold starts (each + // restart re-parses, re-connects, and pays a radio burst, ~10s each). + val pushScore = hours(s.wakelockMs) + s.appStarts * 10.0 / 3600.0 + if (s.wakelockMs > days * WAKELOCK_MINUTES_PER_DAY * MS_PER_MINUTE || s.appStarts > days * APP_STARTS_PER_DAY) { + candidates += Insight(Target.PUSH_PROCESSING, s.wakelockMs, score = pushScore) + } + + // In-app Tor: circuit crypto + keep-alives are overhead ON TOP of + // traffic that would exist anyway — count a fraction of its uptime. if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + candidates += Insight(Target.PRIVACY_SETTINGS, s.torMs, score = hours(s.torMs) * 0.3) } - return insights.take(MAX_INSIGHTS) + val ranked = candidates.sortedByDescending { it.score }.take(MAX_INSIGHTS) + + // Honesty note: when the measured split says the battery went to + // screen-on use, say so first — settings mainly reduce the background + // share, and the user deserves to know how big that share is. + val fgDominates = + s.batteryDrainFg >= MIN_DRAIN_SIGNAL_PCT && + s.batteryDrainFg >= 3 * s.batteryDrainBg.coerceAtLeast(1L) + return if (fgDominates) { + listOf(Insight(Target.FOREGROUND_INFO, s.batteryDrainFg, score = 0.0)) + ranked + } else { + ranked + } } + private fun hours(ms: Long): Double = ms / MS_PER_HOUR.toDouble() + const val MAX_INSIGHTS = 3 private const val MS_PER_HOUR = 60L * 60L * 1000L + private const val MS_PER_MINUTE = 60L * 1000L // Per-day thresholds — deliberately well below the alert levels. const val BG_RELAY_HOURS_PER_DAY = 3L const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L const val AVG_RELAYS = 25L + const val RECONNECTS_PER_DAY = 500L + const val POW_MINUTES_PER_DAY = 10L + const val WAKELOCK_MINUTES_PER_DAY = 5L + const val APP_STARTS_PER_DAY = 15L const val TOR_HOURS_PER_DAY = 4L + + /** Foreground drain below this many percent points is too noisy to call a trend. */ + const val MIN_DRAIN_SIGNAL_PCT = 5L } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 2e9f97683b..bd974db39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -253,11 +253,14 @@ private fun InsightsSection( text = insightText(insight), style = MaterialTheme.typography.bodyMedium, ) - TextButton( - onClick = { nav.nav(insightRoute(insight.target)) }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringRes(insightButton(insight.target))) + val route = insightRoute(insight.target) + if (route != null) { + TextButton( + onClick = { nav.nav(route) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } } } } @@ -267,30 +270,50 @@ private fun InsightsSection( @Composable private fun insightText(insight: UsageInsights.Insight): String = when (insight.target) { + UsageInsights.Target.FOREGROUND_INFO -> + stringRes(R.string.resource_usage_insight_foreground, insight.value.toString()) UsageInsights.Target.NOTIFICATION_SETTINGS -> stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) UsageInsights.Target.MEDIA_SETTINGS -> stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) - UsageInsights.Target.RELAY_SETTINGS -> - stringRes(R.string.resource_usage_insight_relays, insight.value.toString()) + UsageInsights.Target.RELAY_SETTINGS -> { + val relays = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_relays, relays, relays) + } + UsageInsights.Target.RELAY_CHURN -> { + val reconnects = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_churn, reconnects, reconnects) + } + UsageInsights.Target.POW_SETTINGS -> + stringRes(R.string.resource_usage_insight_pow, formatDurationMs(insight.value)) + UsageInsights.Target.PUSH_PROCESSING -> + stringRes(R.string.resource_usage_insight_push, formatDurationMs(insight.value)) UsageInsights.Target.PRIVACY_SETTINGS -> stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) } -private fun insightRoute(target: UsageInsights.Target): Route = +private fun insightRoute(target: UsageInsights.Target): Route? = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> null UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.RELAY_CHURN -> Route.EditRelays + UsageInsights.Target.POW_SETTINGS -> Route.ComposeSettings + UsageInsights.Target.PUSH_PROCESSING -> Route.NotificationSettings UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions } @StringRes private fun insightButton(target: UsageInsights.Target): Int = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> R.string.resource_usage_insights_section UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.RELAY_CHURN -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.POW_SETTINGS -> R.string.compose_settings + UsageInsights.Target.PUSH_PROCESSING -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 53ad796d07..5067aaf011 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3228,9 +3228,19 @@ Battery drained meanwhile (whole device) Change notification settings What’s using your battery - Relay connections held while the app was closed are your largest background cost (%1$s). + The always-on notification service held relay connections open for %1$s while the app was in the background. + Most of your measured battery use (%1$s%%) happened while you were actively using the app — screen and browsing, not background activity. Settings changes mainly reduce the background share. + + Relays reconnected %1$d time. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + Relays reconnected %1$d times. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + + Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly. + Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it. %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. - The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + + The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. Media settings Edit relays diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index a4ae9b092a..e972982b3d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -676,10 +676,11 @@ class UsageInsightsTest { @Test fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + // Service ran 100 of the ~168 background hours: it owns the connections. val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 100L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, ), days = 7, @@ -688,6 +689,86 @@ class UsageInsightsTest { assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) } + @Test + fun briefServiceUptimeIsNotBlamedForBackgroundConnectionsItCannotOwn() { + // Service ran 1h all week; 28h of background connections came from + // something else (e.g. background audio) — no notification insight. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 1L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) + } + + @Test + fun insightsAreRankedByEstimatedImpactNotDeclarationOrder() { + // PoW at 100h scores ~300; the notification insight scores ~150 — + // PoW must come first even though its rule is declared later. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 150L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + UsageKeys.POW_MS to 100L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.POW_SETTINGS, insights[0].target) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights[1].target) + } + + @Test + fun powMiningTimeSuggestsComposeSettings() { + val s = summary(mapOf(UsageKeys.POW_MS to 7L * 15L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.POW_SETTINGS }) + } + + @Test + fun reconnectChurnSuggestsReviewingTheRelayList() { + val s = summary(mapOf(UsageKeys.relayConnects(mobile = true, foreground = false) to 7L * 600L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_CHURN }) + } + + @Test + fun pushProcessingTimeSuggestsNotificationSettings() { + val s = summary(mapOf(UsageKeys.WAKELOCK_NOTIF_MS to 7L * 10L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PUSH_PROCESSING }) + } + + @Test + fun foregroundDominatedDrainLeadsWithTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 20L, + UsageKeys.BATTERY_DRAIN_BG to 2L, + UsageKeys.TOR_MS to 7L * 5L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.FOREGROUND_INFO, insights.first().target) + assertTrue(insights.any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun balancedDrainDoesNotAddTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 10L, + UsageKeys.BATTERY_DRAIN_BG to 8L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.FOREGROUND_INFO }) + } + @Test fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { val s = @@ -745,7 +826,7 @@ class UsageInsightsTest { val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 168L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, From b2456f2051ab1ec3be123a91cd62b4a0ade8422d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:42:56 +0000 Subject: [PATCH 20/39] revert: remove the 'What's using your battery' insights section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-focused recommendations were built on a wrong premise: with the outbox model, the number of open connections and the reconnect churn are driven by the relays the user's FOLLOWS publish to, not by the user's own relay list — so 'Edit relays' steered users at a lever that doesn't control the number. Rather than ship a recommendations engine whose headline advice is misleading, the section, the UsageInsights rules, their tests, and their strings are removed. The measured data itself (rates tiles, cost cards, subsystem bars, screen time) stays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../service/resourceusage/UsageInsights.kt | 164 ---------------- .../loggedIn/settings/ResourceUsageScreen.kt | 91 +-------- amethyst/src/main/res/values/strings.xml | 19 +- .../resourceusage/ResourceUsageLedgerTest.kt | 175 ------------------ 4 files changed, 2 insertions(+), 447 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt deleted file mode 100644 index c60e095cf6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt +++ /dev/null @@ -1,164 +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.service.resourceusage - -/** - * Turns a multi-day summary into at most [MAX_INSIGHTS] actionable - * recommendations, each mapping to a setting the user can actually change. - * The numbers alone make the user the analyst; these rules encode the - * analysis (thresholds informed by the 2026-07-12 ping study) and leave the - * user only the decision. - * - * Candidates that fire are ranked by an estimated-impact [score] — a rough - * conversion of each signal to "hours of extra radio/CPU activity" — so the - * cut to [MAX_INSIGHTS] drops the smallest consumers, not whichever rule - * happened to be declared last. Scores are order-of-magnitude heuristics for - * RANKING ONLY and are never shown to the user. - * - * When the measured battery split says foreground use dominates, a non-action - * [Target.FOREGROUND_INFO] note leads the list: most drain being screen-on - * time is the honest headline, and without it users would act on a minor - * insight expecting savings the settings can't deliver. - * - * Unlike [ResourceUsageAlerts] (which detects "something is wrong" and - * interrupts), insights render passively on the usage screen and use much - * lower thresholds — "worth knowing", not "pathological". - */ -object UsageInsights { - /** Each target names the settings surface that can act on the insight. */ - enum class Target { - /** Informational only — no button. Foreground/screen use dominates the measured drain. */ - FOREGROUND_INFO, - NOTIFICATION_SETTINGS, - MEDIA_SETTINGS, - RELAY_SETTINGS, - RELAY_CHURN, - POW_SETTINGS, - PUSH_PROCESSING, - PRIVACY_SETTINGS, - } - - data class Insight( - val target: Target, - /** ms for time-based insights, bytes for data, counts for relays/restarts, percent for FOREGROUND_INFO. */ - val value: Long, - /** Estimated impact in "hours of extra radio/CPU activity" — ranking only, never displayed. */ - val score: Double, - ) - - /** - * Evaluates a multi-day summary ([UsageSummary.dayCount] normalizes the - * thresholds, so a 2-day-old install is judged on 2 days, not 7). - */ - fun evaluate(s: UsageSummary): List { - val days = s.dayCount.coerceAtLeast(1) - val candidates = mutableListOf() - - // Background relay connections, attributed to the always-on service - // ONLY when its uptime actually covers most of the background window — - // a service that ran 20 minutes cannot own 30 hours of background - // connections (long background audio sessions also hold relays). - val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg - val bgWallMs = (days * ResourceUsageAccountant.DAY_MS - s.foregroundMs).coerceAtLeast(1L) - val serviceCoversBackground = s.alwaysOnMs * 2 >= bgWallMs - if (serviceCoversBackground && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { - candidates += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs, score = hours(s.alwaysOnMs)) - } - - // Cellular media: images/video/previews can be limited to Wi-Fi. - // Score: scattered mobile downloads pay radio ramp+tail per burst; - // ~60 MB of feed media ≈ an hour of radio-active time. - val cellularMediaBytes = - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) - if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { - candidates += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes, score = cellularMediaBytes / (60.0 * 1024 * 1024)) - } - - // Average simultaneous relay connections: every open connection is - // server-pinged every 30-70s. Score: marginal — the radio is often up - // anyway; extra relays add pings, duplicates, and handshakes. - val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) - if (avgRelays > AVG_RELAYS) { - candidates += Insight(Target.RELAY_SETTINGS, avgRelays, score = hours(s.relayConnMs) / 100.0) - } - - // Reconnect churn: a flaky relay in the list burns a TCP+TLS - // handshake plus a radio burst per retry. Score: ~15s of radio each. - val reconnects = s.relayConnects + s.relayConnectFails - if (reconnects > days * RECONNECTS_PER_DAY) { - candidates += Insight(Target.RELAY_CHURN, reconnects, score = reconnects * 15.0 / 3600.0) - } - - // NIP-13 mining: multi-core CPU flat out — roughly 3x the drain rate - // of an ordinary radio-active hour. - if (s.powMs > days * POW_MINUTES_PER_DAY * MS_PER_MINUTE) { - candidates += Insight(Target.POW_SETTINGS, s.powMs, score = hours(s.powMs) * 3.0) - } - - // Push processing: wakelock time plus process cold starts (each - // restart re-parses, re-connects, and pays a radio burst, ~10s each). - val pushScore = hours(s.wakelockMs) + s.appStarts * 10.0 / 3600.0 - if (s.wakelockMs > days * WAKELOCK_MINUTES_PER_DAY * MS_PER_MINUTE || s.appStarts > days * APP_STARTS_PER_DAY) { - candidates += Insight(Target.PUSH_PROCESSING, s.wakelockMs, score = pushScore) - } - - // In-app Tor: circuit crypto + keep-alives are overhead ON TOP of - // traffic that would exist anyway — count a fraction of its uptime. - if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { - candidates += Insight(Target.PRIVACY_SETTINGS, s.torMs, score = hours(s.torMs) * 0.3) - } - - val ranked = candidates.sortedByDescending { it.score }.take(MAX_INSIGHTS) - - // Honesty note: when the measured split says the battery went to - // screen-on use, say so first — settings mainly reduce the background - // share, and the user deserves to know how big that share is. - val fgDominates = - s.batteryDrainFg >= MIN_DRAIN_SIGNAL_PCT && - s.batteryDrainFg >= 3 * s.batteryDrainBg.coerceAtLeast(1L) - return if (fgDominates) { - listOf(Insight(Target.FOREGROUND_INFO, s.batteryDrainFg, score = 0.0)) + ranked - } else { - ranked - } - } - - private fun hours(ms: Long): Double = ms / MS_PER_HOUR.toDouble() - - const val MAX_INSIGHTS = 3 - private const val MS_PER_HOUR = 60L * 60L * 1000L - private const val MS_PER_MINUTE = 60L * 1000L - - // Per-day thresholds — deliberately well below the alert levels. - const val BG_RELAY_HOURS_PER_DAY = 3L - const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L - const val AVG_RELAYS = 25L - const val RECONNECTS_PER_DAY = 500L - const val POW_MINUTES_PER_DAY = 10L - const val WAKELOCK_MINUTES_PER_DAY = 5L - const val APP_STARTS_PER_DAY = 15L - const val TOR_HOURS_PER_DAY = 4L - - /** Foreground drain below this many percent points is too noisy to call a trend. */ - const val MIN_DRAIN_SIGNAL_PCT = 5L -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index bd974db39b..4e841ad4ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs -import com.vitorpamplona.amethyst.service.resourceusage.UsageInsights import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -140,7 +139,6 @@ fun ResourceUsageScreen( TodayTiles(todaySummary) WeekRatesTiles(weekSummary) - InsightsSection(weekSummary, nav) if (weekSummary.totalBytes > 0) { SettingsSection(R.string.resource_usage_trend_section) { UsageTrendChart(loaded, today) @@ -230,93 +228,6 @@ private fun WeekRatesTiles(s: UsageSummary) { } } -/** - * Up to three plain-language recommendations, each deep-linking to the - * setting that acts on it — the rules live in [UsageInsights] so they are - * unit-testable and shared with nothing UI-bound. - */ -@Composable -private fun InsightsSection( - week: UsageSummary, - nav: INav, -) { - val insights = remember(week) { UsageInsights.evaluate(week) } - if (insights.isEmpty()) return - SettingsSection(R.string.resource_usage_insights_section) { - insights.forEachIndexed { index, insight -> - if (index > 0) SettingsDivider() - Column( - modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = insightText(insight), - style = MaterialTheme.typography.bodyMedium, - ) - val route = insightRoute(insight.target) - if (route != null) { - TextButton( - onClick = { nav.nav(route) }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringRes(insightButton(insight.target))) - } - } - } - } - } -} - -@Composable -private fun insightText(insight: UsageInsights.Insight): String = - when (insight.target) { - UsageInsights.Target.FOREGROUND_INFO -> - stringRes(R.string.resource_usage_insight_foreground, insight.value.toString()) - UsageInsights.Target.NOTIFICATION_SETTINGS -> - stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) - UsageInsights.Target.MEDIA_SETTINGS -> - stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) - UsageInsights.Target.RELAY_SETTINGS -> { - val relays = insight.value.toInt() - pluralStringResource(R.plurals.resource_usage_insight_relays, relays, relays) - } - UsageInsights.Target.RELAY_CHURN -> { - val reconnects = insight.value.toInt() - pluralStringResource(R.plurals.resource_usage_insight_churn, reconnects, reconnects) - } - UsageInsights.Target.POW_SETTINGS -> - stringRes(R.string.resource_usage_insight_pow, formatDurationMs(insight.value)) - UsageInsights.Target.PUSH_PROCESSING -> - stringRes(R.string.resource_usage_insight_push, formatDurationMs(insight.value)) - UsageInsights.Target.PRIVACY_SETTINGS -> - stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) - } - -private fun insightRoute(target: UsageInsights.Target): Route? = - when (target) { - UsageInsights.Target.FOREGROUND_INFO -> null - UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings - UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings - UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays - UsageInsights.Target.RELAY_CHURN -> Route.EditRelays - UsageInsights.Target.POW_SETTINGS -> Route.ComposeSettings - UsageInsights.Target.PUSH_PROCESSING -> Route.NotificationSettings - UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions - } - -@StringRes -private fun insightButton(target: UsageInsights.Target): Int = - when (target) { - UsageInsights.Target.FOREGROUND_INFO -> R.string.resource_usage_insights_section - UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button - UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media - UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays - UsageInsights.Target.RELAY_CHURN -> R.string.resource_usage_insight_button_relays - UsageInsights.Target.POW_SETTINGS -> R.string.compose_settings - UsageInsights.Target.PUSH_PROCESSING -> R.string.resource_usage_alwayson_settings_button - UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy - } - /** * Ranked per-feature traffic with proportion bars (7 days). Cellular is the * scarce resource (battery and often money), so when any cellular traffic @@ -652,7 +563,7 @@ private fun TorServiceSection( onClick = { nav.nav(Route.PrivacyOptions) }, modifier = Modifier.align(Alignment.End), ) { - Text(stringRes(R.string.resource_usage_insight_button_privacy)) + Text(stringRes(R.string.resource_usage_tor_settings_button)) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5067aaf011..5be4510e0f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3227,24 +3227,7 @@ Relay connections held while closed Battery drained meanwhile (whole device) Change notification settings - What’s using your battery - The always-on notification service held relay connections open for %1$s while the app was in the background. - Most of your measured battery use (%1$s%%) happened while you were actively using the app — screen and browsing, not background activity. Settings changes mainly reduce the background share. - - Relays reconnected %1$d time. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. - Relays reconnected %1$d times. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. - - Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly. - Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it. - %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. - - The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery. - The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. - - Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. - Media settings - Edit relays - Privacy options + Privacy options Battery / hour in app Data / hour in app Avg. relay connections diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index e972982b3d..6be24d2942 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -662,181 +662,6 @@ class ScreenTimeIntegratorTest { } } -class UsageInsightsTest { - private fun summary( - counters: Map, - days: Int, - ) = UsageSummary.fromDays(List(days) { if (it == 0) counters else emptyMap() }) - - @Test - fun quietWeekYieldsNoInsights() { - val s = summary(mapOf(UsageKeys.APP_FG_MS to 3_600_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).isEmpty()) - } - - @Test - fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { - // Service ran 100 of the ~168 background hours: it owns the connections. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 100L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) - } - - @Test - fun briefServiceUptimeIsNotBlamedForBackgroundConnectionsItCannotOwn() { - // Service ran 1h all week; 28h of background connections came from - // something else (e.g. background audio) — no notification insight. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 1L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - ), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) - } - - @Test - fun insightsAreRankedByEstimatedImpactNotDeclarationOrder() { - // PoW at 100h scores ~300; the notification insight scores ~150 — - // PoW must come first even though its rule is declared later. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 150L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - UsageKeys.POW_MS to 100L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.POW_SETTINGS, insights[0].target) - assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights[1].target) - } - - @Test - fun powMiningTimeSuggestsComposeSettings() { - val s = summary(mapOf(UsageKeys.POW_MS to 7L * 15L * 60_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.POW_SETTINGS }) - } - - @Test - fun reconnectChurnSuggestsReviewingTheRelayList() { - val s = summary(mapOf(UsageKeys.relayConnects(mobile = true, foreground = false) to 7L * 600L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_CHURN }) - } - - @Test - fun pushProcessingTimeSuggestsNotificationSettings() { - val s = summary(mapOf(UsageKeys.WAKELOCK_NOTIF_MS to 7L * 10L * 60_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PUSH_PROCESSING }) - } - - @Test - fun foregroundDominatedDrainLeadsWithTheHonestyNote() { - val s = - summary( - mapOf( - UsageKeys.BATTERY_DRAIN_FG to 20L, - UsageKeys.BATTERY_DRAIN_BG to 2L, - UsageKeys.TOR_MS to 7L * 5L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.FOREGROUND_INFO, insights.first().target) - assertTrue(insights.any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun balancedDrainDoesNotAddTheHonestyNote() { - val s = - summary( - mapOf( - UsageKeys.BATTERY_DRAIN_FG to 10L, - UsageKeys.BATTERY_DRAIN_BG to 8L, - ), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.FOREGROUND_INFO }) - } - - @Test - fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { - val s = - summary( - mapOf(UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) - } - - @Test - fun cellularMediaSuggestsMediaSettingsButWifiDoesNot() { - val cellular = - summary( - mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), - days = 7, - ) - assertEquals(UsageInsights.Target.MEDIA_SETTINGS, UsageInsights.evaluate(cellular).first().target) - - val wifi = - summary( - mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(wifi).isEmpty()) - } - - @Test - fun manySimultaneousRelaysSuggestsEditingTheRelayList() { - // 40 relays open around the clock for a week. - val s = - summary( - mapOf(UsageKeys.relayConnMs(mobile = false, foreground = true) to 40L * 7L * 24L * 3_600_000L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_SETTINGS }) - } - - @Test - fun longTorUptimeSuggestsPrivacyOptions() { - val s = summary(mapOf(UsageKeys.TOR_MS to 7L * 5L * 3_600_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun thresholdsScaleWithTheNumberOfObservedDays() { - // 5 tor-hours looks fine over a week but heavy over a single day. - val counters = mapOf(UsageKeys.TOR_MS to 5L * 3_600_000L) - assertTrue(UsageInsights.evaluate(summary(counters, days = 7)).isEmpty()) - assertTrue(UsageInsights.evaluate(summary(counters, days = 1)).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun neverMoreThanThreeInsights() { - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 168L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, - UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, - UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, - ), - days = 7, - ) - assertEquals(UsageInsights.MAX_INSIGHTS, UsageInsights.evaluate(s).size) - } -} - class UsageSummaryMapsTest { @Test fun screenTimeAndCellularMapsAreExtractedFromCounters() { From 86da29fa788522451b753c9108a5c0d486ba6419 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 01:26:33 +0000 Subject: [PATCH 21/39] fix(ui): mute the header pill wash, restore dotted timestamps, tighten menu gap - HeaderPill's secondary-container background pulled too much attention; it now uses a faint onSurface wash (7%) with placeholderText content, so pills read as tappable metadata without competing with the note. - Note-header timestamps go back to the original dotted format at the default size; the TimeAgo style/fontSize params are reverted. - The timestamp + more-options pair renders unspaced again (the dot and the button's icon inset provide the separation), fixing the oversized gap the row-level spacedBy introduced before the 3-dot menu. - The header preview now consumes its fabricated events as already-verified (they cannot pass id/sig checks, which left every row bare), gives the repost a parseable inner event, and fetches the draft through its AddressableNote (draft wraps are addressable events). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 16 +++++----- .../note/elements/NoteHeaderMarkersPreview.kt | 30 +++++++++++-------- .../amethyst/ui/note/elements/TimeAgo.kt | 17 +++-------- .../loggedIn/threadview/ThreadFeedView.kt | 10 ++++--- .../amethyst/commons/ui/note/HeaderPill.kt | 15 ++++++---- 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 579844d990..88b1c511eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -207,7 +206,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDi import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -1898,12 +1896,16 @@ fun FirstUserInfoRow( JumpToParentReplyButton(baseNote, accountViewModel, nav) - TimeAgo(baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) + // The dotted TimeAgo carries its own " • " separator and the options + // button has slack around its icon, so this pair stays unspaced. + Row(verticalAlignment = CenterVertically) { + TimeAgo(baseNote) - if (moreOptions == null) { - MoreOptionsButton(baseNote, editState, accountViewModel, nav) - } else { - moreOptions() + if (moreOptions == null) { + MoreOptionsButton(baseNote, editState, accountViewModel, nav) + } else { + moreOptions() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 02438cf59a..432e585803 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -110,30 +110,36 @@ fun NoteHeaderFirstRowDensityPreview() { val geoPowExpiring: Note val kitchenSink: Note + val plainEvent = TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x") + + // Drafts are addressable: consumption lands on the AddressableNote, so the + // preview must fetch it by address rather than by event id. + val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume( MetadataEvent(AUTHOR_METADATA_ID, AUTHOR, now, emptyArray(), """{"name":"Vitor"}""", "x"), null, - false, + true, ) LocalCache.justConsume( MetadataEvent(LONG_NAME_METADATA_ID, LONG_NAME_AUTHOR, now, emptyArray(), """{"name":"A user with a very long display name"}""", "x"), null, - false, + true, ) - LocalCache.justConsume(TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x"), null, false) - LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, false) - LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), "", "x"), null, false) + LocalCache.justConsume(plainEvent, null, true) + LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, true) + LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), plainEvent.toJson(), "x"), null, true) // An empty sig is what marks a note as a private rumor. - LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, false) - LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, false) - LocalCache.justConsume(DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, emptyArray(), "", "x"), null, false) + LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, true) + LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, true) + LocalCache.justConsume(draftEvent, null, true) LocalCache.justConsume( TextNoteEvent(COMMUNITY_POST_ID, AUTHOR, now - 300, arrayOf(arrayOf("a", COMMUNITY_ADDRESS)), "hello community", "x"), null, - false, + true, ) LocalCache.justConsume( TextNoteEvent( @@ -149,7 +155,7 @@ fun NoteHeaderFirstRowDensityPreview() { "x", ), null, - false, + true, ) LocalCache.justConsume( TextNoteEvent( @@ -168,7 +174,7 @@ fun NoteHeaderFirstRowDensityPreview() { "", ), null, - false, + true, ) plain = LocalCache.getOrCreateNote(PLAIN_ID) @@ -176,7 +182,7 @@ fun NoteHeaderFirstRowDensityPreview() { rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) edited = LocalCache.getOrCreateNote(EDITED_ID) editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) - draft = LocalCache.getOrCreateNote(DRAFT_ID) + draft = LocalCache.getOrCreateAddressableNote(draftEvent.address()) communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index af77ed2b82..c23fd2d41c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -125,25 +125,16 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo( - note: Note, - style: TimeAgoStyle = TimeAgoStyle.Dotted, - fontSize: TextUnit = TextUnit.Unspecified, -) { +fun TimeAgo(note: Note) { val time = note.createdAt() ?: return - TimeAgo(time, style, fontSize) + TimeAgo(time) } @Composable -fun TimeAgo( - time: Long, - style: TimeAgoStyle = TimeAgoStyle.Dotted, - fontSize: TextUnit = TextUnit.Unspecified, -) { +fun TimeAgo(time: Long) { ToggleableTimeAgoText( timestamp = time, - style = style, - fontSize = fontSize, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 11c461fee7..f2762f30bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -127,7 +127,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -232,7 +231,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -743,9 +741,13 @@ private fun FullBleedNoteCompose( Expiration(baseNote) - TimeAgo(note = baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) + // The dotted TimeAgo carries its own " • " separator and the + // options button has slack around its icon: keep the pair unspaced. + Row(verticalAlignment = Alignment.CenterVertically) { + TimeAgo(note = baseNote) - MoreOptionsButton(baseNote, editState, accountViewModel, nav) + MoreOptionsButton(baseNote, editState, accountViewModel, nav) + } } Row( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 6c4dc4726b..312ca0d584 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -37,12 +37,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText /** * Compact squared chip for note-header metadata (PoW, OTS, location, relay - * hosts, ...). Matches the relay information pills used in the NIP-29 chat - * headers so all header badges share one look: a small icon plus a short - * label on a secondary-container surface with slightly rounded corners. + * hosts, ...) so all header badges share one look: a small icon plus a short + * label on a faint surface tint with slightly rounded corners. The wash is + * deliberately subtle — just enough of an outline to read as tappable + * metadata without competing with the note content. */ @Composable fun HeaderPill( @@ -55,7 +57,8 @@ fun HeaderPill( ) { Surface( shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + contentColor = MaterialTheme.colorScheme.placeholderText, modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, ) { Row( @@ -66,13 +69,13 @@ fun HeaderPill( Icon( symbol = symbol, contentDescription = contentDescription, - tint = iconTint ?: MaterialTheme.colorScheme.onSecondaryContainer, + tint = iconTint ?: MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(11.dp), ) Text( text = text, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) From efae1dec22023378e486c67bed7b402f848cbc4b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 08:21:44 -0400 Subject: [PATCH 22/39] Adjustments --- .../amethyst/ui/note/NoteCompose.kt | 42 +++++++++---------- .../note/elements/NoteHeaderMarkersPreview.kt | 31 +++++--------- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 88b1c511eb..041d3b37ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1834,6 +1834,16 @@ fun FirstUserInfoRow( NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } + if (isRepost) { + BoostedMark() + } + + CheckAndDisplayEditStatus(editState) + + if (isDraft) { + DisplayDraft() + } + if (isDraft) { ObserveDraftEvent(baseNote, accountViewModel) { draftNote -> val isCommunityPost = @@ -1860,16 +1870,22 @@ fun FirstUserInfoRow( } } - if (isRepost) { - BoostedMark() + val noteEvent = baseNote.event + + val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } + if (geo != null) { + DisplayLocation(geo, accountViewModel, nav) } - CheckAndDisplayEditStatus(editState) + DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) - if (isDraft) { - DisplayDraft() + val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } + if (pow != null) { + DisplayPoW(pow) } + Expiration(baseNote) + if (baseNote.isPrivateRumor()) { PrivateRumorMark() } @@ -1878,22 +1894,6 @@ fun FirstUserInfoRow( PinnedMark() } - val noteEvent = baseNote.event - - val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } - if (geo != null) { - DisplayLocation(geo, accountViewModel, nav) - } - - val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } - if (pow != null) { - DisplayPoW(pow) - } - - DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) - - Expiration(baseNote) - JumpToParentReplyButton(baseNote, accountViewModel, nav) // The dotted TimeAgo carries its own " • " separator and the options diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 432e585803..5ea22ae855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction.repost import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache @@ -100,22 +101,22 @@ fun NoteHeaderFirstRowDensityPreview() { val now = TimeUtils.now() val expiresAt = (now + 7200).toString() - val plain: Note - val repost: Note - val rumorPinned: Note - val edited: Note - val editVersion: Note - val draft: Note - val communityPost: Note - val geoPowExpiring: Note - val kitchenSink: Note - val plainEvent = TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x") // Drafts are addressable: consumption lands on the AddressableNote, so the // preview must fetch it by address rather than by event id. val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") + val plain: Note = LocalCache.getOrCreateNote(PLAIN_ID) + val repost: Note = LocalCache.getOrCreateNote(REPOST_ID) + val rumorPinned: Note = LocalCache.getOrCreateNote(RUMOR_ID) + val edited: Note = LocalCache.getOrCreateNote(EDITED_ID) + val editVersion: Note = LocalCache.getOrCreateNote(EDIT_VERSION_ID) + val draft: Note = LocalCache.getOrCreateAddressableNote(draftEvent.address()) + val communityPost: Note = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) + val geoPowExpiring: Note = LocalCache.getOrCreateNote(POW_ID) + val kitchenSink: Note = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume( @@ -176,16 +177,6 @@ fun NoteHeaderFirstRowDensityPreview() { null, true, ) - - plain = LocalCache.getOrCreateNote(PLAIN_ID) - repost = LocalCache.getOrCreateNote(REPOST_ID) - rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) - edited = LocalCache.getOrCreateNote(EDITED_ID) - editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) - draft = LocalCache.getOrCreateAddressableNote(draftEvent.address()) - communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) - geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) - kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) } } From 41ef1d89b3856736a50999b0ac24ae87c4e5a687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:59:30 +0000 Subject: [PATCH 23/39] feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill Bake the official OpenTimestamps stamp logo (traced from opentimestamps/logo vector.svg; monochrome outline, tinted at render time like every other glyph) into the Material Symbols subset font at U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts the traced SVGs in custom/ into TrueType glyphs and is invoked by subset.sh after pyftsubset, so font regenerations keep them. With the logo identifying the pill, drop the verbose "OTS:" prefix: the pill now reads icon + "2y" (or icon + "Pending", new R.string.pending). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/elements/DisplayOts.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../font/material_symbols_outlined.ttf | Bin 464004 -> 466160 bytes .../commons/icons/symbols/MaterialSymbols.kt | 6 + tools/material-symbols-subset/README.md | 12 ++ .../add_custom_glyphs.py | 104 ++++++++++++++ .../custom/opentimestamps.svg | 130 ++++++++++++++++++ tools/material-symbols-subset/subset.sh | 4 + 8 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 tools/material-symbols-subset/add_custom_glyphs.py create mode 100644 tools/material-symbols-subset/custom/opentimestamps.svg diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 319293d69c..ea62ebdb9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -61,8 +61,8 @@ fun DisplayOts( } HeaderPill( - symbol = MaterialSymbols.CheckCircle, - text = stringRes(R.string.existed_since, timeStr), + symbol = MaterialSymbols.OpenTimestamps, + text = timeStr, contentDescription = stringRes(R.string.ots_info_title), onClick = { accountViewModel.toastManager.toast( @@ -75,8 +75,8 @@ fun DisplayOts( }, whenPending = { HeaderPill( - symbol = MaterialSymbols.Schedule, - text = stringRes(id = R.string.timestamp_pending_short), + symbol = MaterialSymbols.OpenTimestamps, + text = stringRes(id = R.string.pending), contentDescription = stringRes(R.string.ots_info_title), ) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a3ef6e6b7d..71c5cba0c2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,6 +45,7 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending + Pending Request Deletion Block / Report diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 01bd58472eab53e9922f2ac64092cd463a60370c..fde397e9efc2d552e8205b3b0ec61759f5f3a6df 100644 GIT binary patch delta 2930 zcmY*bYlu|W9Y62KJ@?#uXXeh#x-Z>_JIQ9N>+C!rQ4*V)q@)j%Xt9N2m$+(ZR+qS~ zrmyM4#)U{LEG7LILMW6#5VkZa{h&zFQXAUlL+O`N8d0&0QlSmxL#X|oJFcep%(?gc zpTGa>EPL|j!#965e5TgB`Vk@Y^kZMx`Q(c)t?VM?+6p0j@bgb?ZSH(x|3i@dlo0Zj zr}pgJbL!^uON6}tD(JW7zdrvQiT`nykn4v*?|yo5ere&`7f%u*t`VXx9)$eGe=oic z`J0emKQw=A3Ezy6tN+2*9D4TogBSjM_K&1T5;#0}{tyg)D8IFlkQ=WOwe>TH7v>Li ze|zQv0vLut=P(%hk{$!S2lV9O#pM(GUfX*U^oNA7o6jD7dj7quwM&G!Uj%w_{=^bJ z&Fhe#fc~Z<^NR~#dh&bk5b}ouNHAVHdTjZ`Urv3uN65Q~ta_4AsId;mgk#d_`EG@W4)Ny7h991oE~#9M>Njbm(zjBYD9JNbAq) z*564(1V2yD5V=}ks_~Y`#Okzgl{GhS7b%$?vC+(8t#0cfWKyG_gPa(AhxI zEya6=c3?g^O8dD4K|;$x6dsxsbOx0={RreVDo}IH zfY(3Pwv7jV0ImiP&H8K+NFgosv}yx5Sm->25Duz68<>3}-&SY@a@%YOFPwrBfa-Kp!a|JojI&-2N4C?_yi2#r~tTY0A?GjF$H<(m#Sii z72LH8s5=t$HE@GPW2#q}gi=?Feg|#Hrdb^<@b2^y@NFd4>>#&V6LXN~H8ckp(-QH9 zP#RNH0TSHMv0A-8*9>W4ZD;N6Y^unuF*_~bJFiv)WW85|yc@EC{rJ?x1i%{st0yk@Ok^toFA6@>~`A*O!H!3kXHFk{_LVN>7-Cj|vR z@Dbb=RX`&GDjG6QKcR%Af(xZ0XO*OsTds|)OP-}lN0Mu+;^{;((QsbSycAnUDOHt; z=^?MeDH*NglNpZEkLCG2iJ+AEo^@%N3m*?xlWfdLVPm(kRP}XhI0e9r)-y)q$VDSd z^U8#ze6W%;DmOPKD;7TF=7Cn0^Z|xieJD2=JI<%e-nz|MWQB-q;x;&471~P8m{7J7 zdnFX>ZDgKN@5keq$9$Aq!CgL>0-Q<1Q|K5rkd1{1gyo~mNQP8Kx!}wJoI&Qe(GR~PS)ETs3>{yRi5UTZ64LHiUvO_WIY^ici(*S`1 z(8|I=iAPMvkq!a7(ty!L0NgSWml$#>X+2Pim6R;YzFgLblX7KZM!BJCpgsFw$5UWX z7SXBuKl#8EwIF&hIk}+p|Rzi!o+>niwTyF^Fr#$u}5o|F9j4>OJIrP0@9T(Md zWGw7u^1#TbWjJIHk5q~v(k-|QVrv=-Z6+tjHX=1v$w#P|E>{M86>*z;$}X;!bKu4C zNMvgpe2jT;@u0&ElADKydz(Eb%GlIYMylafVai6hlq!xZhzPFHZc*_u?N1ZHFoUWb zSDi|=vzR~11ZH5^nK)Z#Xn{L3A}Dj17_U*3;8>Y7@i^k@_6G^E^x}9jj6SB=3l=6q zz*ff*Iw~=c7~Le6EaJ{>@}+>Jol{hDRWn`}bq0t|A|n7tnez4i&A{M`Qe0L{+5{62 zt_77Usv%uOU&d{qKM^I-vjdd~n3A%XmA&fs*HC}&6Oa6!VfHLsmYFtaA+vcsLK+-6 z+C(&+`QqcB-nXU9S*#dcKy2+cvt{&L3Jfv#p~$FVr;!G?+cEIUc+9%PpDC{6AU?p7 z$TB#r7zJKrd2vNFE;vW27*jft>Ik#Kl`uSzhsmPUYxEt(@IYd@RfrO*XkHL9%(uXh z0^J(J)tdgz7S503-;d8-y0*6IB4&&oUmJgCp`0d1mllpJKeM=SYDb!J-=3GN hH!km9rI)Y&V8@kHJFZY{hRv}BwtQp974_)W{{d$pa4G-* delta 750 zcmYjOUr1A76hG(Q{nce<(?myE>>;EYMU+UO1N-w3WNH#c*k#U*a&6>V69!t@Tlezj}a|2g1?ah(iJj& znQ^Qz zE+sbOE${`{K|7I!t^AO;D2ehPLX*k>VWl~p^M<-N9$qmBViG5`TldGxJB(ky+s7fSk(^9 zq|xDFISitwuu`TSW+keNuu^LXb4^hLS;;sEZHR~DRM{?hk~IQ%VJLGGxAM`FX3aC^ zRI%Q8ugZ9Lac$rOXwJHgD@zhKG5+hSu?a#GSD?aH{tIE_#` to patch the existing font). diff --git a/tools/material-symbols-subset/add_custom_glyphs.py b/tools/material-symbols-subset/add_custom_glyphs.py new file mode 100644 index 0000000000..158c98e438 --- /dev/null +++ b/tools/material-symbols-subset/add_custom_glyphs.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Append custom (non-Material) glyphs to the subset Material Symbols font. + +Some icons Amethyst needs are third-party logos that Google's Material +Symbols font will never carry (e.g. the OpenTimestamps stamp). Their traced +SVG outlines live in `custom/` and this script bakes them into the subset +TTF at private-use codepoints, so `MaterialSymbols.kt` can reference them +exactly like any Material glyph. + +subset.sh runs this automatically after pyftsubset; it can also be run +directly against an existing font: + + python3 add_custom_glyphs.py + +The SVGs are potrace output: the raw path coordinates are y-up already (the +file's group transform re-flips them for SVG display), so they map into font +space with a plain uniform scale + translate. +""" + +import os +import re +import sys + +from fontTools.pens.boundsPen import BoundsPen +from fontTools.pens.cu2quPen import Cu2QuPen +from fontTools.pens.transformPen import TransformPen +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.svgLib.path import parse_path +from fontTools.ttLib import TTFont + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# codepoint -> (glyph name, traced SVG). Keep codepoints at the very end of +# the BMP private-use area (U+F8xx tail) to stay clear of the range Material +# Symbols actually allocates. +CUSTOM = { + 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg")), +} + +# Fit content into the same square Material Symbols draw in (its check_circle +# glyph spans 80..880 in a 960 upm font). Scaled to the font's real upm. +BOX_MIN_FRAC = 80 / 960 +BOX_MAX_FRAC = 880 / 960 + + +def svg_path_data(svg_file): + with open(svg_file, "r", encoding="utf-8") as f: + svg = f.read() + return re.findall(r']*\bd="([^"]+)"', svg) + + +def draw_svg(paths, pen): + for d in paths: + parse_path(d, pen) + + +def build_glyph(paths, upm): + # First pass: raw outline bounds. + bounds = BoundsPen(None) + draw_svg(paths, bounds) + x_min, y_min, x_max, y_max = bounds.bounds + + # Uniform scale into the Material content box, centered on both axes. + box_min = BOX_MIN_FRAC * upm + box_max = BOX_MAX_FRAC * upm + box = box_max - box_min + scale = box / max(x_max - x_min, y_max - y_min) + tx = box_min + (box - (x_max - x_min) * scale) / 2 - x_min * scale + ty = box_min + (box - (y_max - y_min) * scale) / 2 - y_min * scale + + tt_pen = TTGlyphPen(None) + quad_pen = Cu2QuPen(tt_pen, max_err=1.0) + draw_svg(paths, TransformPen(quad_pen, (scale, 0, 0, scale, tx, ty))) + return tt_pen.glyph() + + +def main(font_path): + font = TTFont(font_path) + upm = font["head"].unitsPerEm + order = font.getGlyphOrder() + + for codepoint, (name, svg_file) in sorted(CUSTOM.items()): + glyph = build_glyph(svg_path_data(svg_file), upm) + + if name not in order: + order.append(name) + font.setGlyphOrder(order) + font["glyf"][name] = glyph + glyph.recalcBounds(font["glyf"]) + font["hmtx"][name] = (upm, glyph.xMin) + for table in font["cmap"].tables: + if table.isUnicode(): + table.cmap[codepoint] = name + print(f"Added {name} at U+{codepoint:04X}") + + font["maxp"].numGlyphs = len(order) + font.save(font_path) + print(f"Wrote {font_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/tools/material-symbols-subset/custom/opentimestamps.svg b/tools/material-symbols-subset/custom/opentimestamps.svg new file mode 100644 index 0000000000..054387593f --- /dev/null +++ b/tools/material-symbols-subset/custom/opentimestamps.svg @@ -0,0 +1,130 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/tools/material-symbols-subset/subset.sh b/tools/material-symbols-subset/subset.sh index 0689a78aec..640964d342 100755 --- a/tools/material-symbols-subset/subset.sh +++ b/tools/material-symbols-subset/subset.sh @@ -59,4 +59,8 @@ pyftsubset "$SOURCE_TTF" \ --glyph-names \ --symbol-cmap +# Bake in the custom (non-Material) glyphs from custom/ — third-party logos +# like OpenTimestamps that the upstream font will never carry. +python3 "$REPO_ROOT/tools/material-symbols-subset/add_custom_glyphs.py" "$TARGET" + echo "Wrote $TARGET ($(du -h "$TARGET" | cut -f1))" From 170bc7c121e0cdd1a4b36952096780c02a46910f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:30:38 +0000 Subject: [PATCH 24/39] fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiet marks go back to the row's regular text size in bold with 16dp icons; the hashtag/community soft links lose their 12sp override too (the smaller tier read as too small). A new TimeAgoStyle.DottedTight renders "• 5m" without the leading space for rows whose spacedBy already provides the gap, removing the double space before the timestamp. The OTS pending pill shrinks to the stamp icon plus an ellipsis (the words move to the content description). The Boosted mark is removed entirely — from the Android header, the commons component, and the desktop feed — since the repost context is already visible. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 8 +--- .../amethyst/ui/note/elements/BoostedMark.kt | 31 -------------- .../ui/note/elements/DisplayCommunity.kt | 3 -- .../ui/note/elements/DisplayHashtags.kt | 3 -- .../amethyst/ui/note/elements/DisplayOts.kt | 6 ++- .../note/elements/NoteHeaderMarkersPreview.kt | 8 ---- .../amethyst/ui/note/elements/TimeAgo.kt | 22 ++++++++-- .../loggedIn/threadview/ThreadFeedView.kt | 3 +- amethyst/src/main/res/values/strings.xml | 1 - .../commons/ui/elements/BoostedMark.kt | 40 ------------------- .../amethyst/commons/ui/note/QuietMark.kt | 15 +++---- .../amethyst/desktop/ui/FeedScreen.kt | 2 - 12 files changed, 31 insertions(+), 111 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 041d3b37ff..f28d0fc270 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits -import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost @@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -1834,10 +1834,6 @@ fun FirstUserInfoRow( NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } - if (isRepost) { - BoostedMark() - } - CheckAndDisplayEditStatus(editState) if (isDraft) { @@ -1899,7 +1895,7 @@ fun FirstUserInfoRow( // The dotted TimeAgo carries its own " • " separator and the options // button has slack around its icon, so this pair stays unspaced. Row(verticalAlignment = CenterVertically) { - TimeAgo(baseNote) + TimeAgo(baseNote, style = TimeAgoStyle.DottedTight) if (moreOptions == null) { MoreOptionsButton(baseNote, editState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt deleted file mode 100644 index c48531aaca..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ /dev/null @@ -1,31 +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.note.elements - -import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.note.QuietMark -import com.vitorpamplona.amethyst.ui.stringRes - -@Composable -fun BoostedMark() { - QuietMark(text = stringRes(id = R.string.boosted)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 64b27c68a5..a2a68c1fca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,7 +34,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress @@ -66,7 +64,6 @@ private fun DisplayCommunity( ClickableTextColor( label, - style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cc48d6d08e..cd56c146fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,7 +38,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -85,7 +83,6 @@ private fun DisplayTagList( ) { ClickableTextColor( "#$firstTag", - style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index ea62ebdb9d..43ffee19e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -74,10 +74,12 @@ fun DisplayOts( ) }, whenPending = { + // The stamp icon plus an ellipsis: attestation requested, not yet + // anchored. The content description carries the words. HeaderPill( symbol = MaterialSymbols.OpenTimestamps, - text = stringRes(id = R.string.pending), - contentDescription = stringRes(R.string.ots_info_title), + text = "…", + contentDescription = stringRes(R.string.timestamp_pending_short), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 5ea22ae855..efac0ed3ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction.repost import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache @@ -44,7 +43,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers @@ -59,7 +57,6 @@ private val AUTHOR_METADATA_ID = "e1".repeat(32) private val LONG_NAME_METADATA_ID = "e2".repeat(32) private val PLAIN_ID = "1".repeat(64) -private val REPOST_ID = "2".repeat(64) private val RUMOR_ID = "3".repeat(64) private val EDITED_ID = "4".repeat(64) private val EDIT_VERSION_ID = "5".repeat(64) @@ -108,7 +105,6 @@ fun NoteHeaderFirstRowDensityPreview() { val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") val plain: Note = LocalCache.getOrCreateNote(PLAIN_ID) - val repost: Note = LocalCache.getOrCreateNote(REPOST_ID) val rumorPinned: Note = LocalCache.getOrCreateNote(RUMOR_ID) val edited: Note = LocalCache.getOrCreateNote(EDITED_ID) val editVersion: Note = LocalCache.getOrCreateNote(EDIT_VERSION_ID) @@ -132,7 +128,6 @@ fun NoteHeaderFirstRowDensityPreview() { LocalCache.justConsume(plainEvent, null, true) LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, true) - LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), plainEvent.toJson(), "x"), null, true) // An empty sig is what marks a note as a private rumor. LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, true) LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, true) @@ -197,9 +192,6 @@ fun NoteHeaderFirstRowDensityPreview() { // Bare minimum: username + time + options HeaderRowSample(plain, accountViewModel) - // One quiet mark: repost - HeaderRowSample(repost, accountViewModel) - // Quiet icon marks: private rumor + pinned HeaderRowSample(rumorPinned, accountViewModel, isPinned = true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index c23fd2d41c..a552e99cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -53,6 +53,12 @@ enum class TimeAgoStyle { /** "• 5m", uses [timeAgo]. Used by note timestamps and chatroom row last-message time. */ Dotted, + /** + * "• 5m" without the leading space — for rows whose `Arrangement.spacedBy` + * already provides the gap before the timestamp (the note-header first row). + */ + DottedTight, + /** "5m" (no dot, no leading space), uses [timeAgoShort]. Used by chat bubbles and channel headers. */ Short, } @@ -96,6 +102,7 @@ fun ToggleableTimeAgoText( if (showAbsolute) { when (style) { TimeAgoStyle.Dotted -> timeAbsolute(timestamp, context) + TimeAgoStyle.DottedTight -> timeAbsolute(timestamp, context).trimStart() TimeAgoStyle.Short -> timeAbsoluteNoDot(timestamp, context) } } else { @@ -104,6 +111,7 @@ fun ToggleableTimeAgoText( nowState.value when (style) { TimeAgoStyle.Dotted -> timeAgo(timestamp, context) + TimeAgoStyle.DottedTight -> timeAgo(timestamp, context).trimStart() TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr) } } @@ -125,16 +133,22 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo(note: Note) { +fun TimeAgo( + note: Note, + style: TimeAgoStyle = TimeAgoStyle.Dotted, +) { val time = note.createdAt() ?: return - TimeAgo(time) + TimeAgo(time, style) } @Composable -fun TimeAgo(time: Long) { +fun TimeAgo( + time: Long, + style: TimeAgoStyle = TimeAgoStyle.Dotted, +) { ToggleableTimeAgoText( timestamp = time, - style = TimeAgoStyle.Dotted, + style = style, color = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index f2762f30bf..d3a2b8a1dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -744,7 +745,7 @@ private fun FullBleedNoteCompose( // The dotted TimeAgo carries its own " • " separator and the // options button has slack around its icon: keep the pair unspaced. Row(verticalAlignment = Alignment.CenterVertically) { - TimeAgo(note = baseNote) + TimeAgo(note = baseNote, style = TimeAgoStyle.DottedTight) MoreOptionsButton(baseNote, editState, accountViewModel, nav) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 71c5cba0c2..a3ef6e6b7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,7 +45,6 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending - Pending Request Deletion Block / Report diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt deleted file mode 100644 index 8308fdfcfc..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt +++ /dev/null @@ -1,40 +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.commons.ui.elements - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun BoostedMark() { - Text( - "Boosted", - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.padding(start = 5.dp), - ) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt index 8f3cea79a7..2e0374c0d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt @@ -31,18 +31,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -private val QuietMarkFontSize = 12.sp - /** - * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, + * Quiet passive-state marker for note headers: Draft, Edited, pinned, * private rumor, ... One spec for all of them so the header reads as a single - * system: 12sp medium gray text with an optional 12dp icon. Contrast with - * [HeaderPill], which is the loud tier for tappable/verifiable metadata. + * system: bold gray text at the row's text size with an optional 16dp icon. + * Contrast with [HeaderPill], the chip tier for tappable/verifiable metadata. * * Spacing between markers is owned by the parent row (`Arrangement.spacedBy`), * not baked in here. @@ -65,16 +62,14 @@ fun QuietMark( symbol = symbol, contentDescription = contentDescription, tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.size(12.dp), + modifier = Modifier.size(16.dp), ) } if (text != null) { Text( text = text, color = MaterialTheme.colorScheme.placeholderText, - fontSize = QuietMarkFontSize, - lineHeight = QuietMarkFontSize, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Bold, maxLines = 1, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ec07c0e06b..1eace52ec5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend @@ -304,7 +303,6 @@ private fun FeedNoteCardBody( ) }, ) - BoostedMark() } // Original note content with actions inside card From 811d9cf80243b596ef8bdbe2f60295b697d39204 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:46:12 +0000 Subject: [PATCH 25/39] feat(ui): icon-only fork mark, pencil edit mark, tighter expiration and location pills - The fork marker drops "Forked from " for a bare fork-right icon (new MaterialSymbols.ForkRight, U+EBAC); tapping still opens the original version. Font regenerated via subset.sh, which also re-baked the custom OpenTimestamps glyph, proving the custom-glyph pipeline survives regeneration. - The edited mark becomes a pencil: bare pencil for the latest edit, pencil + "#2"/"original" only while cycling versions on tap. - The expiration pill clamps beyond one year to "1y+" instead of switching to a full date. - The location pill caps at 110dp and ellipsizes, so unbounded city names cannot squeeze the author's name out of the row. - Remove the now-unused existed_since string from all 35 locale files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 11 +++++- .../ui/note/elements/DisplayEditStatus.kt | 16 ++++++-- .../ui/note/elements/DisplayLocation.kt | 6 +++ .../amethyst/ui/note/elements/ForkInfo.kt | 36 ++++++------------ amethyst/src/main/res/values-cs/strings.xml | 1 - .../src/main/res/values-de-rDE/strings.xml | 1 - amethyst/src/main/res/values-de/strings.xml | 1 - amethyst/src/main/res/values-eo/strings.xml | 1 - .../src/main/res/values-es-rES/strings.xml | 1 - .../src/main/res/values-es-rMX/strings.xml | 1 - .../src/main/res/values-es-rUS/strings.xml | 1 - amethyst/src/main/res/values-es/strings.xml | 2 - .../src/main/res/values-fa-rIR/strings.xml | 1 - amethyst/src/main/res/values-fa/strings.xml | 1 - .../src/main/res/values-fr-rFR/strings.xml | 1 - amethyst/src/main/res/values-fr/strings.xml | 1 - .../src/main/res/values-hi-rIN/strings.xml | 1 - .../src/main/res/values-hu-rHU/strings.xml | 1 - amethyst/src/main/res/values-in/strings.xml | 2 - amethyst/src/main/res/values-ja/strings.xml | 2 - .../src/main/res/values-nl-rBE/strings.xml | 2 - .../src/main/res/values-nl-rNL/strings.xml | 1 - amethyst/src/main/res/values-nl/strings.xml | 1 - .../src/main/res/values-pl-rPL/strings.xml | 1 - .../src/main/res/values-pt-rBR/strings.xml | 1 - amethyst/src/main/res/values-ru/strings.xml | 2 - .../src/main/res/values-sl-rSI/strings.xml | 1 - .../src/main/res/values-sv-rSE/strings.xml | 1 - amethyst/src/main/res/values-ta/strings.xml | 1 - .../src/main/res/values-th-rTH/strings.xml | 1 - amethyst/src/main/res/values-th/strings.xml | 1 - amethyst/src/main/res/values-tr/strings.xml | 2 - amethyst/src/main/res/values-uk/strings.xml | 2 - .../src/main/res/values-zh-rCN/strings.xml | 1 - .../src/main/res/values-zh-rHK/strings.xml | 1 - .../src/main/res/values-zh-rSG/strings.xml | 1 - .../src/main/res/values-zh-rTW/strings.xml | 1 - amethyst/src/main/res/values-zh/strings.xml | 2 - amethyst/src/main/res/values/strings.xml | 2 +- .../font/material_symbols_outlined.ttf | Bin 466160 -> 469812 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + 41 files changed, 42 insertions(+), 72 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index f28d0fc270..56c40f13d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -351,6 +351,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -1762,9 +1763,17 @@ fun SecondUserInfoRow( @Composable fun DisplayExpiration(expirationDate: Long) { val context = LocalContext.current + // Beyond a year timeAheadNoDot switches to a full date, which is too wide + // for the pill: clamp it. + val text = + if (expirationDate - TimeUtils.now() > TimeUtils.ONE_YEAR) { + stringRes(R.string.one_year_plus) + } else { + timeAheadNoDot(expirationDate, context) + } HeaderPill( symbol = MaterialSymbols.Timer, - text = timeAheadNoDot(expirationDate, context), + text = text, contentDescription = stringRes(R.string.expiration_date_label), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 0ffae65a1d..355d205e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -22,23 +22,31 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes +/** + * Pencil marking an edited note; tapping cycles through the versions. The + * bare pencil is the latest edit — a suffix appears only while browsing + * older versions. + */ @Composable fun DisplayEditStatus(editState: EditState) { val label = - if (editState.showingVersion.value == editState.originalVersionId()) { + if (editState.showingVersion.value == editState.lastVersionId()) { + null + } else if (editState.showingVersion.value == editState.originalVersionId()) { stringRes(id = R.string.original) - } else if (editState.showingVersion.value == editState.lastVersionId()) { - stringRes(id = R.string.edited) } else { - stringRes(id = R.string.edited_number, editState.versionId()) + "#${editState.versionId()}" } QuietMark( + symbol = MaterialSymbols.Edit, text = label, + contentDescription = stringRes(id = R.string.edited), onClick = { editState.nextModification() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index a32e65d1b5..e533cc1cb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -31,6 +34,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** * Compact pill showing the geohash a note is scoped to, resolved to a city * name. Tapping it opens the geohash feed. Sits inline in note headers. + * City names are unbounded user data, so the pill is capped and ellipsizes + * to protect the author's name from being squeezed out of the row. */ @Composable fun DisplayLocation( @@ -42,6 +47,7 @@ fun DisplayLocation( HeaderPill( symbol = MaterialSymbols.LocationOn, text = cityName, + modifier = Modifier.widthIn(max = 110.dp), onClick = { nav.nav(Route.Geohash(geohashStr)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 7b6486bfd9..d9a32e94a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -20,26 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.experimental.forks.IForkableEvent @Composable @@ -54,20 +49,21 @@ fun ShowForkInformation( if (forkedAddress != null) { LoadAddressableNote(forkedAddress, accountViewModel) { addressableNote -> if (addressableNote != null) { - ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) + ForkMark(addressableNote, modifier, accountViewModel, nav) } } } else if (forkedEvent != null) { LoadNote(forkedEvent, accountViewModel) { event -> if (event != null) { - ForkInformationRowLightColor(event, modifier, accountViewModel, nav) + ForkMark(event, modifier, accountViewModel, nav) } } } } +/** Fork-right icon marking a forked note; tapping opens the original version. */ @Composable -fun ForkInformationRowLightColor( +fun ForkMark( originalVersion: Note, modifier: Modifier = Modifier, accountViewModel: AccountViewModel, @@ -75,22 +71,14 @@ fun ForkInformationRowLightColor( ) { val noteState by observeNote(originalVersion, accountViewModel) val note = noteState.note - val author = note.author ?: return val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - val userState by observeUserInfo(author, accountViewModel) - - CreateClickableTextWithEmoji( - clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()), - maxLines = 1, - route = route, - overrideColor = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - nav = nav, - tags = userState?.tags, - ) - } + QuietMark( + symbol = MaterialSymbols.ForkRight, + contentDescription = stringRes(id = R.string.forked_from), + modifier = modifier, + onClick = { nav.nav(route) }, + ) } } diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f9a50c85e7..b27e809d56 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2797,7 +2797,6 @@ Zdroj: Servery: Otevřít - OTS: %1$s Důkaz časového razítka Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. Upravit článek diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 090be920d0..1f106783f7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2683,7 +2683,6 @@ Quelle: Server: Öffnen - OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. Artikel bearbeiten diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 12b2abea2c..33afa5019d 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -680,7 +680,6 @@ anz der Bedingungen ist erforderlich Geforkt von Internet: Klonen: - OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. Beitrag bearbeiten diff --git a/amethyst/src/main/res/values-eo/strings.xml b/amethyst/src/main/res/values-eo/strings.xml index 0c1c4e18cf..2d610d685d 100644 --- a/amethyst/src/main/res/values-eo/strings.xml +++ b/amethyst/src/main/res/values-eo/strings.xml @@ -2125,7 +2125,6 @@ Fonto: Serviloj: Malfermi -OTS: %1$s Tempomarko-Pruvo Estas pruvo ke ĉi tiu afiŝo estis subskribita iam antaŭ %1$s. La pruvo estis stampita en la Bitcoin-blokĉeno en tiu dato kaj horo. Redakti Artikolon diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 4243cf8549..8a758dad23 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2297,7 +2297,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 6df3bf7d0f..6c76d29dcc 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 05a196bd7d..feb5c202e2 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es/strings.xml b/amethyst/src/main/res/values-es/strings.xml index 8ca8201d19..8af516d72d 100644 --- a/amethyst/src/main/res/values-es/strings.xml +++ b/amethyst/src/main/res/values-es/strings.xml @@ -2618,8 +2618,6 @@ Clon: -OTS: %1$s - Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 10dad02bfb..429bc76504 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2253,7 +2253,6 @@ منبع: سرورها: باز کردن - OTS: %1$s اثبات مهر زمان مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. ویرایش مقاله diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index d2c9bce73b..1cf69b0c1c 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -665,7 +665,6 @@ انشعاب شده از وب: نسخه برداری: - OTS: %1$s اثبات مهر زمان مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. ویرایش نوشته diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5caec58f87..3b445362af 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2511,7 +2511,6 @@ Source : Serveurs : Ouvrir - OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. Modifier l\'article diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index abbf896ec1..6bb2c6ea7c 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -670,7 +670,6 @@ Forké depuis Web : Clone : - OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. Modifier le Message diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 90d73c13a3..1124b60683 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2730,7 +2730,6 @@ स्रोत : सेवासंगणक : खोलें - ओ॰टी॰एस : %1$s समयांकन प्रमाण प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। लेख सम्पादित करें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c667c9f116..517a94c213 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2731,7 +2731,6 @@ Forrás: Kiszolgálók: Megnyitás - OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. Cikk szerkesztése diff --git a/amethyst/src/main/res/values-in/strings.xml b/amethyst/src/main/res/values-in/strings.xml index 51cc6610c8..bcb14b65b9 100644 --- a/amethyst/src/main/res/values-in/strings.xml +++ b/amethyst/src/main/res/values-in/strings.xml @@ -2726,8 +2726,6 @@ Buka - OTS: %1$s - Bukti Stempel Waktu Ada bukti bahwa postingan ini ditandatangani sebelum %1$s. Bukti ini dicap dalam blockchain Bitcoin pada tanggal dan waktu tersebut. diff --git a/amethyst/src/main/res/values-ja/strings.xml b/amethyst/src/main/res/values-ja/strings.xml index 8ddae5afed..59ec50d5b1 100644 --- a/amethyst/src/main/res/values-ja/strings.xml +++ b/amethyst/src/main/res/values-ja/strings.xml @@ -2818,8 +2818,6 @@ 開く - OTS: %1$s - タイムスタンプ証明 この投稿が %1$s より前に署名されたことの証明があります。この証明はその日時に Bitcoin ブロックチェーンに刻印されています。 diff --git a/amethyst/src/main/res/values-nl-rBE/strings.xml b/amethyst/src/main/res/values-nl-rBE/strings.xml index 73a4c7371e..6bb2bf83b3 100644 --- a/amethyst/src/main/res/values-nl-rBE/strings.xml +++ b/amethyst/src/main/res/values-nl-rBE/strings.xml @@ -3798,8 +3798,6 @@ Servers: -OTS: %1$s - Bewijs van tijdstempel Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 48933a0fb3..4bed90f621 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2566,7 +2566,6 @@ Bron: Servers: Openen - OTS: %1$s Bewijs van tijdstempel Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain. Artikel bewerken diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index 6ab57fbb53..e43d1b58d8 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -669,7 +669,6 @@ Geforked van Web: Kopiëren: - OTS: %1$s Bewijs van tijdstempel Er is bewijs dat dit bericht enige tijd voor %1$sis getekend. Het bewijs is op dat tijdstip en op dat moment getypeerd in de Bitcoin-blockchain. Bericht wijzigen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cf336b07d8..23c0a216cf 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2798,7 +2798,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Źródło: Serwery: Otwórz - OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. Redaguj artykuł diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2a7f29143b..4b0e0381de 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2680,7 +2680,6 @@ Fonte: Servidores: Abrir - OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. Editar artigo diff --git a/amethyst/src/main/res/values-ru/strings.xml b/amethyst/src/main/res/values-ru/strings.xml index 6d6d33c1ad..cc57214b33 100644 --- a/amethyst/src/main/res/values-ru/strings.xml +++ b/amethyst/src/main/res/values-ru/strings.xml @@ -2596,8 +2596,6 @@ Открыть - OTS: %1$s - Доказательство временно́й метки Есть доказательство того, что эта публикация была подписана до %1$s. Доказательство зафиксировано в блокчейне Bitcoin в указанную дату и время. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7470dcfef9..53783ca50f 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2813,7 +2813,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Vir: Strežniki: Odpri - OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. Uredi članek diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 83a1991f8d..734be267e9 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2688,7 +2688,6 @@ Källa: Servrar: Öppna - OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. Redigera artikel diff --git a/amethyst/src/main/res/values-ta/strings.xml b/amethyst/src/main/res/values-ta/strings.xml index e7f9170abc..bcc0f3b33c 100644 --- a/amethyst/src/main/res/values-ta/strings.xml +++ b/amethyst/src/main/res/values-ta/strings.xml @@ -2125,7 +2125,6 @@ மூலம்: சேவையகங்கள்: திறக்கவும் -OTS: %1$s நேர முத்திரை சான்று இந்த இடுகை %1$s-க்கு முன்பு ஒரு நேரத்தில் கையொப்பமிடப்பட்டது என்பதற்கான சான்று உள்ளது. சான்று அந்த தேதியிலும் நேரத்திலும் Bitcoin blockchain-ல் முத்திரையிடப்பட்டது. கட்டுரையைத் திருத்து diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 7be1e93544..2779a9625d 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2171,7 +2171,6 @@ แหล่งที่มา: เซิร์ฟเวอร์: เปิด - OTS: %1$s Timestamp ถูกยืนยัน มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว แก้ไขบทความ diff --git a/amethyst/src/main/res/values-th/strings.xml b/amethyst/src/main/res/values-th/strings.xml index d6a900f1ac..b2ffd57fbb 100644 --- a/amethyst/src/main/res/values-th/strings.xml +++ b/amethyst/src/main/res/values-th/strings.xml @@ -574,7 +574,6 @@ คัดลอกมาจาก เว็บไซน์ สำเนา: - OTS: %1$s Timestamp ถูกยืนยัน มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว แก้ไขโพสต์ diff --git a/amethyst/src/main/res/values-tr/strings.xml b/amethyst/src/main/res/values-tr/strings.xml index 561c98aa9c..d29471ab49 100644 --- a/amethyst/src/main/res/values-tr/strings.xml +++ b/amethyst/src/main/res/values-tr/strings.xml @@ -2996,8 +2996,6 @@ Aç - OTS: %1$s - Zaman Damgası Kanıtı Bu gönderinin %1$s tarihinden önce imzalandığına dair kanıt var. Kanıt, o tarih ve saatte Bitcoin blok zincirine işlendi. diff --git a/amethyst/src/main/res/values-uk/strings.xml b/amethyst/src/main/res/values-uk/strings.xml index d587710e89..4d980bb3ba 100644 --- a/amethyst/src/main/res/values-uk/strings.xml +++ b/amethyst/src/main/res/values-uk/strings.xml @@ -2786,8 +2786,6 @@ Відкрити - OTS: %1$s - Доказ часової позначки Є доказ того, що ця публікація була підписана до %1$s. Доказ засвідчено в блокчейні Bitcoin на ту дату та час. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3ff2b4c951..ff2d4ec596 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2695,7 +2695,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index fea511090e..2afb33976f 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 4d2541c844..6e804d6206 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 8feb85b6b3..accf6196d6 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2209,7 +2209,6 @@ 來源: 伺服器: 開啟 - OTS:%1$s 時間戳證明 有在 %1$s 之前的某個時間簽署了此帖子的證明。此證明是在那個日期和時間在比特幣區塊鏈中記錄的時間戳。 編輯文章 diff --git a/amethyst/src/main/res/values-zh/strings.xml b/amethyst/src/main/res/values-zh/strings.xml index 4dd6d7bff0..b9f3309a64 100644 --- a/amethyst/src/main/res/values-zh/strings.xml +++ b/amethyst/src/main/res/values-zh/strings.xml @@ -4214,8 +4214,6 @@ 打开 -OTS:%1$s - OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a3ef6e6b7d..9005e9f7e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,6 +45,7 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending + 1y+ Request Deletion Block / Report @@ -3017,7 +3018,6 @@ Source: Servers: Open -OTS: %1$s Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index fde397e9efc2d552e8205b3b0ec61759f5f3a6df..d75ba235aab931c0844fa28d9529614b88afa8b8 100644 GIT binary patch delta 8827 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 delta 5944 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 F{|4V`38(-7 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 7ef5919971..714013ec98 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -117,6 +117,7 @@ object MaterialSymbols { val FitnessCenter = MaterialSymbol("\uEB43") val Folder = MaterialSymbol("\uE2C7") val FolderZip = MaterialSymbol("\uEB2C") + val ForkRight = MaterialSymbol("\uEBAC") val FormatBold = MaterialSymbol("\uE238") val FormatItalic = MaterialSymbol("\uE23F") val FormatListNumbered = MaterialSymbol("\uE242") From 568aa9b005d3c7a5b82eb6a99f7a0276097fde92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:49:08 +0000 Subject: [PATCH 26/39] fix(ui): use middle ellipsis in HeaderPill labels Truncated pill labels (capped city names, relay hosts) keep their distinguishing endings, matching the codebase convention for URLs and relay links. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 312ca0d584..068a06e7d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -77,7 +77,7 @@ fun HeaderPill( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) } } From 7e78204336f9ea195ca9ae11800a40d3f0780b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:06:37 +0000 Subject: [PATCH 27/39] feat: ship a Flatpak bundle of the desktop app on release CI The linux-portable release leg now wraps the createReleaseDistributable tree it already builds into a single-file Flatpak bundle and attaches it to the GH Release as amethyst-desktop--linux-x64.flatpak. Packaging fixes to the existing (previously unwired) Flathub manifest: - add the missing 512x512 icon (copy of desktopApp icon.png) and install it under hicolor/512x512 instead of the never-present 256px path - drop the openjdk module + sdk-extension: the jpackage tree bundles its own trimmed JRE, so /app/jre was pure bloat - grant --socket=x11 instead of wayland/fallback-x11: Compose Desktop renders via AWT/skiko (X11-only on Linux, XWayland on Wayland), so fallback-x11 left the app socketless on Wayland sessions CI wiring: - install flatpak tooling + the freedesktop runtime/sdk (version greped from the manifest so the pin can't drift), retried like other fetches - inject the AppStream entry for the tagged version at build time (the checked-in metainfo deliberately carries none) - flatpak-builder with --disable-rofiles-fuse (GH runners) and a --state-dir under desktopApp/build so the repo tree stays clean - collect via the existing asset-name.sh contract (new flatpak ext) Verified end-to-end locally: built the bundle from the manifest with a stubbed jpackage tree, installed it, and ran the exported command inside the sandbox (freedesktop 24.08, args forwarded through the wrapper). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4 --- .github/workflows/create-release.yml | 51 ++++++++++- .gitignore | 5 + BUILDING.md | 16 +++- desktopApp/packaging/flatpak/README.md | 86 ++++++++++++------ .../com.vitorpamplona.amethyst.Desktop.yml | 24 ++--- .../com.vitorpamplona.amethyst.Desktop.png | Bin 0 -> 16645 bytes scripts/asset-name.sh | 4 +- 7 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8442a8942b..4236fa6955 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -44,7 +44,7 @@ jobs: - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle defaults: run: shell: bash @@ -101,6 +101,25 @@ jobs: fi chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + # Flatpak tooling + the freedesktop runtime/sdk the manifest pins + # (runtime-version is greped from the manifest so this never drifts). + # Retried: the runtime download from Flathub is ~1 GB and flatpak + # install resumes cleanly on re-run. + - name: Install Flatpak tooling + runtimes (linux-portable only) + if: matrix.family == 'linux-portable' + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 3 + timeout_minutes: 15 + command: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2) + flatpak install --user --noninteractive flathub \ + "org.freedesktop.Platform//${FDO_VER}" \ + "org.freedesktop.Sdk//${FDO_VER}" + # macOS only: import the Developer ID Application cert into a throwaway # keychain so jpackage's codesign pass can find it. Soft — if the # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are @@ -161,6 +180,36 @@ jobs: ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) fi + # Flatpak bundle: wraps the same createReleaseDistributable tree the + # AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the + # prebuilt jpackage tree into /app — no Gradle runs inside the sandbox. + # build-bundle emits a single-file .flatpak whose baked-in runtime-repo + # lets the user's flatpak fetch the freedesktop runtime from Flathub on + # install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse. + - name: Build Flatpak bundle (linux-portable only) + if: matrix.family == 'linux-portable' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + PKG="desktopApp/packaging/flatpak" + APP_ID="com.vitorpamplona.amethyst.Desktop" + OUT="desktopApp/build/flatpak" + # Inject the AppStream entry for this build (the checked-in + # metainfo deliberately carries none — CI is the source of truth). + sed -i "s||\n |" \ + "${PKG}/${APP_ID}.metainfo.xml" + mkdir -p "$OUT" + flatpak-builder --user --force-clean --disable-rofiles-fuse \ + --state-dir="${OUT}/.flatpak-builder" \ + --repo="${OUT}/repo" \ + "${OUT}/build-dir" \ + "${PKG}/${APP_ID}.yml" + flatpak build-bundle "${OUT}/repo" \ + "${OUT}/Amethyst-${VER}-x86_64.flatpak" \ + "$APP_ID" \ + --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo + ls -la "$OUT" + - name: Collect + rename assets run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 45f5f1d139..2616f94e70 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,11 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/* desktopApp/packaging/appimage/appimagetool-x86_64.AppImage desktopApp/packaging/appimage/squashfs-root/ +# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/) +.flatpak-builder/ +desktopApp/packaging/flatpak/build-dir/ +desktopApp/packaging/flatpak/repo/ + # Git worktrees .worktrees/ .claude/worktrees/ diff --git a/BUILDING.md b/BUILDING.md index 54b6021e97..e719e3aad2 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -37,7 +37,9 @@ Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; - `appimagetool` + `desktop-file-utils` for AppImage + `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + + `flatpak-builder` for the Flatpak bundle (see + [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md)) Install Linux RPM tooling: @@ -109,6 +111,7 @@ are **not** required to build Amethyst from the committed sources. | Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | | Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | | Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | +| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) | | Windows `.zip` portable | See below (inline `7z`) | — | | Linux `.tar.gz` portable | See below (inline `tar`) | — | @@ -148,7 +151,7 @@ Where: | `` | Tag stripped of leading `vX.YY.ZZ` | | `` | `macos`, `windows`, `linux` | | `` | `x64`, `arm64` | -| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | +| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` | Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). Package manager manifests (Homebrew cask, Winget) depend on this exact scheme — @@ -160,6 +163,7 @@ Examples: - `amethyst-desktop-1.12.1-macos-arm64.dmg` - `amethyst-desktop-1.12.1-windows-x64.msi` - `amethyst-desktop-1.12.1-linux-x64.AppImage` +- `amethyst-desktop-1.12.1-linux-x64.flatpak` --- @@ -625,12 +629,18 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb, expose downgrade migration risks — **prefer a single install channel per machine**. +**Exception: Flatpak.** The sandbox redirects XDG dirs into +`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps +its own separate state and does not see (or risk downgrading) state written +by any other channel. + | OS | App location | State directories | |---|---|---| | macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | | Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | | Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | | Linux (AppImage/tar.gz) | user-chosen | Same as above | +| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` | Uninstall: @@ -639,6 +649,8 @@ Uninstall: - .deb: `sudo apt remove amethyst` - .rpm: `sudo dnf remove amethyst` - AppImage / tar.gz: delete the file / extracted directory +- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add + `--delete-data` to also remove `~/.var/app/…`) - macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually --- diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md index 12bf3f3c7a..c732506f48 100644 --- a/desktopApp/packaging/flatpak/README.md +++ b/desktopApp/packaging/flatpak/README.md @@ -1,44 +1,65 @@ -# Flathub packaging for Amethyst Desktop +# Flatpak packaging for Amethyst Desktop -This directory contains the Flatpak manifest and associated metadata for -publishing Amethyst Desktop on Flathub. +This directory contains the Flatpak manifest and associated metadata. It is +used two ways: + +1. **Release CI** (`.github/workflows/create-release.yml`, `linux-portable` + leg) builds a single-file bundle from it on every tag and attaches it to + the GitHub Release as `amethyst-desktop--linux-x64.flatpak`. +2. **Flathub submission** (future) — the same manifest is the starting + point, but Flathub requires building from downloadable sources instead of + the local `type: dir` tree, so it will need a source rewrite at + submission time. ## Files -- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest -- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata - (categories, license, screenshots — needs screenshots added before - submission) +- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest. It packages + the **prebuilt** jpackage tree from + `./gradlew :desktopApp:createReleaseDistributable` (which bundles its own + trimmed JRE — that's why there is no openjdk module/sdk-extension). +- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata. + Release CI injects the `` entry for the version being built + (the checked-in file deliberately carries none); screenshots still need + to be added before any Flathub submission. - `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry -- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256 - PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before - submission - -## Build prerequisites - -- `./gradlew :desktopApp:createReleaseDistributable` — produces - `desktopApp/build/compose/binaries/main-release/app/Amethyst/` -- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` +- `icons/512/com.vitorpamplona.amethyst.Desktop.png` — 512x512 icon (copy of + `desktopApp/src/jvmMain/resources/icon.png`) ## Local build ```bash +# 1. Build the app tree the manifest packages +./gradlew :desktopApp:createReleaseDistributable + +# 2. Tooling + Flathub remote (one-time) +sudo apt-get install -y flatpak flatpak-builder # or distro equivalent +flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + +# 3. Build + install locally cd desktopApp/packaging/flatpak -flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak-builder --user --install --install-deps-from=flathub --force-clean \ + build-dir com.vitorpamplona.amethyst.Desktop.yml flatpak run com.vitorpamplona.amethyst.Desktop ``` -## Submission to Flathub +To produce the distributable single-file bundle instead (what CI ships): -Follow https://docs.flathub.org/docs/for-app-authors/submission +```bash +flatpak-builder --user --install-deps-from=flathub --force-clean \ + --repo=repo build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak build-bundle repo amethyst.flatpak com.vitorpamplona.amethyst.Desktop \ + --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo +``` -1. Fork `flathub/flathub` on GitHub. -2. Branch from `new-pr` (NOT `master`). -3. Copy this manifest + AppStream + desktop into a new directory matching - the app id. -4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". -5. After merge, a per-app repo is created with write access for ongoing - updates. +Installing the bundle: `flatpak install --user ./amethyst.flatpak`. The +`--runtime-repo` baked in above lets flatpak fetch the freedesktop runtime +from Flathub automatically on the user's machine. + +## Sandbox notes + +- `--socket=x11` (not wayland/fallback-x11): Compose Desktop renders through + AWT/skiko, which is X11-only on Linux and runs under XWayland on Wayland + sessions. ## Codec coverage @@ -47,6 +68,19 @@ Follow https://docs.flathub.org/docs/for-app-authors/submission - HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in `org.freedesktop.Platform 24.08` itself. +## Submission to Flathub + +Follow https://docs.flathub.org/docs/for-app-authors/submission + +1. Fork `flathub/flathub` on GitHub. +2. Branch from `new-pr` (NOT `master`). +3. Copy this manifest + AppStream + desktop into a new directory matching + the app id, and rework the `type: dir` source into a + buildable-from-source or downloadable-artifact form per Flathub policy. +4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". +5. After merge, a per-app repo is created with write access for ongoing + updates. + ## License metadata The manifest declares the binary as diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml index 8ed6f86207..79f44e226b 100644 --- a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml @@ -8,8 +8,6 @@ app-id: com.vitorpamplona.amethyst.Desktop runtime: org.freedesktop.Platform runtime-version: '24.08' sdk: org.freedesktop.Sdk -sdk-extensions: - - org.freedesktop.Sdk.Extension.openjdk21 command: amethyst-desktop add-extensions: @@ -26,8 +24,11 @@ cleanup-commands: finish-args: - --share=network - --share=ipc - - --socket=fallback-x11 - - --socket=wayland + # Compose Desktop renders through AWT/skiko, which is X11-only on Linux + # (runs under XWayland on Wayland sessions). fallback-x11 + wayland would + # leave the app without a usable display socket on Wayland sessions, so + # grant x11 unconditionally. + - --socket=x11 - --socket=pulseaudio - --device=dri - --filesystem=xdg-download @@ -37,12 +38,10 @@ finish-args: # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 +# No openjdk module / sdk-extension: the jpackage tree copied below already +# bundles its own trimmed JRE under Amethyst/lib/runtime/, so installing a +# second JRE at /app/jre would only bloat the bundle. modules: - - name: openjdk - buildsystem: simple - build-commands: - - /usr/lib/sdk/openjdk21/install.sh - - name: amethyst-desktop buildsystem: simple build-commands: @@ -53,7 +52,7 @@ modules: - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ - - install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/ + - install -Dm644 icons/512/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/512x512/apps/ sources: # Built artifact from `./gradlew :desktopApp:createReleaseDistributable`. # Path matches Compose Multiplatform 1.11's output layout. @@ -73,5 +72,6 @@ modules: - type: file path: com.vitorpamplona.amethyst.Desktop.desktop - - type: file - path: icons/256/com.vitorpamplona.amethyst.Desktop.png + - type: dir + path: icons + dest: icons diff --git a/desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png b/desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe89a16f0378d0e21e8ab3e1c8b2d46b4cf0b52 GIT binary patch literal 16645 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 = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux # = x64 | arm64 -# = dmg | msi | zip | deb | rpm | AppImage | tar.gz +# = dmg | msi | zip | deb | rpm | AppImage | flatpak | tar.gz # # Consumed by: .github/workflows/create-release.yml, .github/workflows/bump-*.yml, # BUILDING.md, local release runbooks. @@ -26,6 +26,7 @@ # amethyst-desktop-1.08.0-linux-x64.deb # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage +# amethyst-desktop-1.08.0-linux-x64.flatpak # amethyst-desktop-1.08.0-linux-x64.tar.gz # amy-1.08.0-macos-arm64.tar.gz # amy-1.08.0-macos-x64.tar.gz @@ -74,6 +75,7 @@ collect_assets() { desktopApp/build/compose/binaries/main-release/deb/*.deb \ desktopApp/build/compose/binaries/main-release/rpm/*.rpm \ desktopApp/build/appimage/*.AppImage \ + desktopApp/build/flatpak/*.flatpak \ desktopApp/build/portable/*.tar.gz \ desktopApp/build/portable/*.zip \ ; do From 500de62841a3cc51516ddadbc4a024c10c1fdc7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:15:56 +0000 Subject: [PATCH 28/39] fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the pending OTS pill now shows a toast explaining that the attestation is waiting to be stamped into the Bitcoin blockchain (new ots_info_pending_description). The OpenTimestamps glyph gets a near-full-em content box — its fine outline read much lighter than Material's solid shapes at the standard 80..880 bounds — and HeaderPill icons go from 11dp to 13dp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/elements/DisplayOts.kt | 9 +++++- amethyst/src/main/res/values/strings.xml | 1 + .../font/material_symbols_outlined.ttf | Bin 469812 -> 469856 bytes .../amethyst/commons/ui/note/HeaderPill.kt | 2 +- .../add_custom_glyphs.py | 29 +++++++++--------- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 43ffee19e3..22c3720962 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -75,11 +75,18 @@ fun DisplayOts( }, whenPending = { // The stamp icon plus an ellipsis: attestation requested, not yet - // anchored. The content description carries the words. + // anchored. The content description carries the words; tapping + // explains what is being waited on. HeaderPill( symbol = MaterialSymbols.OpenTimestamps, text = "…", contentDescription = stringRes(R.string.timestamp_pending_short), + onClick = { + accountViewModel.toastManager.toast( + R.string.ots_info_title, + R.string.ots_info_pending_description, + ) + }, ) }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9005e9f7e7..a9ed842bdd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3021,6 +3021,7 @@ Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. + A timestamp proof for this post has been requested and is waiting to be stamped in the Bitcoin blockchain. It usually completes within a few hours. Edit Article Edit Post diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index d75ba235aab931c0844fa28d9529614b88afa8b8..aed61e63f444fc798feb320f8b9ba482543dd9d4 100644 GIT binary patch delta 2369 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 (glyph name, traced SVG). Keep codepoints at the very end of -# the BMP private-use area (U+F8xx tail) to stay clear of the range Material -# Symbols actually allocates. +# codepoint -> (glyph name, traced SVG, content-box fractions). Keep +# codepoints at the very end of the BMP private-use area (U+F8xx tail) to +# stay clear of the range Material Symbols actually allocates. +# +# Material's own solid shapes draw inside 80..880 of a 960 upm em; a glyph +# with fine outlines (like the OpenTimestamps stamp) reads much lighter at +# the same bounds, so it gets a near-full-em box to appear the same size. CUSTOM = { - 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg")), + 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg"), 20 / 960, 940 / 960), } -# Fit content into the same square Material Symbols draw in (its check_circle -# glyph spans 80..880 in a 960 upm font). Scaled to the font's real upm. -BOX_MIN_FRAC = 80 / 960 -BOX_MAX_FRAC = 880 / 960 - def svg_path_data(svg_file): with open(svg_file, "r", encoding="utf-8") as f: @@ -54,15 +53,15 @@ def draw_svg(paths, pen): parse_path(d, pen) -def build_glyph(paths, upm): +def build_glyph(paths, upm, box_min_frac, box_max_frac): # First pass: raw outline bounds. bounds = BoundsPen(None) draw_svg(paths, bounds) x_min, y_min, x_max, y_max = bounds.bounds - # Uniform scale into the Material content box, centered on both axes. - box_min = BOX_MIN_FRAC * upm - box_max = BOX_MAX_FRAC * upm + # Uniform scale into the content box, centered on both axes. + box_min = box_min_frac * upm + box_max = box_max_frac * upm box = box_max - box_min scale = box / max(x_max - x_min, y_max - y_min) tx = box_min + (box - (x_max - x_min) * scale) / 2 - x_min * scale @@ -79,8 +78,8 @@ def main(font_path): upm = font["head"].unitsPerEm order = font.getGlyphOrder() - for codepoint, (name, svg_file) in sorted(CUSTOM.items()): - glyph = build_glyph(svg_path_data(svg_file), upm) + for codepoint, (name, svg_file, box_min_frac, box_max_frac) in sorted(CUSTOM.items()): + glyph = build_glyph(svg_path_data(svg_file), upm, box_min_frac, box_max_frac) if name not in order: order.append(name) From 079822ed9d3fb0f5fe281c94ad66f3923ac0b064 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:30:40 +0000 Subject: [PATCH 29/39] fix: NPE in AntiSpamFilter.isSpam when the first duplicate was evicted from the LRU cache The spam check can fire via the spamMessages record alone, after the original event id/address has been evicted from the 2000-entry recentEventIds/recentAddressables LruCache. In that case existingEvent / existingAddress is null and building the njump link threw a NullPointerException, aborting event consumption in LocalCache. Fall back to the current event's link when the original is no longer cached, and use setOfNotNull in logOffender so a null cache entry can't sneak into the Spammer sets through the platform-type hole. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGiCujAHWMbmGT89X77S4K --- .../vitorpamplona/amethyst/model/AntiSpamFilter.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 791d69dff1..dbecbb436f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -82,10 +82,12 @@ class AntiSpamFilter { (recentAddressables[hash] != null && recentAddressables[hash] != address) || (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address)) ) { + // may be null if the first duplicate was evicted from the LRU cache + // while the spammer record still matches this hash. val existingAddress = recentAddressables[hash] - val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay)) val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay)) + val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2 Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } @@ -111,8 +113,10 @@ class AntiSpamFilter { (existingEvent != null && existingEvent != event.id) || (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id)) ) { - val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay)) val link2 = njumpLink(NEvent.create(event.id, null, null, relay)) + // existingEvent may be null if the first duplicate was evicted from the + // LRU cache while the spammer record still matches this hash. + val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2 Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } @@ -149,12 +153,12 @@ class AntiSpamFilter { Spammer( pubkeyHex = event.pubKey, duplicatedEventIds = setOf(), - duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()), + duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()), ) } else { Spammer( pubkeyHex = event.pubKey, - duplicatedEventIds = setOf(recentEventIds[hashCode], event.id), + duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id), duplicatedEventAddresses = setOf(), ) } From e684cb02a3d2e0ff3bba5860f7cf765c8232a687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:31:23 +0000 Subject: [PATCH 30/39] feat(ui): tap explainers for the PoW, private-rumor and expiration markers Following the OTS pill's pattern, tapping now explains what the marker means: PoW describes the mining difficulty as an anti-spam stamp (pow_info_description), the private-rumor lock explains the unsigned private delivery and its deniability (private_rumor_info_*), and the expiration pill shows the request to delete plus the exact expiry date/time (expiration_info_description). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 35 +++++++++++++++---- .../amethyst/ui/note/elements/DisplayPoW.kt | 20 ++++++++--- .../loggedIn/chats/feed/ChatMessageCompose.kt | 2 +- .../loggedIn/threadview/ThreadFeedView.kt | 4 +-- amethyst/src/main/res/values/strings.xml | 4 +++ 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 56c40f13d6..b1810aac12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -355,6 +355,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date @Composable fun NoteCompose( @@ -1761,7 +1763,10 @@ fun SecondUserInfoRow( } @Composable -fun DisplayExpiration(expirationDate: Long) { +fun DisplayExpiration( + expirationDate: Long, + accountViewModel: AccountViewModel, +) { val context = LocalContext.current // Beyond a year timeAheadNoDot switches to a full date, which is too wide // for the pill: clamp it. @@ -1775,6 +1780,13 @@ fun DisplayExpiration(expirationDate: Long) { symbol = MaterialSymbols.Timer, text = text, contentDescription = stringRes(R.string.expiration_date_label), + onClick = { + accountViewModel.toastManager.toast( + R.string.expiration_date_label, + R.string.expiration_info_description, + SimpleDateFormat.getDateTimeInstance().format(Date(expirationDate * 1000)), + ) + }, ) } @@ -1886,13 +1898,13 @@ fun FirstUserInfoRow( val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } if (pow != null) { - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } - Expiration(baseNote) + Expiration(baseNote, accountViewModel) if (baseNote.isPrivateRumor()) { - PrivateRumorMark() + PrivateRumorMark(accountViewModel) } if (isPinned) { @@ -1924,10 +1936,16 @@ fun PinnedMark() { } @Composable -fun PrivateRumorMark() { +fun PrivateRumorMark(accountViewModel: AccountViewModel) { QuietMark( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), + onClick = { + accountViewModel.toastManager.toast( + R.string.private_rumor_info_title, + R.string.private_rumor_info_description, + ) + }, ) } @@ -1968,12 +1986,15 @@ fun JumpToParentReplyButton( } @Composable -fun Expiration(note: Note) { +fun Expiration( + note: Note, + accountViewModel: AccountViewModel, +) { val event = note.event if (event != null) { val expires = remember(event) { event.expiration() } if (expires != null) { - DisplayExpiration(expires) + DisplayExpiration(expires, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index e6fc7b772e..681975f006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -32,20 +34,30 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Preview fun DisplayPoWPreview() { ThemeComparisonColumn( - toPreview = { DisplayPoW(pow = 24) }, + toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) }, ) } /** * Compact pill showing the proof of work a received note carries: a gear plus - * the difficulty in leading zero bits. Sits inline in note headers, so it - * stays at text height. + * the difficulty in leading zero bits. Tapping it explains what the number + * means. Sits inline in note headers, so it stays at text height. */ @Composable -fun DisplayPoW(pow: Int) { +fun DisplayPoW( + pow: Int, + accountViewModel: AccountViewModel, +) { HeaderPill( symbol = MaterialSymbols.Manufacturing, text = pow.toString(), contentDescription = stringRes(R.string.pow_settings_title), + onClick = { + accountViewModel.toastManager.toast( + R.string.pow_settings_title, + R.string.pow_info_description, + pow.toString(), + ) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 30ca93806a..f6b09bb796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -289,7 +289,7 @@ fun NormalChatNote( val pow = remember(note) { note.event?.strongPoWOrNull() } if (pow != null) { Spacer(StdHorzSpacer) - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } } else { DisplayDraftChat() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index d3a2b8a1dd..51993a25a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -740,7 +740,7 @@ private fun FullBleedNoteCompose( CheckAndDisplayEditStatus(editState) - Expiration(baseNote) + Expiration(baseNote, accountViewModel) // The dotted TimeAgo carries its own " • " separator and the // options button has slack around its icon: keep the pair unspaced. @@ -781,7 +781,7 @@ private fun FullBleedNoteCompose( val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } if (pow != null) { - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } if (isDraft) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a9ed842bdd..7a864a29f1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1234,6 +1234,8 @@ is a public bookmark here is a private bookmark here Private — only visible to tagged participants + Private Note + This note was delivered privately and is not signed. Only the tagged participants can see it, and nobody can prove to outsiders who wrote it. Make private: gift-wrap the note to the notified users only Make public Replies to a private note always stay private @@ -2822,6 +2824,7 @@ Add expiration date Remove expiration date Expiration Date + The author asked relays to delete this note after %1$s. It will disappear from feeds once it expires. Post will be hidden by clients after this date (NIP-40) Select expiration date and time Expires in %1$s @@ -3776,6 +3779,7 @@ Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable. Your signature Proof of Work + The author spent computing power to mine this note to difficulty %1$s — a proof-of-work stamp that makes spamming expensive. Difficulty Mines a NIP-13 proof of work into your posts before publishing so relays and readers can weigh them against spam. Higher values take exponentially longer to mine. Posts publish in the background once mined. Off From 079417ba606756cd084db813ec8167a362b698a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:32:47 +0000 Subject: [PATCH 31/39] feat: add submission-ready Flathub manifest variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained desktopApp/packaging/flatpak/flathub/ directory whose contents are copied verbatim into the flathub per-app repo at submission time (Flathub has no separate accounts — submission is a GitHub PR): - manifest builds from the published GH Release tarball (pinned to v1.12.6 url + sha256 from the release asset digest) instead of the local type:dir tree CI uses — Flathub build servers must fetch all sources themselves - x-checker-data (json type, is-main-source) on the archive source so flatpak-external-data-checker auto-PRs url/sha256 bumps and metainfo entries on every new GitHub Release - own metainfo copy carrying the permanent history Flathub requires (the CI variant keeps injecting its entry at build time); screenshots remain the documented submission blocker - flathub.json restricting builds to x86_64 (no aarch64 tarball exists and jpackage cannot cross-compile one) Verified locally: built the flathub manifest end-to-end from the real v1.12.6 tarball (sha256 enforced by flatpak-builder), installed it, and booted the actual app inside the sandbox under Xvfb — UI rendered and the embedded Tor daemon spawned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4 --- .gitignore | 4 +- desktopApp/packaging/flatpak/README.md | 38 +++++-- ...com.vitorpamplona.amethyst.Desktop.desktop | 9 ++ ...itorpamplona.amethyst.Desktop.metainfo.xml | 52 ++++++++++ .../com.vitorpamplona.amethyst.Desktop.yml | 98 ++++++++++++++++++ .../packaging/flatpak/flathub/flathub.json | 3 + .../com.vitorpamplona.amethyst.Desktop.png | Bin 0 -> 16645 bytes 7 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml create mode 100644 desktopApp/packaging/flatpak/flathub/flathub.json create mode 100644 desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png diff --git a/.gitignore b/.gitignore index 2616f94e70..23d04ed5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -182,8 +182,8 @@ desktopApp/packaging/appimage/squashfs-root/ # flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/) .flatpak-builder/ -desktopApp/packaging/flatpak/build-dir/ -desktopApp/packaging/flatpak/repo/ +desktopApp/packaging/flatpak/**/build-dir/ +desktopApp/packaging/flatpak/**/repo/ # Git worktrees .worktrees/ diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md index c732506f48..552fba7e42 100644 --- a/desktopApp/packaging/flatpak/README.md +++ b/desktopApp/packaging/flatpak/README.md @@ -6,10 +6,10 @@ used two ways: 1. **Release CI** (`.github/workflows/create-release.yml`, `linux-portable` leg) builds a single-file bundle from it on every tag and attaches it to the GitHub Release as `amethyst-desktop--linux-x64.flatpak`. -2. **Flathub submission** (future) — the same manifest is the starting - point, but Flathub requires building from downloadable sources instead of - the local `type: dir` tree, so it will need a source rewrite at - submission time. +2. **Flathub submission** — the `flathub/` subdirectory holds a + submission-ready variant that builds from the published GitHub Release + tarball (Flathub's build servers must fetch sources themselves; the + local `type: dir` tree used by CI is not allowed there). ## Files @@ -24,6 +24,12 @@ used two ways: - `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry - `icons/512/com.vitorpamplona.amethyst.Desktop.png` — 512x512 icon (copy of `desktopApp/src/jvmMain/resources/icon.png`) +- `flathub/` — self-contained, copy-ready Flathub submission dir: its own + manifest (archive source pinned to the release tarball URL + sha256, with + `x-checker-data` so Flathub's update bot bumps it), its own metainfo + (carries the permanent `` history Flathub requires), desktop + entry, icon, and `flathub.json` (`only-arches: x86_64` — we publish no + aarch64 tarball, and jpackage can't cross-compile one) ## Local build @@ -70,16 +76,28 @@ from Flathub automatically on the user's machine. ## Submission to Flathub -Follow https://docs.flathub.org/docs/for-app-authors/submission +Follow https://docs.flathub.org/docs/for-app-authors/submission — no +separate Flathub account exists; everything runs through GitHub PRs. + +**Remaining blocker before submitting:** at least one screenshot with a +publicly reachable URL in `flathub/….metainfo.xml` (the Flathub linter +rejects the current empty placeholder). 1. Fork `flathub/flathub` on GitHub. 2. Branch from `new-pr` (NOT `master`). -3. Copy this manifest + AppStream + desktop into a new directory matching - the app id, and rework the `type: dir` source into a - buildable-from-source or downloadable-artifact form per Flathub policy. +3. Copy the contents of `flathub/` verbatim into a new top-level directory + named `com.vitorpamplona.amethyst.Desktop`. 4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". -5. After merge, a per-app repo is created with write access for ongoing - updates. +5. After merge, Flathub creates a per-app repo + (`flathub/com.vitorpamplona.amethyst.Desktop`) with write access for + ongoing updates. Its `x-checker-data` makes + flatpak-external-data-checker open update PRs there automatically on + each new GitHub Release (bumping url/sha256 and appending the metainfo + `` entry). + +To test the Flathub variant locally, run the same flatpak-builder commands +as above from inside `flathub/` — it downloads the pinned release tarball +instead of using a local Gradle build. ## License metadata diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop new file mode 100644 index 0000000000..cc52ce21e4 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Amethyst Desktop +Comment=Nostr client for desktop +Categories=Network;InstantMessaging; +Exec=amethyst-desktop +Icon=com.vitorpamplona.amethyst.Desktop +Terminal=false +StartupWMClass=Amethyst diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml new file mode 100644 index 0000000000..a203d09e68 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml @@ -0,0 +1,52 @@ + + + com.vitorpamplona.amethyst.Desktop + CC0-1.0 + MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 + Amethyst Desktop + Nostr client for desktop + + +

+ Amethyst Desktop is a desktop client for the Nostr protocol. + Browse feeds, post notes, send zaps (Lightning Network), and + participate in NIP-53 live audio rooms. +

+
+ + com.vitorpamplona.amethyst.Desktop.desktop + + https://github.com/vitorpamplona/amethyst + https://github.com/vitorpamplona/amethyst/issues + https://github.com/vitorpamplona/amethyst + + + Vitor Pamplona + + + + + + Network + InstantMessaging + + + + + + + + + + +
diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml new file mode 100644 index 0000000000..54faa72fe1 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml @@ -0,0 +1,98 @@ +# Flathub submission manifest for Amethyst Desktop. +# +# Unlike ../com.vitorpamplona.amethyst.Desktop.yml (which packages the locally +# built tree for release CI), this variant builds from the published GitHub +# Release tarball — Flathub's build servers must be able to fetch every source +# themselves. This directory is copy-ready: everything in it (manifest, +# metainfo, desktop entry, icon, flathub.json) is exactly what gets committed +# to the flathub per-app repo at submission time. +# +# Update flow after each Amethyst release: bump url + sha256 on the archive +# source and append a entry to the metainfo. The x-checker-data +# block below lets Flathub's flatpak-external-data-checker bot do both +# automatically once the app is published. + +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: amethyst-desktop + +add-extensions: + org.freedesktop.Platform.ffmpeg-full: + directory: lib/ffmpeg + version: '24.08' + add-ld-path: . + autodownload: true + autodelete: false + +cleanup-commands: + - mkdir -p /app/lib/ffmpeg + +finish-args: + - --share=network + - --share=ipc + # Compose Desktop renders through AWT/skiko, which is X11-only on Linux + # (runs under XWayland on Wayland sessions). fallback-x11 + wayland would + # leave the app without a usable display socket on Wayland sessions, so + # grant x11 unconditionally. + - --socket=x11 + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --filesystem=xdg-pictures + - --talk-name=org.freedesktop.Notifications + - --talk-name=org.freedesktop.secrets + # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). + - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 + +# No openjdk module / sdk-extension: the jpackage tree in the release tarball +# already bundles its own trimmed JRE under Amethyst/lib/runtime/. +modules: + - name: amethyst-desktop + buildsystem: simple + build-commands: + # Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst. + # Wrapper at /app/bin/amethyst-desktop forwards args. + - mkdir -p /app/lib/Amethyst + - cp -r ./Amethyst/* /app/lib/Amethyst/ + - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop + - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ + - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ + - install -Dm644 icons/512/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/512x512/apps/ + sources: + # jpackage self-contained tree published by release CI. The tarball + # carries a single Amethyst/ top-level dir — keep it (strip-components + # defaults to 1, which would flatten it away). + - type: archive + url: https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-desktop-1.12.6-linux-x64.tar.gz + sha256: 42250027857b37f184ebca52bf6124ff6524be4bc15fedf819e4acaca8bc9538 + strip-components: 0 + # flatpak-external-data-checker: watches GitHub Releases and opens + # update PRs against the flathub repo (bumps url/sha256 above and, + # via is-main-source, appends the entry to the metainfo). + x-checker-data: + type: json + is-main-source: true + url: https://api.github.com/repos/vitorpamplona/amethyst/releases/latest + version-query: .tag_name | sub("^v"; "") + timestamp-query: .published_at + url-query: '"https://github.com/vitorpamplona/amethyst/releases/download/" + + .tag_name + "/amethyst-desktop-" + (.tag_name | sub("^v"; "")) + + "-linux-x64.tar.gz"' + + - type: script + dest-filename: amethyst-desktop.sh + commands: + - "#!/bin/sh" + - exec /app/lib/Amethyst/bin/Amethyst "$@" + + - type: file + path: com.vitorpamplona.amethyst.Desktop.metainfo.xml + + - type: file + path: com.vitorpamplona.amethyst.Desktop.desktop + + - type: dir + path: icons + dest: icons diff --git a/desktopApp/packaging/flatpak/flathub/flathub.json b/desktopApp/packaging/flatpak/flathub/flathub.json new file mode 100644 index 0000000000..c75b742acf --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/flathub.json @@ -0,0 +1,3 @@ +{ + "only-arches": ["x86_64"] +} diff --git a/desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png b/desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe89a16f0378d0e21e8ab3e1c8b2d46b4cf0b52 GIT binary patch literal 16645 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 Date: Tue, 14 Jul 2026 14:33:35 +0000 Subject: [PATCH 32/39] fix: crop drawer banner instead of letterboxing short images The left drawer banner used ContentScale.FillWidth inside a fixed 120dp-tall box, so banners wider than the box's aspect ratio left empty bars above and below the image. ContentScale.Crop fills the full banner height and crops the excess width instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BhQ1FCyoKCD1UeBT5mWy18 --- .../amethyst/ui/navigation/drawer/DrawerContent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a4ba397054..56136b06f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -237,14 +237,14 @@ fun ProfileContentTemplate( AsyncImage( model = profileBanner, contentDescription = stringRes(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, modifier = bannerModifier, ) } else { AsyncImage( model = R.drawable.profile_banner, contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, modifier = bannerModifier, ) } From 103a3dfc23ede2abec4683b21fcf5953fc1ed000 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:41:43 +0000 Subject: [PATCH 33/39] fix: skip event parse of community approval contents that are not JSON objects Clients in the wild publish kind-4550 approvals whose content is plain text (e.g. braille ASCII art) or a custom non-event JSON wrapper instead of the NIP-72 stringified approved event. containedPost() fed any non-blank content straight into Event.fromJson, producing a JsonParseException and a noisy logcat warning for every such approval. Only attempt the parse when the trimmed content starts with '{', so plain-text contents are skipped silently while genuinely malformed event-looking JSON still logs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EXiy3Ajmr3vdrV83jZsUx6 --- .../approval/CommunityPostApprovalEvent.kt | 4 +- .../CommunityPostApprovalEventTest.kt | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 3347b88a9e..9414dfe67c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -70,7 +70,9 @@ class CommunityPostApprovalEvent( fun containedPost(): Event? = try { - content.ifBlank { null }?.let { fromJson(it) } + // Only attempts to parse contents that could be an event json. + // Clients in the wild put all sorts of non-event text in here. + content.trim().takeIf { it.startsWith("{") }?.let { fromJson(it) } } catch (e: Exception) { Log.w( "CommunityPostEvent", diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt new file mode 100644 index 0000000000..488764e280 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.nip72ModCommunities.approval + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CommunityPostApprovalEventTest { + private fun approvalWith(content: String) = + CommunityPostApprovalEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("a", "34550:${"11".repeat(32)}:community")), + content = content, + sig = "22".repeat(64), + ) + + @Test + fun containedPostParsesEmbeddedEventJson() { + val embedded = + """{"id":"${"33".repeat(32)}","pubkey":"${"44".repeat(32)}","created_at":1700000000,"kind":1,"tags":[],"content":"hello","sig":"${"55".repeat(64)}"}""" + + val post = approvalWith(embedded).containedPost() + + assertEquals("33".repeat(32), post?.id) + assertEquals(1, post?.kind) + assertEquals("hello", post?.content) + } + + @Test + fun containedPostIgnoresBlankContent() { + assertNull(approvalWith("").containedPost()) + assertNull(approvalWith(" ").containedPost()) + } + + /** + * Clients in the wild fill approval contents with plain text (e.g. braille + * ASCII art) instead of the NIP-72 stringified event. These must be skipped + * without even attempting a JSON parse. + */ + @Test + fun containedPostIgnoresNonJsonContent() { + assertNull(approvalWith("⠀⠀⠀⠐⣖⠢⢤⣄⡀⠀").containedPost()) + assertNull(approvalWith("Approved by moderator").containedPost()) + } + + /** Valid JSON that isn't a Nostr event (no pubkey field) must return null. */ + @Test + fun containedPostIgnoresNonEventJson() { + assertNull(approvalWith("""{"approvedEvent":{"kind":1111},"moderation":{"action":"approve"}}""").containedPost()) + } +} From 8a34ae692f8bef101389812da10b53c72c393060 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:04:35 +0000 Subject: [PATCH 34/39] feat: adapt the app shell to large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layout tiers driven by the window width size class, published once through LocalScreenLayout (ScreenLayout.kt): - Compact (phones): unchanged — bottom bar + modal drawer. - Medium (portrait tablets, unfolded foldables): the bottom bar is replaced by a left NavigationRail built from the same user-configured BottomBarEntry list (customization, pinned favorites and new-item dots carry over); the drawer stays modal behind the rail's avatar button. - Expanded (landscape tablets, desktop windows): the drawer is permanently docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a docked notification panel renders the notifications card feed on the right, sharing last-read marking with the full screen. The panel hides while the Notifications screen itself is open. Large screens also pin the chrome: DisappearingScaffold stops hiding the top/bottom bars on scroll (and stops toggling the OS status bar), and AppBottomBar renders nothing everywhere. Feed content width is capped at 600dp inside wide center panes: the shell measures the center pane and provides (paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which rememberFeedContentPadding merges into every feed's contentPadding — the scroll surface stays full-width so pull-to-refresh and edge scrolling keep working. Panes that manage their own width (Messages two-pane, the notification panel) override it back to 0. Screen sweep: Messages now picks single/two-pane from its actual pane width instead of the window size class; the Home/Messages pagers only attach the drawer edge-swipe when a modal drawer exists; top-bar avatar drawer-openers hide on large screens; FABs keep their bottom spacing without the bar; the status editor in the drawer no longer cancels editing when the drawer is permanent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 10 +- .../amethyst/ui/layouts/ScreenLayout.kt | 108 ++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 19 +++ .../ui/navigation/bottombars/AppBottomBar.kt | 5 + .../bottombars/AppNavigationRail.kt | 155 ++++++++++++++++++ .../bottombars/FabBottomBarPadding.kt | 9 +- .../ui/navigation/drawer/DrawerContent.kt | 128 ++++++++++----- .../topbars/UserDrawerSearchTopBar.kt | 7 +- .../AccountSwitcherAndLeftDrawerLayout.kt | 140 +++++++++++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 67 ++++---- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +- .../chats/rooms/twopane/MessagesTwoPane.kt | 134 ++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +- .../loggedIn/napplets/NappletsTopBar.kt | 3 +- .../notifications/NotificationSidePanel.kt | 127 ++++++++++++++ .../commons/ui/layouts/PaddingMerge.kt | 22 ++- 17 files changed, 797 insertions(+), 174 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 4611f09a70..fb56c99551 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -38,6 +38,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" + const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 6f36f0e63f..fd8e8ab850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -80,10 +80,14 @@ fun DisappearingScaffold( ) { val state = rememberDisappearingBarState() + // Large screens (rail / permanent drawer) pin the chrome: bars never slide away on + // scroll, and the immersive status-bar hiding stays off. + val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen + // Hold the latest values in state so the NSC's captured lambda stays fresh across // recompositions without rebuilding the NSC itself. val latestIsActive by rememberUpdatedState(isActive) - val latestAllowBarHide by rememberUpdatedState(allowBarHide) + val latestAllowBarHide by rememberUpdatedState(canHideBars) val latestAccountViewModel by rememberUpdatedState(accountViewModel) val connection = @@ -100,7 +104,7 @@ fun DisappearingScaffold( } // Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars. - if (allowBarHide) { + if (canHideBars) { ResetBarsOnResume(state) ImmersiveStatusBarEffect(state) } @@ -110,7 +114,7 @@ fun DisappearingScaffold( // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text // color falls back to Color.Black and is invisible on the dark theme). val baseModifier = - if (allowBarHide) { + if (canHideBars) { Modifier.imePadding().nestedScroll(connection) } else { Modifier.imePadding() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt new file mode 100644 index 0000000000..ae373a5ed6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -0,0 +1,108 @@ +/* + * 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.layouts + +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.getActivity + +/** How the app shell presents its top-level navigation for the current window size. */ +enum class NavigationStyle { + /** Compact windows (phones): bottom navigation bar + modal drawer. */ + BOTTOM_BAR, + + /** + * Medium windows (portrait tablets, unfolded foldables): a left navigation rail + * replaces the bottom bar; the drawer stays modal behind the rail's avatar button. + */ + NAV_RAIL, + + /** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */ + PERMANENT_DRAWER, +} + +/** + * The shell layout decisions for the current window, published once per window size change + * through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier. + */ +@Immutable +data class ScreenLayoutSpec( + val navigationStyle: NavigationStyle, + val showsNotificationPanel: Boolean, +) { + /** + * True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin + * the top/bottom chrome (no disappearing bars on scroll). + */ + val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR + + companion object { + val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false) + } +} + +val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone } + +/** + * Minimum window width for the docked notification panel: the permanent drawer + * ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth]) + * only coexist comfortably from a landscape-tablet-sized window up. + */ +private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200 + +val PermanentDrawerWidth = 300.dp + +val NotificationPanelWidth = 360.dp + +/** + * Maximum width of a feed's content column inside a wide center pane. Wider than this, + * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; + * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + */ +val FeedContentMaxWidth = 600.dp + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberScreenLayoutSpec(): ScreenLayoutSpec { + val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass + val windowWidthDp = LocalConfiguration.current.screenWidthDp + return remember(widthSizeClass, windowWidthDp) { + val style = + when (widthSizeClass) { + WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER + WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL + else -> NavigationStyle.BOTTOM_BAR + } + ScreenLayoutSpec( + navigationStyle = style, + showsNotificationPanel = + style == NavigationStyle.PERMANENT_DRAWER && + windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d4866a71b0..0e25c9e778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -29,6 +29,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -59,6 +60,8 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -282,6 +285,22 @@ fun AppNavigation( ) { val nav = rememberNav() + // One layout decision per window size for the whole shell: bottom bar vs rail vs + // permanent drawer, plus the docked notification panel. Every screen, bar and panel + // below reads the same spec through LocalScreenLayout. + val screenLayout = rememberScreenLayoutSpec() + + CompositionLocalProvider(LocalScreenLayout provides screenLayout) { + AppNavigationLayers(accountViewModel, accountSessionManager, nav) + } +} + +@Composable +private fun AppNavigationLayers( + accountViewModel: AccountViewModel, + accountSessionManager: AccountSessionManager, + nav: Nav, +) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { BuildNavigation(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 145f145807..a635f485ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -73,6 +74,10 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Large screens replace the bottom bar with the navigation rail (Medium) or the + // permanently docked drawer (Expanded). + if (LocalScreenLayout.current.isLargeScreen) return + // Hide the bar on entries that aren't a tab root (drawer or in-app // pushes). Mirrors the back-arrow rule in canPop(). if (nav.canPop()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt new file mode 100644 index 0000000000..d90eb99281 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -0,0 +1,155 @@ +/* + * 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.bottombars + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.Size27Modifier +import com.vitorpamplona.amethyst.ui.theme.onSurface65 + +/** + * Medium-width windows: a left rail that carries the same user-configured destinations as the + * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the + * user's customization and new-item dots carry over. The header avatar opens the modal drawer, + * mirroring the avatar button in the phone top bars. + */ +@Composable +fun AppNavigationRail( + nav: Nav, + accountViewModel: AccountViewModel, +) { + val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favoritesById = remember(favorites) { favorites.associateBy { it.id } } + + // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + NavigationRail( + containerColor = MaterialTheme.colorScheme.background, + header = { + // Same affordance as the phone top bars: the account avatar opens the drawer. + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + }, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items.forEach { entry -> + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] ?: return@forEach + val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + val selected = currentDestination?.hasRoute(destination::class) == true + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + Icon( + symbol = def.icon, + contentDescription = stringRes(def.labelRes), + modifier = Size25Modifier, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + ) + AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + } + }, + ) + } + + is BottomBarEntry.Favorite -> { + val fav = favoritesById[entry.favoriteId] ?: return@forEach + val destination = + when (fav) { + is FavoriteApp.WebApp -> Route.WebApp(fav.url) + is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) + } + // Favorites carry arguments (url / coordinate), so class matching alone + // would light up every pinned app of the same kind; compare the full route. + val selected = + remember(navBackStackEntry, destination) { + when (destination) { + is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination + is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination + else -> false + } + } + val iconModel = + when (fav) { + is FavoriteApp.WebApp -> + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt index 18d199d594..c7fc2c4c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav /** @@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav val FABPaddingFromBottom = 30.dp @Composable -fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this +fun Modifier.fabBottomBarPadding(nav: INav): Modifier = + if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) { + // canPop entries hide the bar on phones; large screens never render it at all. + padding(bottom = FABPaddingFromBottom) + } else { + this + } /** * Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a4ba397054..2c2ed264f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -102,6 +104,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems @@ -147,59 +152,93 @@ fun DrawerContent( openSheet: () -> Unit, accountViewModel: AccountViewModel, ) { - val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) - nav.closeDrawer() - } - ModalDrawerSheet( windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), drawerContainerColor = MaterialTheme.colorScheme.background, drawerTonalElevation = 0.dp, + ) { + DrawerContentBody(nav, openSheet, accountViewModel) + } +} + +/** + * Expanded windows: the same drawer content, permanently docked on the left of the shell + * instead of sliding in as a modal sheet. + */ +@Composable +fun PermanentDrawerContent( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + Surface( + modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, ) { Column( - Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()), + Modifier.windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), + ), ) { - ProfileContent( - baseAccountUser = accountViewModel.account.userProfile(), - modifier = profileContentHeaderModifier, - accountViewModel, - onClickUser, - ) - - Column(drawerSpacing) { - EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) - } - - FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) - - HorizontalDivider( - thickness = DividerThickness, - modifier = Modifier.padding(top = 20.dp), - ) - - Spacer(modifier = StdHorzSpacer) - - ListContent( - modifier = Modifier.fillMaxWidth(), - openSheet, - accountViewModel, - nav, - ) - - Spacer(modifier = Modifier.weight(1f)) - - BottomContent( - accountViewModel.account.userProfile(), - accountViewModel, - nav, - ) + DrawerContentBody(nav, openSheet, accountViewModel) } } } +@Composable +private fun DrawerContentBody( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + val onClickUser = { + nav.nav(routeFor(accountViewModel.userProfile())) + nav.closeDrawer() + } + + Column( + Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + ProfileContent( + baseAccountUser = accountViewModel.account.userProfile(), + modifier = profileContentHeaderModifier, + accountViewModel, + onClickUser, + ) + + Column(drawerSpacing) { + EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) + } + + FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) + + HorizontalDivider( + thickness = DividerThickness, + modifier = Modifier.padding(top = 20.dp), + ) + + Spacer(modifier = StdHorzSpacer) + + ListContent( + modifier = Modifier.fillMaxWidth(), + openSheet, + accountViewModel, + nav, + ) + + Spacer(modifier = Modifier.weight(1f)) + + BottomContent( + accountViewModel.account.userProfile(), + accountViewModel, + nav, + ) + } +} + @Composable fun ProfileContent( baseAccountUser: User, @@ -392,8 +431,11 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // is always on screen), so the modal close-cancels-editing behavior must not apply there. + val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (nav.drawerState.isClosed) { + if (!isPermanentDrawer && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index efdcfff438..36ca168941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -65,12 +66,14 @@ fun UserDrawerSearchTopBar( navigationIcon = { // When this screen sits on top of a back stack (entered via the drawer // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener. + // the bottom nav, which clears the stack), show the drawer opener — + // unless a large-screen shell already shows the drawer (rail avatar or + // permanently docked pane). if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index d4c7090b08..494cce6135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.SheetValue +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,15 +42,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent +import com.vitorpamplona.amethyst.ui.navigation.drawer.PermanentDrawerContent import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSidePanel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -75,6 +91,51 @@ fun AccountSwitcherAndLeftDrawerLayout( } } + when (LocalScreenLayout.current.navigationStyle) { + NavigationStyle.PERMANENT_DRAWER -> + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + + NavigationStyle.NAV_RAIL -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + + NavigationStyle.BOTTOM_BAR -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + } + + // Sheet content + if (openAccountSwitcherBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + openAccountSwitcherBottomSheet = false + } + } + }, + sheetState = sheetState, + ) { + AccountSwitchBottomSheet( + accountViewModel = accountViewModel, + accountSessionManager = accountSessionManager, + ) + } + } +} + +/** + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. + */ +@Composable +private fun ModalDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + showRail: Boolean, + content: @Composable () -> Unit, +) { val orientation = LocalConfiguration.current.orientation val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { @@ -104,30 +165,69 @@ fun AccountSwitcherAndLeftDrawerLayout( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, drawerContent = { - DrawerContent(nav, openSheetFunction, accountViewModel) + DrawerContent(nav, openSheet, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) }, - content = content, + content = { + if (showRail) { + Row(Modifier.fillMaxSize()) { + AppNavigationRail(nav, accountViewModel) + VerticalDivider(thickness = DividerThickness) + CenterPane(Modifier.weight(1f), content) + } + } else { + content() + } + }, ) +} - // Sheet content - if (openAccountSwitcherBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - openAccountSwitcherBottomSheet = false - } - } - }, - sheetState = sheetState, - ) { - AccountSwitchBottomSheet( - accountViewModel = accountViewModel, - accountSessionManager = accountSessionManager, - ) +/** + * Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears, + * and — when the window is wide enough — the notification feed docks on the right. + */ +@Composable +private fun PermanentDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + content: @Composable () -> Unit, +) { + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + // The panel duplicates the Notifications screen, so it steps aside while the user is there. + val showPanel = + LocalScreenLayout.current.showsNotificationPanel && + navBackStackEntry?.destination?.hasRoute() != true + + Row(Modifier.fillMaxSize()) { + PermanentDrawerContent(nav, openSheet, accountViewModel) + + VerticalDivider(thickness = DividerThickness) + + CenterPane(Modifier.weight(1f), content) + + if (showPanel) { + VerticalDivider(thickness = DividerThickness) + NotificationSidePanel(accountViewModel, nav) + } + } +} + +/** + * Hosts the navigation content and publishes the side padding feeds need to cap their content + * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + */ +@Composable +private fun CenterPane( + modifier: Modifier, + content: @Composable () -> Unit, +) { + BoxWithConstraints(modifier) { + val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { + Box(Modifier.fillMaxSize()) { + content() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index ab8ac1842c..0ecafb852b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -20,57 +20,48 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.ui.components.getActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val act = LocalContext.current.getActivity() - val windowSizeClass = calculateWindowSizeClass(act) + // Decide single- vs two-pane from the pane this screen actually occupies, not the + // window: on large screens the shell already spends width on the rail / permanent + // drawer / notification panel, so window-level size classes would overestimate. + BoxWithConstraints(Modifier.fillMaxSize()) { + val paneWidth = maxWidth - val twoPane by remember(windowSizeClass.widthSizeClass) { - derivedStateOf { - when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, - -> true - - else -> false - } + if (paneWidth >= 600.dp) { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = + if (paneWidth >= 840.dp) { + WindowWidthSizeClass.Expanded + } else { + WindowWidthSizeClass.Medium + }, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MessagesSinglePane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + accountViewModel = accountViewModel, + nav = nav, + ) } } - - if (twoPane) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = windowSizeClass.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - MessagesSinglePane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - accountViewModel = accountViewModel, - nav = nav, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 805aaaba77..5d486b9323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -121,14 +123,22 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { + // The left-edge swipe opens the modal drawer; with the drawer permanently docked + // there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + modifier + }, ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 2cf453a10c..c71fb18663 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,22 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -72,9 +77,6 @@ fun MessagesTwoPane( } } - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -91,58 +93,78 @@ fun MessagesTwoPane( } }, ) { padding -> - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) + // Each pane manages its own width; the shell's center-pane reading cap must not + // re-pad the lists inside them. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + } } } + +@Composable +private fun TwoPaneContent( + padding: PaddingValues, + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + twoPaneNav: TwoPaneNav, + strategy: TwoPaneStrategy, + accountViewModel: AccountViewModel, + nav: INav, +) { + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index fc2f1a88ab..7621c99dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -76,6 +76,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -273,14 +275,22 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { + // The left-edge swipe opens the modal drawer; with the drawer permanently + // docked there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + Modifier + }, ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index e8183abcc3..210a126acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner @@ -73,7 +74,7 @@ fun NappletsTopBar( navigationIcon = { if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt new file mode 100644 index 0000000000..7f43ec4d17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -0,0 +1,127 @@ +/* + * 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.screen.loggedIn.notifications + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState +import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer + +/** + * The docked notification feed shown on very wide windows, to the right of the center pane. + * It renders the same card feed as the Notifications screen (same last-read marking, so + * reading here clears the new-item dot too); tapping its header opens the full screen, + * which keeps the summary chart and the Following/Everyone split. + */ +@Composable +fun NotificationSidePanel( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val notifFeedContentState = accountViewModel.feedStates.notifications + WatchAccountForNotifications(notifFeedContentState, accountViewModel) + + Column( + modifier + .width(NotificationPanelWidth) + .fillMaxHeight() + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, + ), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + // The panel is its own narrow pane; never apply the center pane's reading-width cap here. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + Box(Modifier.fillMaxWidth()) { + RefresheableBox(notifFeedContentState, true) { + val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) + + RenderCardFeed( + feedContent = notifFeedContentState, + pollContent = accountViewModel.feedStates.notificationsOpenPolls, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = NOTIFICATION_LAST_READ_KEY, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index eb2b94bd87..fa25234ba5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -48,6 +48,18 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) */ val LocalDisappearingBarState = compositionLocalOf { null } +/** + * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a + * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it + * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the + * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to + * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * + * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side + * panels, two-pane splits) must override this back to 0 for their subtree. + */ +val LocalFeedSidePadding = compositionLocalOf { 0.dp } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. @@ -70,7 +82,13 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: - * merges the scaffold's reserved space with the list's own baseline padding. + * merges the scaffold's reserved space with the list's own baseline padding, plus the + * [LocalFeedSidePadding] width cap requested by wide layouts. */ @Composable -fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) +fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { + val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val sidePadding = LocalFeedSidePadding.current + if (sidePadding <= 0.dp) return merged + return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) +} From c72024578e7b952834dc138281b786b660767324 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 04:31:01 +0000 Subject: [PATCH 35/39] fix: harden the large-screen shell against runtime window-size changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from an adversarially verified audit of the large-screen commit. The two most serious bugs shared a root cause: the shell was correct at any fixed size but mishandled the size CHANGING mid-session, which foldables and multi-window make routine (MainActivity handles those configChanges without recreation). Bug fixes: - Hoist the shell content into movableContentOf so crossing a layout tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between shells instead of disposing it — screen state (drafts, pager tabs, expanded states, warm embedded tabs) now survives. - DisappearingScaffold snaps bars back to visible when hiding gets disabled, so chrome scrolled away before a resize is no longer stranded off-screen with no reset path. - ProfileScreen keeps WindowInsets.navigationBars instead of zeroing all content insets; with the bottom bar gone (large screens, and pushed entries on phones) content no longer underlaps the system bar. - New TabReselectCoordinator: AppBottomBar registers each screen's re-tap handler even when the bar renders nothing, and the rail routes selected-item taps through it — restoring tap-current-tab-scrolls-to- top on the rail tier with the screens' existing logic. - NotificationSidePanel now reuses the screen's SingleNotificationsBody (parameterized by scroll-state key), which restores WatchScrollToTop — previously the panel stranded scrolltoTopPending=true on the shared feed state, suppressing later send-to-top requests — and the inbox- relay warning header; it also honors split notifications by showing the Following feed when that setting is on. - Entering the permanent-drawer tier snaps a stale Open drawerState to Closed, so returning to a modal tier no longer pops the drawer uninvited. - MessagesTwoPane keys its TwoPane strategy on the width size class so the split fraction updates when the pane crosses 840dp in place. - The drawer status editor calls onDone() after send/delete, so it can collapse back to the read-only bar in the docked drawer (and no longer waits for a drawer close in the modal one). - The landscape auto-close drawer effect's inverted condition (close-only-when-already-closed, a pre-existing no-op) now closes an open drawer as intended. Structure and performance: - INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer() no-ops while docked, and consumers stop inferring from a DrawerState that never transitions. - zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked check so call sites can't forget it; TopBarNavigationIcon centralizes the back-arrow/avatar-or-nothing leading slot. - The rail reuses AppBottomBar's entry icons (NotifiableIcon, FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating them. - MessagesScreen derives its pane size class via WindowSizeClass.calculateFromSize instead of restating the 600/840 breakpoints. - rememberFeedContentPadding folds the scaffold, baseline, and side paddings into one remember slot; the shell quantizes the feed side padding to 8dp steps so continuous resizes don't invalidate every feed per pixel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 8 ++ .../amethyst/ui/navigation/AppNavigation.kt | 84 ++++++----- .../ui/navigation/bottombars/AppBottomBar.kt | 82 +++++++---- .../bottombars/AppNavigationRail.kt | 55 ++------ .../bottombars/TabReselectCoordinator.kt | 67 +++++++++ .../ui/navigation/drawer/DrawerContent.kt | 13 +- .../ui/navigation/navs/DrawerSwipe.kt | 44 ++++++ .../amethyst/ui/navigation/navs/INav.kt | 8 ++ .../amethyst/ui/navigation/navs/Nav.kt | 8 ++ .../topbars/UserDrawerSearchTopBar.kt | 35 +++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 59 +++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 35 ++--- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +-- .../chats/rooms/twopane/MessagesTwoPane.kt | 132 ++++++++---------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +-- .../loggedIn/napplets/NappletsTopBar.kt | 12 +- .../notifications/NotificationScreen.kt | 11 +- .../notifications/NotificationSidePanel.kt | 56 +++++--- .../screen/loggedIn/profile/ProfileScreen.kt | 6 +- .../commons/ui/layouts/PaddingMerge.kt | 21 ++- 21 files changed, 466 insertions(+), 307 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index fb56c99551..a6595089cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -39,6 +39,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" + const val NOTIFICATION_SIDE_PANEL_FOLLOWING = "NotificationsSidePanelFollowing" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index fd8e8ab850..40b32b4327 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -109,6 +109,14 @@ fun DisappearingScaffold( ImmersiveStatusBarEffect(state) } + // If the bars were scrolled away when hiding got disabled (e.g. the window grew to a + // large tier mid-scroll), nothing above can bring them back — the nested-scroll + // connection and the resume reset are gone. Snap them visible here instead of + // leaving the chrome stranded off-screen. + LaunchedEffect(canHideBars, state) { + if (!canHideBars) state.resetToVisible() + } + // When bars are pinned, skip attaching the nested-scroll connection entirely. // The outer Surface provides the Material container color + onBackground as // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0e25c9e778..bf8203d6f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec +import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator +import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -287,54 +289,50 @@ fun AppNavigation( // One layout decision per window size for the whole shell: bottom bar vs rail vs // permanent drawer, plus the docked notification panel. Every screen, bar and panel - // below reads the same spec through LocalScreenLayout. + // below reads the same spec through LocalScreenLayout. The provider wraps this whole + // function body so anything added to AppNavigation later is inside it by construction. val screenLayout = rememberScreenLayoutSpec() + val tabReselectCoordinator = remember { TabReselectCoordinator() } - CompositionLocalProvider(LocalScreenLayout provides screenLayout) { - AppNavigationLayers(accountViewModel, accountSessionManager, nav) - } -} - -@Composable -private fun AppNavigationLayers( - accountViewModel: AccountViewModel, - accountSessionManager: AccountSessionManager, - nav: Nav, -) { - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { - Box(Modifier.fillMaxSize()) { - BuildNavigation(accountViewModel, nav) - // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) - // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded - // preloader below and the full-screen launcher both need. Not API-gated: every device's - // launcher benefits, and it's the only preload step that runs below API 30. - FavoriteAppManifestPreloader(accountViewModel) - // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by - // holding their surfaces attached. Below the drawer (drawn by the layout above) and below - // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems - .collectAsStateWithLifecycle() - EmbeddedTabLayer(bottomBarItems.favoriteIds()) - // Warm every pinned tab at startup so the first tap is instant (content already local). - EmbeddedTabPreloader(accountViewModel) - // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips - // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). - EmbeddedTabThemeWatcher() + CompositionLocalProvider( + LocalScreenLayout provides screenLayout, + LocalTabReselectCoordinator provides tabReselectCoordinator, + ) { + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + Box(Modifier.fillMaxSize()) { + BuildNavigation(accountViewModel, nav) + // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) + // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded + // preloader below and the full-screen launcher both need. Not API-gated: every device's + // launcher benefits, and it's the only preload step that runs below API 30. + FavoriteAppManifestPreloader(accountViewModel) + // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by + // holding their surfaces attached. Below the drawer (drawn by the layout above) and below + // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + EmbeddedTabLayer(bottomBarItems.favoriteIds()) + // Warm every pinned tab at startup so the first tap is instant (content already local). + EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() + } } } + + TrackScreenTime(nav) + NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) + + DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) + DisplayNotifyMessages(accountViewModel, nav) + DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) + DisplayBroadcastProgress(accountViewModel) + + ObserveIncomingCalls(accountViewModel) } - - TrackScreenTime(nav) - NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) - - DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) - DisplayNotifyMessages(accountViewModel, nav) - DisplayCrashMessages(accountViewModel, nav) - DisplayResourceUsageAlert(accountViewModel, nav) - DisplayBroadcastProgress(accountViewModel) - - ObserveIncomingCalls(accountViewModel) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index a635f485ac..930f08d4d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -74,6 +76,18 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Publish this screen's re-tap behavior even when the bar renders nothing: on large + // screens the navigation rail routes reselect taps back through the coordinator so the + // same per-screen scroll-to-top/refresh logic runs. + val coordinator = LocalTabReselectCoordinator.current + val latestRoute by rememberUpdatedState(selectedRoute) + val latestOnClick by rememberUpdatedState(onClick) + DisposableEffect(coordinator) { + val handler: (Route) -> Unit = { latestOnClick(it) } + coordinator.register({ latestRoute }, handler) + onDispose { coordinator.unregister(handler) } + } + // Large screens replace the bottom bar with the navigation rail (Medium) or the // permanently docked drawer (Expanded). if (LocalScreenLayout.current.isLargeScreen) return @@ -106,6 +120,43 @@ fun AppBottomBar( } } +/** + * Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the + * generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the + * iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and + * the navigation rail. + */ +@Composable +internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? = + when (fav) { + is FavoriteApp.WebApp -> { + // Captured favicons, keyed so the icon appears once the site's capture lands. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + } + + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + +/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */ +@Composable +internal fun FavoriteEntryIcon( + fav: FavoriteApp, + selected: Boolean, + iconModel: Any?, +) { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } +} + @Composable private fun RenderBottomMenu( items: List, @@ -117,9 +168,6 @@ private fun RenderBottomMenu( // Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan. val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - Column( modifier = Modifier @@ -152,17 +200,7 @@ private fun RenderBottomMenu( is FavoriteApp.WebApp -> Route.WebApp(fav.url) is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) } - // A web favorite uses its captured favicon; an nsite/napplet uses the verified - // icon blob bundled in its own content (the iframe sandbox rules out live capture). - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } - FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav) + FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav) } } } @@ -180,18 +218,7 @@ private fun RowScope.FavoriteNavItem( ) { NavigationBarItem( alwaysShowLabel = false, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - // A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else - // the grid glyph). - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) - } - }, + icon = { FavoriteEntryIcon(fav, selected, iconModel) }, // No label — favorite tabs match the built-in items, which show icon only. selected = selected, onClick = { nav(destination) }, @@ -221,8 +248,9 @@ private fun RowScope.HasNewItemsIcon( ) } +/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */ @Composable -private fun NotifiableIcon( +internal fun NotifiableIcon( selected: Boolean, def: NavBarItemDef, destination: Route, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index d90eb99281..be8c77035e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -35,28 +34,20 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp -import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry -import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size25Modifier -import com.vitorpamplona.amethyst.ui.theme.Size27Modifier -import com.vitorpamplona.amethyst.ui.theme.onSurface65 /** * Medium-width windows: a left rail that carries the same user-configured destinations as the * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the * user's customization and new-item dots carry over. The header avatar opens the modal drawer, - * mirroring the avatar button in the phone top bars. + * mirroring the avatar button in the phone top bars. Re-tapping the selected item routes + * through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler. */ @Composable fun AppNavigationRail( @@ -68,8 +59,7 @@ fun AppNavigationRail( val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val reselectCoordinator = LocalTabReselectCoordinator.current val navBackStackEntry by nav.controller.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -93,18 +83,14 @@ fun AppNavigationRail( val selected = currentDestination?.hasRoute(destination::class) == true NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - Icon( - symbol = def.icon, - contentDescription = stringRes(def.labelRes), - modifier = Size25Modifier, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - ) - AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { NotifiableIcon(selected, def, destination, accountViewModel) }, ) } @@ -125,27 +111,16 @@ fun AppNavigationRail( else -> false } } - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt new file mode 100644 index 0000000000..b67bc5c66c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt @@ -0,0 +1,67 @@ +/* + * 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.bottombars + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.ui.navigation.routes.Route + +/** + * Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in + * each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...). + * + * On phones the bottom bar invokes the screen's lambda directly. On large screens the bar + * renders nothing, but it still registers the screen's lambda here; when the user taps the + * rail item that is already selected, the rail routes the tap back through [reselect] so the + * exact same per-screen logic runs. + */ +@Stable +class TabReselectCoordinator { + private var currentRoute: (() -> Route?)? = null + private var currentHandler: ((Route) -> Unit)? = null + + fun register( + route: () -> Route?, + handler: (Route) -> Unit, + ) { + currentRoute = route + currentHandler = handler + } + + /** Unregisters only if [handler] is still the active one, so a newly composed screen's + * registration is not torn down by the outgoing screen's dispose during a transition. */ + fun unregister(handler: (Route) -> Unit) { + if (currentHandler === handler) { + currentHandler = null + currentRoute = null + } + } + + /** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */ + fun reselect(route: Route): Boolean { + val handler = currentHandler ?: return false + if (currentRoute?.invoke() != route) return false + handler(route) + return true + } +} + +val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 2c2ed264f8..bb6f93f288 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -104,8 +104,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems @@ -431,11 +429,10 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // In the docked drawer the DrawerState never opens (it stays Closed while the drawer // is always on screen), so the modal close-cancels-editing behavior must not apply there. - val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (!isPermanentDrawer && nav.drawerState.isClosed) { + if (!nav.isDrawerDocked && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { @@ -469,6 +466,10 @@ fun StatusEditBar( } focusManager.clearFocus(true) + // Collapse back to the read-only bar: in the docked drawer no + // drawer-close will ever do it, and in the modal drawer this beats + // staying in edit mode until the drawer closes. + onDone() }, ), singleLine = true, @@ -484,12 +485,14 @@ fun StatusEditBar( accountViewModel.updateStatus(address, currentStatus.value) } focusManager.clearFocus(true) + onDone() } } else { if (address != null) { UserStatusDeleteButton { accountViewModel.deleteStatus(address) focusManager.clearFocus(true) + onDone() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt new file mode 100644 index 0000000000..0380e182b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt @@ -0,0 +1,44 @@ +/* + * 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.navs + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe + +/** + * [zonedDrawerSwipe] gated on the drawer actually being modal. With the drawer permanently + * docked ([INav.isDrawerDocked]) there is nothing to open, so the left-edge swipe zone is + * not attached at all and the pager keeps its own gestures. Use this instead of calling + * [zonedDrawerSwipe] directly from screens — the guard then can't be forgotten at new + * call sites. + */ +@Composable +fun Modifier.zonedDrawerSwipeIfModal( + pagerState: PagerState, + nav: INav, +): Modifier = + if (nav.isDrawerDocked) { + this + } else { + zonedDrawerSwipe(pagerState, nav::openDrawer) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt index f7ceff737b..229b86e212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt @@ -32,6 +32,14 @@ interface INav { val navigationScope: CoroutineScope val drawerState: DrawerState + /** + * True while the shell renders the drawer permanently docked (Expanded windows). In that + * mode [drawerState] never transitions — it stays [androidx.compose.material3.DrawerValue.Closed] + * while the drawer is visibly on screen — so drawer consumers (edge swipes, open buttons, + * close-driven effects) should consult this instead of inferring from [drawerState]. + */ + val isDrawerDocked: Boolean get() = false + fun closeDrawer() fun openDrawer() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 05ca546fd7..1578cbc853 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -44,11 +46,17 @@ class Nav( ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) + /** Set by the shell when the layout tier docks the drawer permanently. */ + override var isDrawerDocked: Boolean by mutableStateOf(false) + override fun closeDrawer() { navigationScope.launch { drawerState.close() } } override fun openDrawer() { + // Nothing renders the modal drawer while it is docked; opening the state would + // only strand an Open value for the next modal tier to trip over. + if (isDrawerDocked) return navigationScope.launch { drawerState.open() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 36ca168941..da7fcbbb4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -63,20 +63,7 @@ fun UserDrawerSearchTopBar( content() } }, - navigationIcon = { - // When this screen sits on top of a back stack (entered via the drawer - // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener — - // unless a large-screen shell already shows the drawer (rail avatar or - // permanently docked pane). - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { - ArrowBackIcon() - } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) @@ -85,6 +72,26 @@ fun UserDrawerSearchTopBar( ) } +/** + * The standard leading slot for root-capable top bars: a back arrow when the screen sits on + * top of a back stack; otherwise the avatar drawer-opener — unless a large-screen shell + * already presents the drawer (rail avatar or permanently docked pane), in which case + * nothing is shown. Use this instead of hand-writing the branches per top bar. + */ +@Composable +fun TopBarNavigationIcon( + accountViewModel: AccountViewModel, + nav: INav, +) { + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + } else if (!LocalScreenLayout.current.isLargeScreen) { + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + } +} + @Composable internal fun LoggedInUserPictureDrawer( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 494cce6135..74733ffad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,13 +37,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState @@ -91,15 +94,30 @@ fun AccountSwitcherAndLeftDrawerLayout( } } - when (LocalScreenLayout.current.navigationStyle) { - NavigationStyle.PERMANENT_DRAWER -> - PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + // The layout tier can change while the app runs (fold/unfold, rotation, window resize) + // and the shells below place `content` at different composition positions. Movable + // content lets the whole NavHost subtree MOVE between those positions instead of being + // disposed and rebuilt, preserving every screen's remember/rememberSaveable state. + val currentContent by rememberUpdatedState(content) + val movableContent = remember { movableContentOf { currentContent() } } - NavigationStyle.NAV_RAIL -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + val docked = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER - NavigationStyle.BOTTOM_BAR -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + // Publish docked-ness on the Nav so drawer consumers (openDrawer, edge swipes, the + // status editor) can behave correctly without each re-deriving the layout tier. + LaunchedEffect(docked) { + nav.isDrawerDocked = docked + // Entering the permanent tier with the modal drawer still Open would otherwise + // carry the stale Open value back to the modal tier and pop the drawer uninvited. + if (docked && !nav.drawerState.isClosed) { + nav.drawerState.snapTo(DrawerValue.Closed) + } + } + + if (docked) { + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) + } else { + ModalDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) } // Sheet content @@ -125,7 +143,7 @@ fun AccountSwitcherAndLeftDrawerLayout( } /** - * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. */ @Composable @@ -133,15 +151,13 @@ private fun ModalDrawerShell( accountViewModel: AccountViewModel, nav: Nav, openSheet: () -> Unit, - showRail: Boolean, content: @Composable () -> Unit, ) { val orientation = LocalConfiguration.current.orientation - val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { - if ( - orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed - ) { + // Dismiss an open drawer when the device rotates to landscape; the layout + // underneath changes too much for the sheet to stay meaningful. + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { nav.drawerState.close() } } @@ -161,6 +177,8 @@ private fun ModalDrawerShell( // so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer. !EmbeddedSelectionDrag.dragging + val showRail = LocalScreenLayout.current.navigationStyle == NavigationStyle.NAV_RAIL + ModalNavigationDrawer( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, @@ -213,6 +231,10 @@ private fun PermanentDrawerShell( } } +/** Step size for the feed side padding, so continuous window resizes republish the value + * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ +private val SidePaddingQuantum = 8.dp + /** * Hosts the navigation content and publishes the side padding feeds need to cap their content * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. @@ -222,12 +244,11 @@ private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier) { - val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + BoxWithConstraints(modifier.fillMaxHeight()) { + val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - Box(Modifier.fillMaxSize()) { - content() - } + content() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index 0ecafb852b..514fccede2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -22,15 +22,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, @@ -39,29 +42,29 @@ fun MessagesScreen( // Decide single- vs two-pane from the pane this screen actually occupies, not the // window: on large screens the shell already spends width on the rail / permanent // drawer / notification panel, so window-level size classes would overestimate. + // calculateFromSize keeps the Compact/Medium/Expanded breakpoints in one place + // (Material's) instead of restating 600/840 here. BoxWithConstraints(Modifier.fillMaxSize()) { - val paneWidth = maxWidth + val paneWidthClass = + WindowSizeClass + .calculateFromSize(DpSize(maxWidth, maxHeight)) + .widthSizeClass - if (paneWidth >= 600.dp) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = - if (paneWidth >= 840.dp) { - WindowWidthSizeClass.Expanded - } else { - WindowWidthSizeClass.Medium - }, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { + if (paneWidthClass == WindowWidthSizeClass.Compact) { MessagesSinglePane( knownFeedContentState = accountViewModel.feedStates.dmKnown, newFeedContentState = accountViewModel.feedStates.dmNew, accountViewModel = accountViewModel, nav = nav, ) + } else { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = paneWidthClass, + accountViewModel = accountViewModel, + nav = nav, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 5d486b9323..157702ec24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -42,14 +42,12 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -123,22 +121,10 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { - // The left-edge swipe opens the modal drawer; with the drawer permanently docked - // there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - modifier - }, + modifier = modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index c71fb18663..9d16c3ea38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -38,7 +37,6 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane -import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding @@ -68,8 +66,11 @@ fun MessagesTwoPane( val scope = rememberCoroutineScope() val twoPaneNav = remember { TwoPaneNav(nav, scope) } + // Keyed on the size class: the pane can cross the Medium/Expanded boundary while this + // screen stays composed (window resize, the notification panel docking/undocking), and + // an unkeyed remember would keep serving the stale split fraction. val strategy = - remember { + remember(widthSizeClass) { if (widthSizeClass == WindowWidthSizeClass.Expanded) { HorizontalTwoPaneStrategy(splitFraction = 1f / 3f) } else { @@ -77,6 +78,9 @@ fun MessagesTwoPane( } } + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -96,75 +100,59 @@ fun MessagesTwoPane( // Each pane manages its own width; the shell's center-pane reading cap must not // re-pad the lists inside them. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } } - -@Composable -private fun TwoPaneContent( - padding: PaddingValues, - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - twoPaneNav: TwoPaneNav, - strategy: TwoPaneStrategy, - accountViewModel: AccountViewModel, - nav: INav, -) { - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 7621c99dc5..9dcf4352e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding @@ -76,11 +75,10 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -275,22 +273,10 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { - // The left-edge swipe opens the modal drawer; with the drawer permanently - // docked there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - Modifier - }, + modifier = Modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index 210a126acc..96dfa7982b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,13 +36,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner -import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,13 +69,7 @@ fun NappletsTopBar( NappletsTopNavFilterBar(accountViewModel) } }, - navigationIcon = { - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.ConnectedApps) }) { Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index b3e6823b45..d9ad7b2826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -214,16 +214,23 @@ private fun SplitNotificationsScaffold( } } +/** + * The refreshable notifications card feed (list + scroll-to-top watcher + inbox-relay + * warning header). Shared between the Notifications screen and the docked + * [NotificationSidePanel], parameterized by the persisted scroll-state key so each + * surface keeps its own position. + */ @Composable -private fun SingleNotificationsBody( +internal fun SingleNotificationsBody( notifFeedContentState: CardFeedContentState, notifPolls: OpenPollsState, scrollToEventId: String?, accountViewModel: AccountViewModel, nav: INav, + scrollStateKey: String = ScrollStateKeys.NOTIFICATION_SCREEN, ) { RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + val listState = rememberForeverLazyListState(scrollStateKey) WatchScrollToTop(notifFeedContentState, listState) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 7f43ec4d17..598be6ef1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -39,30 +39,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size12dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer /** * The docked notification feed shown on very wide windows, to the right of the center pane. - * It renders the same card feed as the Notifications screen (same last-read marking, so - * reading here clears the new-item dot too); tapping its header opens the full screen, - * which keeps the summary chart and the Following/Everyone split. + * It renders the same card feed body as the Notifications screen (same last-read marking — + * cards mark themselves read as they become visible here, exactly as they would on the + * screen, so the new-item dot only lights for items the panel hasn't displayed). When the + * user has split notifications enabled, the panel shows the Following feed to match the + * screen's default tab. Tapping the header opens the full screen, which adds the summary + * chart and the Following/Everyone tabs. */ @Composable fun NotificationSidePanel( @@ -70,7 +75,22 @@ fun NotificationSidePanel( nav: INav, modifier: Modifier = Modifier, ) { - val notifFeedContentState = accountViewModel.feedStates.notifications + val split by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + val notifFeedContentState = + if (split) { + accountViewModel.feedStates.notificationsFollowing + } else { + accountViewModel.feedStates.notifications + } + val scrollStateKey = + if (split) { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL_FOLLOWING + } else { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL + } + WatchAccountForNotifications(notifFeedContentState, accountViewModel) Column( @@ -88,7 +108,7 @@ fun NotificationSidePanel( Modifier .fillMaxWidth() .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = Size16dp, vertical = Size12dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -108,19 +128,15 @@ fun NotificationSidePanel( // The panel is its own narrow pane; never apply the center pane's reading-width cap here. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.fillMaxWidth()) { - RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) - - RenderCardFeed( - feedContent = notifFeedContentState, - pollContent = accountViewModel.feedStates.notificationsOpenPolls, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = NOTIFICATION_LAST_READ_KEY, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 78bd24ab41..3a1cf39a2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager @@ -335,7 +336,10 @@ fun ProfileScreen( topBar = { ProfileTopBar(baseUser, accountViewModel, nav) }, - contentWindowInsets = WindowInsets(0), + // Status-bar handling is done inside RenderSurface, but the bottom inset must stay: + // when AppBottomBar renders nothing (pushed entries on phones, all large-screen + // tiers) this inset is the only thing keeping content clear of the system nav bar. + contentWindowInsets = WindowInsets.navigationBars, bottomBar = { AppBottomBar( Route.Profile(accountViewModel.userProfile().pubkeyHex), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index fa25234ba5..f66a2c42a9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -55,8 +55,9 @@ val LocalDisappearingBarState = compositionLocalOf { null * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). * - * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side - * panels, two-pane splits) must override this back to 0 for their subtree. + * Panes that manage their own width — side panels, the panes of a two-pane split — must + * override this back to 0 for their subtree: the shell's value was computed against the full + * center pane and would over-pad a narrower list. */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } @@ -83,12 +84,20 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: * merges the scaffold's reserved space with the list's own baseline padding, plus the - * [LocalFeedSidePadding] width cap requested by wide layouts. + * [LocalFeedSidePadding] width cap requested by wide layouts — all folded into a single + * remember slot, since this runs in every feed on every recomposition. */ @Composable fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { - val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val outer = LocalDisappearingScaffoldPadding.current val sidePadding = LocalFeedSidePadding.current - if (sidePadding <= 0.dp) return merged - return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) + val layoutDirection = LocalLayoutDirection.current + return remember(outer, inner, sidePadding, layoutDirection) { + PaddingValues( + start = outer.calculateStartPadding(layoutDirection) + inner.calculateStartPadding(layoutDirection) + sidePadding, + top = outer.calculateTopPadding() + inner.calculateTopPadding(), + end = outer.calculateEndPadding(layoutDirection) + inner.calculateEndPadding(layoutDirection) + sidePadding, + bottom = outer.calculateBottomPadding() + inner.calculateBottomPadding(), + ) + } } From 6b6f0fd2aefe9176fd3a20ab153f691530fa441f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:41:34 +0000 Subject: [PATCH 36/39] feat: cap every screen to the reading-column width on wide panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping only the feeds' contentPadding left top bars, list screens, bookmarks and settings stretched across the whole center pane. Move the cap up a level: every NavHost destination is wrapped in CappedScreenContent (600dp, centered) through the shared route builders in NavigationEffects, so each screen's entire surface — top bar, tabs, content — shares one reading column, on all ~200 destinations at once. Opt-outs at registration: Route.Message keeps the full pane for its two-pane list/conversation split, and Browser/WebApp/NostrApp stay full-pane so the warm EmbeddedTabLayer surfaces keep lining up. This supersedes the LocalFeedSidePadding-based capping on Android: the shell no longer provides side padding (CenterPane is a plain Box again) and the now-dead overrides in MessagesTwoPane and NotificationSidePanel are removed. The commons local stays, documented as the padding-based alternative for hosts like a desktop reading column where gutters should still scroll. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/ScreenLayout.kt | 39 ++++++- .../amethyst/ui/navigation/AppNavigation.kt | 14 +-- .../ui/navigation/NavigationEffects.kt | 66 +++++++++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 24 +--- .../chats/rooms/twopane/MessagesTwoPane.kt | 103 ++++++++---------- .../notifications/NotificationSidePanel.kt | 24 ++-- .../commons/ui/layouts/PaddingMerge.kt | 16 +-- 7 files changed, 169 insertions(+), 117 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt index ae373a5ed6..57b66bc8b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass @@ -27,6 +32,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.components.getActivity @@ -80,12 +87,38 @@ val PermanentDrawerWidth = 300.dp val NotificationPanelWidth = 360.dp /** - * Maximum width of a feed's content column inside a wide center pane. Wider than this, - * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; - * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + * Maximum width of a screen's content column inside a wide center pane. Every NavHost + * destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects), + * so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading + * column instead of stretching across the pane. Screens that genuinely need the full pane + * (Messages' two-pane split, the embedded browser surfaces) opt out at registration. */ val FeedContentMaxWidth = 600.dp +/** + * Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme + * background so the gutters match the screens' own surfaces; on Compact windows the cap is + * wider than the pane and this is a visual no-op. + */ +@Composable +fun CappedScreenContent(content: @Composable () -> Unit) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .widthIn(max = FeedContentMaxWidth) + .fillMaxSize(), + ) { + content() + } + } +} + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberScreenLayoutSpec(): ScreenLayoutSpec { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index bf8203d6f5..087ebdb43c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -380,9 +380,9 @@ fun BuildNavigation( enterTransition = { fadeIn(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) }, ) { - composable { HomeScreen(accountViewModel, nav) } + composableCapped { HomeScreen(accountViewModel, nav) } composable { MessagesScreen(accountViewModel, nav) } - composable { VideoScreen(accountViewModel, nav) } + composableCapped { VideoScreen(accountViewModel, nav) } composableArgs { DiscoverScreen(it.initialTab, accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } @@ -399,10 +399,10 @@ fun BuildNavigation( composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEnd { NappletsScreen(accountViewModel, nav) } composableFromEnd { NsitesScreen(accountViewModel, nav) } - composableFromEnd { BrowserScreen(accountViewModel, nav) } + composableFromEnd(capWidth = false) { BrowserScreen(accountViewModel, nav) } composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } - composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } @@ -441,7 +441,7 @@ fun BuildNavigation( composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } - composable { ChessLobbyScreen(accountViewModel, nav) } + composableCapped { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } composableFromEndArgs { WalletSendScreen(it.walletId, accountViewModel, nav) } @@ -494,7 +494,7 @@ fun BuildNavigation( composableFromBottomArgs { TopUpMintScreen(it.mintUrl, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } - composable { SearchScreen(accountViewModel, nav) } + composableCapped { SearchScreen(accountViewModel, nav) } composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index cf4a4ee7e9..8b16865a31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -33,6 +33,7 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent // Per-entry hint stamped by Nav.navBottomBar marking that the entry was // reached via a bottom-nav tab. Used in two places: @@ -44,41 +45,84 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true -inline fun NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +/** + * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless + * it opted out with `capWidth = false`. Every builder below routes through this, so all + * destinations — top bars included — share the centered column on large screens by default. + */ +@Composable +fun MaybeCappedScreen( + capWidth: Boolean, + content: @Composable () -> Unit, +) { + if (capWidth) { + CappedScreenContent(content) + } else { + content() + } +} + +/** Stock fade-transition destination, capped to the reading-column width on wide panes. */ +inline fun NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { + composable { entry -> + CappedScreenContent { content(entry) } + } +} + +inline fun NavGraphBuilder.composableFromEnd( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromEnd { +inline fun NavGraphBuilder.composableFromEndArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromEnd(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +inline fun NavGraphBuilder.composableFromBottom( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { slideInVerticallyFromBottom }, exitTransition = { scaleOut }, popEnterTransition = { scaleIn }, popExitTransition = { slideOutVerticallyToBottom }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromBottom { +inline fun NavGraphBuilder.composableFromBottomArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromBottom(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composable { - content(it.toRoute()) +inline fun NavGraphBuilder.composableArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composable { entry -> + MaybeCappedScreen(capWidth) { content(entry.toRoute()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 74733ffad4..dbeddda760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -34,7 +34,6 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf @@ -46,12 +45,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail @@ -231,24 +226,17 @@ private fun PermanentDrawerShell( } } -/** Step size for the feed side padding, so continuous window resizes republish the value - * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ -private val SidePaddingQuantum = 8.dp - /** - * Hosts the navigation content and publishes the side padding feeds need to cap their content - * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + * Hosts the navigation content. Screen width capping happens per NavHost destination + * ([com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent] via the NavigationEffects + * builders), so this pane just claims the leftover row width. */ @Composable private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier.fillMaxHeight()) { - val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) - val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) - CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - content() - } + Box(modifier.fillMaxHeight()) { + content() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 9d16c3ea38..826d46673d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -27,19 +27,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,62 +94,58 @@ fun MessagesTwoPane( } }, ) { padding -> - // Each pane manages its own width; the shell's center-pane reading cap must not - // re-pad the lists inside them. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) } } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) - } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 598be6ef1a..d40dd9db6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -38,16 +38,13 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -126,18 +123,15 @@ fun NotificationSidePanel( HorizontalDivider(thickness = DividerThickness) - // The panel is its own narrow pane; never apply the center pane's reading-width cap here. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index f66a2c42a9..3e9b7261c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -49,15 +49,15 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) val LocalDisappearingBarState = compositionLocalOf { null } /** - * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a - * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it - * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the - * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to - * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * Extra start/end padding a host can ask feeds to apply so their content column stays at a + * readable width while the scroll surface stays full-pane (scrolling and pull-to-refresh + * keep working edge to edge). Feeds pick it up through [rememberFeedContentPadding]. + * Defaults to 0 everywhere. * - * Panes that manage their own width — side panels, the panes of a two-pane split — must - * override this back to 0 for their subtree: the shell's value was computed against the full - * center pane and would over-pad a narrower list. + * The Android shell does NOT provide this — it caps each NavHost destination's width + * instead (CappedScreenContent), which also constrains top bars and non-feed screens. + * The local remains for hosts that prefer padding-based capping (e.g. a desktop-style + * reading column where gutters should still scroll). */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } From bced238db4e7bf1d8fbd504f56adbb5a88f8f835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:46:27 +0000 Subject: [PATCH 37/39] fix: give the notification panel a Surface so its text follows the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's Column sat in a bare Row with no Surface above it, so LocalContentColor fell back to Color.Black and the header label was invisible on the dark theme — the same trap DisappearingScaffold documents for its own root. The Surface provides the container color and onBackground content color for everything inside the panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../notifications/NotificationSidePanel.kt | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index d40dd9db6c..737c9439c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,48 +91,54 @@ fun NotificationSidePanel( WatchAccountForNotifications(notifFeedContentState, accountViewModel) - Column( - modifier - .width(NotificationPanelWidth) - .fillMaxHeight() - .windowInsetsPadding( + // The Surface provides the Material container color + onBackground as LocalContentColor; + // in a bare Row the default text color falls back to Color.Black and is invisible on the + // dark theme (same reason DisappearingScaffold roots itself in a Surface). + Surface( + modifier = modifier.width(NotificationPanelWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + Column( + Modifier.windowInsetsPadding( WindowInsets.systemBars.only( WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, ), ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = Size16dp, vertical = Size12dp), - verticalAlignment = Alignment.CenterVertically, ) { - Icon( - symbol = MaterialSymbols.Notifications, - contentDescription = null, - modifier = Size22Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) - Spacer(modifier = StdHorzSpacer) - Text( - text = stringRes(R.string.route_notifications), - style = MaterialTheme.typography.titleMedium, - ) - } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = Size16dp, vertical = Size12dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) + } } } } From cc03651460b50285d20bc3bea30b88708130d49e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:49:18 +0000 Subject: [PATCH 38/39] fix: pad custom top bars by systemBars so desktop caption bars are respected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search and Browser top bars are plain layouts (not Material3 TopAppBars) padded with statusBarsPadding only, and DisappearingScaffold used the same for its no-top-bar fallback. In a desktop-style window (Waydroid/DeX freeform) the window's caption/title bar is a separate inset that statusBars does not include — Material's own top bars pad by systemBars and were fine, but these three drew underneath the title bar. Pad by WindowInsets.systemBars top instead, which covers both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/DisappearingScaffold.kt | 8 ++++++-- .../ui/screen/loggedIn/browser/BrowserScreen.kt | 11 ++++++++--- .../ui/screen/loggedIn/search/SearchScreen.kt | 11 +++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 40b32b4327..a9c963f370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -129,7 +132,8 @@ fun DisappearingScaffold( } val rootModifier = baseModifier - .let { if (topBar == null) it.statusBarsPadding() else it } + // systemBars (not just statusBars) so a desktop window's caption bar is respected too. + .let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it } .let { if (bottomBar == null) it.navigationBarsPadding() else it } Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index a74ac3db4e..4e0f47f509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -28,12 +28,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -313,8 +317,9 @@ private fun OmniBar( Modifier .fillMaxWidth() // The omnibox is a plain Row in the topBar slot (not a Material3 TopAppBar), so it must - // apply the status-bar inset itself — otherwise it draws under the status bar. - .statusBarsPadding() + // apply the top system insets itself — systemBars rather than just statusBars, so the + // caption bar of a desktop window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) .padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index c3d39717bf..94823f094f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -26,13 +26,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable @@ -197,7 +201,10 @@ private fun SearchBar( modifier = Modifier .background(MaterialTheme.colorScheme.surface) - .statusBarsPadding(), + // A custom bar (not a Material3 TopAppBar) must apply the top system insets + // itself. systemBars — not just statusBars — so the caption bar of a desktop + // window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), ) { SearchTextField(searchBarViewModel, Modifier) // Inline Namecoin lookup feedback for the global search field. From adb26df838b46234328901b71a39873133006f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:00:48 +0000 Subject: [PATCH 39/39] feat: tier-scaled navigation transitions for large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-width slide-from-end pushes read as disconnected on large screens: the click comes from the docked drawer on the left and a whole 600dp+ column flies across the pane from the right. Keep one navigation grammar (drill-in from end, modal from bottom, tab switches fade) and scale the motion per tier: phones keep the existing full-width slides, large screens get shared-axis moves — a 1/10-pane nudge plus fade — and the screen behind a push fades with its slight scale so both aren't visible mid-transition. Transition specs run outside composition and can't read LocalScreenLayout, so AppNavigation mirrors the tier into NavTransitionTier for the spec lambdas, all of which live in NavigationEffects' shared builders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/navigation/AppNavigation.kt | 5 ++ .../ui/navigation/NavigationEffects.kt | 59 ++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 087ebdb43c..149d0ff90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -294,6 +295,10 @@ fun AppNavigation( val screenLayout = rememberScreenLayoutSpec() val tabReselectCoordinator = remember { TabReselectCoordinator() } + // Mirror the tier for the nav-transition specs, which run outside composition and so + // can't read LocalScreenLayout (see NavTransitionTier). + SideEffect { NavTransitionTier.isLargeScreen = screenLayout.isLargeScreen } + CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index 8b16865a31..3e01d5bb98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally @@ -45,6 +47,21 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true +/** + * The shell's current layout tier, mirrored for the transition specs below. Transition + * lambdas run when a navigation starts — outside composition — so they can't read + * LocalScreenLayout; AppNavigation mirrors the spec here instead. + * + * One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed + * screen physically stacks on top), while large screens use short shared-axis moves — + * content there swaps inside a persistent shell, and a full-pane slide from the right + * reads as disconnected when the click came from the docked drawer on the left. + */ +object NavTransitionTier { + @Volatile + var isLargeScreen: Boolean = false +} + /** * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless * it opted out with `capWidth = false`. Every builder below routes through this, so all @@ -74,10 +91,10 @@ inline fun NavGraphBuilder.composableFromEnd( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, - exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, - popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, - popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, + enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() }, + exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() }, + popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() }, + popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -98,10 +115,10 @@ inline fun NavGraphBuilder.composableFromBottom( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { slideInVerticallyFromBottom }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutVerticallyToBottom }, + enterTransition = { enterFromBottom() }, + exitTransition = { exitBehind() }, + popEnterTransition = { popEnterFromBehind() }, + popExitTransition = { popExitToBottom() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -134,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) + +/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */ +private const val SHARED_AXIS_FRACTION = 10 + +val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) +val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) + +// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a +// slight scale is enough; on large screens the incoming screen fades, so the one behind must +// fade too or both stay visible mid-transition. +val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween()) +val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween()) + +fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd + +fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd + +fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom + +fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom + +fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut + +fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn