feat(pow): predictable mining progress — estimated-time bars and time-left labels

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
This commit is contained in:
Claude
2026-07-11 01:43:06 +00:00
parent 39ffb0ed4d
commit c84cee8107
6 changed files with 214 additions and 35 deletions
@@ -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)
}
}
@@ -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<PoWJobState>) {
@@ -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
@@ -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<Double?>(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,
)
}
}
}
@@ -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<Double?>(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,
)
},
@@ -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<String?>(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
+2
View File
@@ -3807,6 +3807,8 @@
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<string name="pow_time_left">≈ %1$s left</string>
<string name="pow_time_left_soon">any moment now</string>
<string name="pow_publish_failed">Your %1$s finished mining but could not be published: %2$s</string>
<string name="pow_publish_failed_retry">Your %1$s finished mining but could not be published. It will be retried the next time the app starts.</string>
<string name="pow_kind_chat_message">Chat message</string>