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