mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+2
-22
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user