From c84cee8107e26c6244a00e7a71440cb5f16dc976 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:43:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(pow):=20predictable=20mining=20progress=20?= =?UTF-8?q?=E2=80=94=20estimated-time=20bars=20and=20time-left=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mining banner's indeterminate bar becomes determinate: each mining job gets its own bar that fills over the device's estimated duration for its difficulty (elapsed / (2^bits / benchmarked hash rate)), with an "≈ 10 minutes left" label next to the elapsed time. The nonce search is memoryless, so past the mean the bar honestly falls back to the indeterminate sweep with "any moment now" instead of parking at 100%. Queued-only banners keep the shared activity sweep. The mining notification mirrors this: single-job progress fills toward the estimate with the time-left text, refreshed every 30 s while mining (the queue only emits on state changes, not clock ticks). The composer difficulty menu now prices each option on this device — "24 bits · ≈ 45 seconds" — using the same cached benchmark, and the duration formatting is shared (formatApproxDuration/formatTimeLeft) with the settings estimate instead of being private to the settings screen. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V --- .../amethyst/service/pow/PowDuration.kt | 72 +++++++++++++++++++ .../service/pow/PowMiningForegroundService.kt | 53 +++++++++++++- .../amethyst/ui/broadcast/BroadcastBanner.kt | 72 ++++++++++++++++--- .../ui/note/creators/pow/PowOverrideButton.kt | 26 ++++++- .../settings/ComposeSettingsScreen.kt | 24 +------ amethyst/src/main/res/values/strings.xml | 2 + 6 files changed, 214 insertions(+), 35 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt new file mode 100644 index 0000000000..fa4eef5166 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.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.amethyst.service.pow + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.stringRes +import kotlin.math.roundToLong + +/** + * "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering + * of a PoW duration estimate, shared by the settings picker, the composer + * difficulty menu, the mining banner, and the mining notification. + * + * Estimates come from [com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator] + * and are the statistical mean of a memoryless search — any single post can be + * much luckier or unluckier, so always present these as approximations. + */ +fun formatApproxDuration( + context: Context, + seconds: Double, +): String { + fun quantity( + id: Int, + count: Long, + ) = pluralStringRes(context, id, count.toInt(), count.toInt()) + + return when { + seconds < 1.0 -> stringRes(context, R.string.pow_estimate_instant) + seconds < 90.0 -> quantity(R.plurals.pow_estimate_seconds, seconds.roundToLong()) + seconds < 90.0 * 60.0 -> quantity(R.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong()) + seconds < 48.0 * 3600.0 -> quantity(R.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong()) + else -> quantity(R.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong()) + } +} + +/** + * "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean, + * "any moment now" once past it — a memoryless search has no shrinking + * remainder, so past the mean the only honest claim is "soon". + */ +fun formatTimeLeft( + context: Context, + expectedSec: Double, + elapsedSec: Long, +): String { + val remaining = expectedSec - elapsedSec + return if (remaining > 1.0) { + stringRes(context, R.string.pow_time_left, formatApproxDuration(context, remaining)) + } else { + stringRes(context, R.string.pow_time_left_soon) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt index cd023e303d..36fe373b50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -34,17 +34,20 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -72,6 +75,11 @@ class PowMiningForegroundService : Service() { private var sessionTotal = 0 private var lastQueueSize = 0 + // Benchmarked once per service run (~250 ms, cached by the estimator); + // read from the notification builder to compute expected durations. + @Volatile + private var hashRate: Double? = null + // Built once per service instance: the intents never change, and // buildNotification runs on every queue update. private val tapIntent: PendingIntent by lazy { @@ -161,6 +169,18 @@ class PowMiningForegroundService : Service() { } } } + + // the estimated-time-left figure and progress fraction only move with + // the clock, not with queue events: benchmark the hash rate once, + // then refresh the card periodically while something is mining. + scope.launch { + hashRate = PoWEstimator.hashesPerSecond() + while (true) { + val jobs = currentJobs() + if (jobs.any { it.isMining }) updateNotification(jobs) + delay(PROGRESS_REFRESH_MS) + } + } } private fun startForegroundCompat(jobs: ImmutableList) { @@ -188,14 +208,39 @@ class PowMiningForegroundService : Service() { val total = (done + jobs.size).coerceAtLeast(1) val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull() - val text = + + // expected duration for the job being mined right now, so the card can + // say "≈ 10 minutes left" and fill its bar toward a predictable end. + val rate = hashRate + val startedAt = current?.miningStartedAt + val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null + val elapsedSec = startedAt?.let { (TimeUtils.now() - it).coerceAtLeast(0) } + + val base = current?.let { pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty) } ?: stringRes(this, R.string.pow_mining_title) + val text = + if (expectedSec != null && elapsedSec != null) { + "$base • ${formatTimeLeft(this, expectedSec, elapsedSec)}" + } else { + base + } + + val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null val progressStyle: NotificationCompat.ProgressStyle = if (total <= 1) { - NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + // single post: fill toward the estimated duration; past the + // mean the search is memoryless, so sweep instead of lying. + if (fraction != null && fraction < 1.0) { + NotificationCompat + .ProgressStyle() + .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100))) + .setProgress((fraction * 100).toInt()) + } else { + NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + } } else { NotificationCompat .ProgressStyle() @@ -230,6 +275,10 @@ class PowMiningForegroundService : Service() { private const val NOTIFICATION_ID = 0x504F57 // "POW" private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL" + // clock-driven refresh cadence for the time-left text and bar; the + // shortService budget (~3 min) caps this at a handful of updates. + private const val PROGRESS_REFRESH_MS = 30_000L + // Best-effort de-dup for start(): the queue calls it on EVERY enqueue, // and each call otherwise round-trips through system_server. A stale // false only costs one redundant startForegroundService (which Android diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index c455211dda..17ef204ce8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -53,11 +53,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +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.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -69,7 +71,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState +import com.vitorpamplona.amethyst.service.pow.formatTimeLeft import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -178,7 +182,8 @@ private fun MiningContent( // 1 Hz clock driving the per-job elapsed labels; only ticks while some // job actually shows an elapsed time (queued-only banners don't need it). var nowSec by remember { mutableLongStateOf(TimeUtils.now()) } - if (miningJobs.any { it.miningStartedAt != null }) { + val anyMiningStarted = miningJobs.any { it.miningStartedAt != null } + if (anyMiningStarted) { LaunchedEffect(Unit) { while (true) { nowSec = TimeUtils.now() @@ -187,6 +192,14 @@ private fun MiningContent( } } + // Benchmarked once and cached (~250 ms on a worker): turns each job's + // difficulty into an expected duration so the bar has a predictable end. + val context = LocalContext.current + val hashRate by + produceState(initialValue = null) { + value = PoWEstimator.hashesPerSecond() + } + Column(modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -211,6 +224,9 @@ private fun MiningContent( } miningJobs.forEach { job -> + val elapsedSec = job.miningStartedAt?.let { (nowSec - it).coerceAtLeast(0) } + val expectedSec = hashRate?.let { PoWEstimator.estimateSeconds(job.difficulty, it) } + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -225,10 +241,16 @@ private fun MiningContent( kindToName(job.kind), job.difficulty, ) - val elapsed = job.miningStartedAt?.let { DateUtils.formatElapsedTime((nowSec - it).coerceAtLeast(0)) } + val suffix = + buildList { + elapsedSec?.let { add(DateUtils.formatElapsedTime(it)) } + if (elapsedSec != null && expectedSec != null) { + add(formatTimeLeft(context, expectedSec, elapsedSec)) + } + }.joinToString(" • ") Text( - text = if (elapsed != null) "$base • $elapsed" else base, + text = if (suffix.isEmpty()) base else "$base • $suffix", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -252,15 +274,47 @@ private fun MiningContent( } } } + + // Predictable end: the bar fills over the estimated duration for + // this difficulty. The search is memoryless, so once the mean is + // passed there is no honest remainder to show — fall back to the + // indeterminate sweep instead of a bar stuck at 100%. + if (elapsedSec != null) { + val fraction = expectedSec?.let { (elapsedSec / it).toFloat() } + + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.width(26.dp)) + if (fraction != null && fraction < 1f) { + LinearProgressIndicator( + progress = { fraction }, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + LinearProgressIndicator( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + Spacer(Modifier.height(4.dp)) + } } - Spacer(Modifier.height(4.dp)) + // nothing mining yet (all jobs waiting for a worker): keep the shared + // activity sweep so the banner still reads as "working". + if (!anyMiningStarted) { + Spacer(Modifier.height(4.dp)) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt index b6274a59b4..22e0dda3a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt @@ -33,10 +33,12 @@ import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -45,6 +47,8 @@ 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.commons.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.service.pow.formatApproxDuration import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow @@ -108,11 +112,29 @@ fun PowOverrideButton( expanded = expanded, onDismissRequest = { expanded = false }, ) { + // benchmarked on first open (~250 ms, cached after): each option + // shows what it would cost on THIS device, e.g. "24 bits · ≈ 45 seconds". + val context = LocalContext.current + val hashRate by + produceState(initialValue = null) { + value = PoWEstimator.hashesPerSecond() + } + + fun eta(difficulty: Int): String? = hashRate?.let { formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, it)) } + + fun withEta( + label: String, + difficulty: Int, + ): String = eta(difficulty)?.let { "$label · $it" } ?: label + DropdownMenuItem( text = { Text( if (defaultDifficulty != null && defaultDifficulty > 0) { - pluralStringResource(R.plurals.pow_option_default_on, defaultDifficulty, defaultDifficulty) + withEta( + pluralStringResource(R.plurals.pow_option_default_on, defaultDifficulty, defaultDifficulty), + defaultDifficulty, + ) } else { stringRes(R.string.pow_option_default_off) }, @@ -140,7 +162,7 @@ fun PowOverrideButton( DropdownMenuItem( text = { Text( - pluralStringResource(R.plurals.pow_option_bits, preset, preset), + withEta(pluralStringResource(R.plurals.pow_option_bits, preset, preset), preset), fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 9c50952229..e28eeb82bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -53,16 +52,15 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.model.AccountPoWPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.service.pow.formatApproxDuration import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.pow.POW_PRESETS -import com.vitorpamplona.amethyst.ui.pluralStringRes 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 import kotlinx.coroutines.flow.MutableStateFlow -import kotlin.math.roundToLong @Composable fun ComposeSettingsScreen( @@ -198,7 +196,7 @@ private fun PowTimeEstimate(difficulty: Int) { val estimate by produceState(initialValue = null, difficulty) { val rate = PoWEstimator.hashesPerSecond() - value = formatEstimate(context, PoWEstimator.estimateSeconds(difficulty, rate)) + value = formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, rate)) } estimate?.let { @@ -210,24 +208,6 @@ private fun PowTimeEstimate(difficulty: Int) { } } -private fun formatEstimate( - context: Context, - seconds: Double, -): String { - fun quantity( - id: Int, - count: Long, - ) = pluralStringRes(context, id, count.toInt(), count.toInt()) - - return when { - seconds < 1.0 -> stringRes(context, R.string.pow_estimate_instant) - seconds < 90.0 -> quantity(R.plurals.pow_estimate_seconds, seconds.roundToLong()) - seconds < 90.0 * 60.0 -> quantity(R.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong()) - seconds < 48.0 * 3600.0 -> quantity(R.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong()) - else -> quantity(R.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong()) - } -} - @Composable private fun PowCategoryChecklist(accountViewModel: AccountViewModel) { val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aba064cb81..33042b5b03 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3807,6 +3807,8 @@ %1$d day %1$d days + ≈ %1$s left + any moment now Your %1$s finished mining but could not be published: %2$s Your %1$s finished mining but could not be published. It will be retried the next time the app starts. Chat message