feat: offer "send without PoW" when abandoning a mining job

Cancelling a proof-of-work mining job used to silently discard the post
(the sign+broadcast continuation only ran on a successfully mined
template). Users had no way to publish the note un-mined once they'd
started waiting.

Now abandoning a template post asks what to do instead of discarding:

- The × on the mining banner opens a dialog with "Send without PoW",
  "Discard post", or tap-away to keep mining. Only shown for jobs that
  carry a plain un-mined fallback (template posts); opaque work jobs
  (reactions, reposts, anonymous posts, gift wraps) keep the direct
  cancel since they have no template to fall back to.
- The mining foreground-service notification gains a "Send now" action
  that publishes every eligible queued post without proof of work.

Implementation: the queue keeps the un-mined publish continuation
alongside the miner. sendWithoutPow() sets a flag the worker picks up on
its next isActive poll; the miner aborts and the plain template is
published through the same sign+broadcast path the mined template would
have used, off the worker pool. PoWJobState exposes canSendWithoutPow so
the UI knows which jobs support it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kLVFV7ps4HfXDDJPNqi82
This commit is contained in:
Claude
2026-07-23 20:00:16 +00:00
parent 66108d8aa1
commit 7a3fc48f07
7 changed files with 265 additions and 21 deletions
@@ -75,6 +75,20 @@ abstract class FlowProgressForegroundService<T> : Service() {
/** The intent action that routes back here to cancel everything. */
protected abstract val cancelAction: String
protected abstract val cancelLabelRes: Int
/**
* Optional second notification action (e.g. "send without PoW"). When
* [secondaryAction] and [secondaryLabelRes] are both non-null, the button is
* shown and tapping it routes back here to run [onSecondaryAction] without
* stopping the service — the work keeps going and the card drains normally.
*/
protected open val secondaryAction: String? = null
protected open val secondaryLabelRes: Int? = null
protected open fun onSecondaryAction() {
// No-op by default: only subclasses that declare a secondary action override this.
}
protected open val smallIcon: Int = R.drawable.amethyst
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
@@ -145,6 +159,17 @@ abstract class FlowProgressForegroundService<T> : Service() {
)
}
private val secondaryIntent: PendingIntent? by lazy {
secondaryAction?.let { action ->
PendingIntent.getService(
this,
2,
Intent(this, this.javaClass).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
@@ -168,6 +193,14 @@ abstract class FlowProgressForegroundService<T> : Service() {
return START_NOT_STICKY
}
if (intent?.action != null && intent.action == secondaryAction) {
// keep the service alive: the jobs flip to publishing and the watch
// loop stops us once the queue drains.
onSecondaryAction()
watch()
return START_NOT_STICKY
}
watch()
return START_NOT_STICKY
}
@@ -252,20 +285,28 @@ abstract class FlowProgressForegroundService<T> : Service() {
.setProgress(bar.done)
}
return NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
val builder =
NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
val secondaryLabel = secondaryLabelRes
val secondaryPending = secondaryIntent
if (secondaryLabel != null && secondaryPending != null) {
builder.addAction(0, stringRes(this, secondaryLabel), secondaryPending)
}
return builder.build()
}
private fun ensureChannel() {
@@ -56,6 +56,8 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
override val notificationId = NOTIFICATION_ID
override val cancelAction = ACTION_CANCEL_ALL
override val cancelLabelRes = R.string.pow_notification_cancel_all
override val secondaryAction = ACTION_SEND_ALL_NOW
override val secondaryLabelRes = R.string.pow_notification_send_without_pow
// clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
// caps this at a handful of updates.
@@ -86,6 +88,8 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
override fun onSecondaryAction() = Amethyst.instance.powPublishQueue.sendAllWithoutPow()
override fun needsClockRefresh(value: ImmutableList<PoWJobState>) = value.any { it.isMining }
override fun onStarted() {
@@ -147,6 +151,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private const val CHANNEL_ID = "pow_mining"
private const val NOTIFICATION_ID = 0x504F57 // "POW"
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
private const val ACTION_SEND_ALL_NOW = "com.vitorpamplona.amethyst.pow.SEND_ALL_NOW"
private const val PROGRESS_REFRESH_MS = 30_000L
@@ -44,15 +44,18 @@ 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.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -105,6 +108,7 @@ fun BroadcastBanner(
broadcasts: ImmutableList<BroadcastEvent>,
miningJobs: ImmutableList<PoWJobState> = persistentListOf(),
onCancelJob: (String) -> Unit = {},
onSendWithoutPow: (String) -> Unit = {},
onTap: () -> Unit = {},
onRetryAll: () -> Unit = {},
onDismiss: () -> Unit = {},
@@ -133,7 +137,7 @@ fun BroadcastBanner(
.animateContentSize(),
) {
if (miningJobs.isNotEmpty()) {
MiningContent(miningJobs, onCancelJob)
MiningContent(miningJobs, onCancelJob, onSendWithoutPow)
if (broadcasts.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
@@ -166,7 +170,32 @@ fun BroadcastBanner(
private fun MiningContent(
miningJobs: ImmutableList<PoWJobState>,
onCancelJob: (String) -> Unit,
onSendWithoutPow: (String) -> Unit,
) {
// the job whose × was tapped and is awaiting the send-or-discard choice.
var confirmJobId by remember { mutableStateOf<String?>(null) }
// drop the dialog if its job finished (mined and published) while it was open.
LaunchedEffect(miningJobs) {
if (confirmJobId != null && miningJobs.none { it.id == confirmJobId }) {
confirmJobId = null
}
}
confirmJobId?.let { jobId ->
PoWCancelDialog(
onSendWithoutPow = {
onSendWithoutPow(jobId)
confirmJobId = null
},
onDiscard = {
onCancelJob(jobId)
confirmJobId = null
},
onKeepMining = { confirmJobId = null },
)
}
// one shared pulse for the gear — mining has no measurable progress, so
// the animation is what says "the app is working right now".
val pulse = rememberInfiniteTransition(label = "miningPulse")
@@ -264,7 +293,16 @@ private fun MiningContent(
// there is nothing safe to abort anymore.
if (job.isCancellable) {
IconButton(
onClick = { onCancelJob(job.id) },
// a template post can still go out un-mined, so ask first;
// opaque jobs (reactions, reposts) have no such choice and
// just cancel.
onClick = {
if (job.canSendWithoutPow) {
confirmJobId = job.id
} else {
onCancelJob(job.id)
}
},
modifier = Modifier.size(22.dp),
) {
Icon(
@@ -320,6 +358,37 @@ private fun MiningContent(
}
}
/**
* Asks what to do with a post whose proof of work is still mining: publish it
* now without PoW, discard it, or keep mining. Only shown for jobs that carry
* an un-mined fallback (template posts).
*/
@Composable
private fun PoWCancelDialog(
onSendWithoutPow: () -> Unit,
onDiscard: () -> Unit,
onKeepMining: () -> Unit,
) {
AlertDialog(
onDismissRequest = onKeepMining,
title = { Text(stringRes(R.string.pow_cancel_dialog_title)) },
text = { Text(stringRes(R.string.pow_cancel_dialog_message)) },
confirmButton = {
TextButton(onClick = onSendWithoutPow) {
Text(stringRes(R.string.pow_cancel_dialog_send_without_pow))
}
},
dismissButton = {
TextButton(onClick = onDiscard) {
Text(
text = stringRes(R.string.pow_cancel_dialog_discard),
color = MaterialTheme.colorScheme.error,
)
}
},
)
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Row(
@@ -132,6 +132,7 @@ fun DisplaySnack(
broadcasts = activeBroadcasts,
miningJobs = miningJobs,
onCancelJob = { Amethyst.instance.powPublishQueue.cancel(it) },
onSendWithoutPow = { Amethyst.instance.powPublishQueue.sendWithoutPow(it) },
onTap = onTap,
onRetryAll = {
activeBroadcasts.forEach { b ->
+5
View File
@@ -4386,6 +4386,11 @@
<string name="pow_notification_channel_name">Proof of work mining</string>
<string name="pow_notification_channel_description">Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app.</string>
<string name="pow_notification_cancel_all">Cancel</string>
<string name="pow_notification_send_without_pow">Send now</string>
<string name="pow_cancel_dialog_title">Stop mining?</string>
<string name="pow_cancel_dialog_message">This post is still mining its proof of work. You can send it now without proof of work, or discard it.</string>
<string name="pow_cancel_dialog_send_without_pow">Send without PoW</string>
<string name="pow_cancel_dialog_discard">Discard post</string>
<string name="pow_difficulty_estimate">≈ %1$s per post on this device</string>
<string name="pow_estimate_instant">&lt;1 s</string>
<plurals name="pow_estimate_seconds">
@@ -75,6 +75,13 @@ data class PoWJobState(
val difficulty: Int,
val phase: PoWJobPhase = PoWJobPhase.QUEUED,
val miningStartedAt: Long? = null,
/**
* True when the job carries a plain, un-mined fallback the user can publish
* right away instead of waiting for (or discarding) the nonce search the
* template send paths. Opaque work jobs (reactions, reposts, anonymous
* posts, gift wraps) have no such fallback and are false.
*/
val canSendWithoutPow: Boolean = false,
) {
val isMining: Boolean get() = phase == PoWJobPhase.MINING
val isCancellable: Boolean get() = phase != PoWJobPhase.PUBLISHING
@@ -131,12 +138,19 @@ class PoWPublishQueue(
val dedupeKey: String?,
val owner: HexKey?,
val work: suspend (isActive: () -> Boolean) -> Unit,
// When non-null, publishes the event WITHOUT proof of work — the plain
// template send path, run in place of the (abandoned) nonce search.
val sendWithoutPow: (suspend () -> Unit)?,
) {
@Volatile
var cancelled = false
@Volatile
var publishing = false
// Set by cancel-and-send-now: stop the miner and publish [sendWithoutPow].
@Volatile
var sendUnminedRequested = false
}
private val queue = Channel<MiningJob>(UNLIMITED)
@@ -205,6 +219,16 @@ class PoWPublishQueue(
PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive)
},
publish = onMined,
// send-now fallback: the same template, minus the nonce the miner would
// have added. created_at follows the mined path — refreshed to "now" for
// ordinary posts, left intact for scheduled ones.
sendWithoutPow = {
if (refreshCreatedAtOnStart) {
EventTemplate<T>(TimeUtils.now(), template.kind, template.tags, template.content)
} else {
template
}
},
)
/**
@@ -222,6 +246,11 @@ class PoWPublishQueue(
owner: HexKey? = null,
mine: suspend (isActive: () -> Boolean) -> R,
publish: suspend (R) -> Unit,
// When non-null, produces the value [publish] would have received
// WITHOUT mining, so the user can abandon the nonce search and send the
// event right away (see [sendWithoutPow]). Null for stages whose result
// only exists after mining (gift wraps).
sendWithoutPow: (suspend () -> R)? = null,
) {
val id = persistAs?.id ?: RandomInstance.randomChars(16)
addJob(
@@ -231,6 +260,7 @@ class PoWPublishQueue(
persistAs = persistAs,
dedupeKey = dedupeKey,
owner = owner ?: persistAs?.accountPubkey,
sendWithoutPow = sendWithoutPow?.let { produce -> { publish(produce()) } },
) { isActive ->
val mined = mine(isActive)
// frees the mining worker: signing may wait on an external signer
@@ -257,7 +287,7 @@ class PoWPublishQueue(
dedupeKey: String? = null,
owner: HexKey? = null,
work: suspend (isActive: () -> Boolean) -> Unit,
) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, dedupeKey = dedupeKey, owner = owner, work = work)
) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, dedupeKey = dedupeKey, owner = owner, sendWithoutPow = null, work = work)
private fun addJob(
id: String,
@@ -266,6 +296,7 @@ class PoWPublishQueue(
persistAs: PersistedPoWJob?,
dedupeKey: String?,
owner: HexKey?,
sendWithoutPow: (suspend () -> Unit)?,
work: suspend (isActive: () -> Boolean) -> Unit,
) {
val current = pending.value
@@ -278,10 +309,10 @@ class PoWPublishQueue(
return
}
val job = MiningJob(id, kind, difficulty, persisted = persistAs != null, dedupeKey = dedupeKey, owner = owner, work = work)
val job = MiningJob(id, kind, difficulty, persisted = persistAs != null, dedupeKey = dedupeKey, owner = owner, work = work, sendWithoutPow = sendWithoutPow)
persistAs?.let { persistence?.save(it) }
pending.update { it.putting(job.id, job) }
_jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty)).toImmutableList() }
_jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty, canSendWithoutPow = sendWithoutPow != null)).toImmutableList() }
Log.d(TAG) {
val durability = if (persistAs != null) "persisted" else "in-memory only, lost on process death"
"Enqueued PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty} ($durability)"
@@ -319,6 +350,28 @@ class PoWPublishQueue(
pending.value.keys.forEach { cancel(it) }
}
/**
* Abandons the nonce search for [jobId] and publishes its event immediately
* WITHOUT proof of work, through the same sign+broadcast continuation the
* mined template would have used. No-op if the job already finished, is
* publishing, was cancelled, or was enqueued without an un-mined fallback
* (opaque work jobs reactions, reposts, anonymous posts, gift wraps have
* no plain template to fall back to). The running worker picks up the flag
* on its next [isActive] poll and does the publish; a still-QUEUED job does
* it as soon as a worker takes it.
*/
fun sendWithoutPow(jobId: String) {
val job = pending.value[jobId] ?: return
if (job.publishing || job.cancelled || job.sendWithoutPow == null) return
job.sendUnminedRequested = true
Log.d(TAG) { "Requested send-without-PoW for job $jobId" }
}
/** Sends every job that supports it without proof of work; others keep mining. */
fun sendAllWithoutPow() {
pending.value.keys.forEach { sendWithoutPow(it) }
}
/**
* Cancels every queued/mining job enqueued for [owner]'s account used at
* log-off so a deleted account's posts can't publish after the fact.
@@ -342,13 +395,24 @@ class PoWPublishQueue(
var detached = false
try {
job.work { !job.cancelled && workerJob.isActive }
job.work { !job.cancelled && !job.sendUnminedRequested && workerJob.isActive }
detached = job.publishing
Log.d(TAG) { "Finished mining PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty}" }
} catch (e: CancellationException) {
// the worker itself was cancelled (scope teardown): propagate.
if (!currentCoroutineContext().isActive) throw e
Log.d(TAG) { "PoW job ${job.id} cancelled while mining" }
val sendWithoutPow = job.sendWithoutPow
if (job.sendUnminedRequested && !job.cancelled && sendWithoutPow != null) {
// user chose "send now": abandon the nonce search and publish the
// plain template through the same continuation, off the pool.
Log.d(TAG) { "PoW job ${job.id} sending without proof of work" }
setPhase(job.id, PoWJobPhase.PUBLISHING)
detached = true
scope.launch { finishDetached(job.id, job.kind, persisted = job.persisted, onMined = sendWithoutPow) }
} else {
Log.d(TAG) { "PoW job ${job.id} cancelled while mining" }
}
} catch (e: Exception) {
// mining-stage failures are deterministic (bad difficulty, broken
// template): drop the checkpoint too, or every restore re-crashes.
@@ -97,6 +97,65 @@ class PoWPublishQueueTest {
scope.cancel()
}
@Test
fun sendWithoutPowPublishesTheUnminedTemplate() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val sent = CompletableDeferred<EventTemplate<TextNoteEvent>>()
// difficulty 40 would mine for ages: it must NOT finish before we
// abandon it, so what publishes is guaranteed the un-mined template.
queue.enqueue(template, pubKey, difficulty = 40) { sent.complete(it) }
// wait until a worker is actually searching, then send now.
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.any { j -> j.isMining } } } }
val jobId =
queue.jobs.value
.first()
.id
assertTrue(
queue.jobs.value
.first()
.canSendWithoutPow,
"template jobs offer the un-mined fallback",
)
queue.sendWithoutPow(jobId)
val result = withContext(Dispatchers.Default) { withTimeout(10_000) { sent.await() } }
val powTag = result.tags.firstNotNullOfOrNull { PoWTag.parse(it) }
assertEquals(null, powTag, "an un-mined send must carry no nonce tag")
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
scope.cancel()
}
@Test
fun sendWithoutPowIsNoOpForOpaqueWorkJobs() =
runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
val gate = CompletableDeferred<Unit>()
var ran = false
// occupies the worker so the second job stays QUEUED and observable
queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() }
queue.enqueueWork(kind = 1, difficulty = 10) { ran = true }
val workJob = queue.jobs.value[1]
assertFalse(workJob.canSendWithoutPow, "opaque work jobs have no un-mined fallback")
// no fallback → the request is ignored, the job keeps its place.
queue.sendWithoutPow(workJob.id)
assertEquals(2, queue.jobs.value.size)
gate.complete(Unit)
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
assertTrue(ran, "the work job still runs its normal mining path")
scope.cancel()
}
@Test
fun jobsRunInFifoOrder() =
runTest {