diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt index 17bd38777f..dc90e77531 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt @@ -75,6 +75,20 @@ abstract class FlowProgressForegroundService : 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 : 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 : 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 : 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() { 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 d61ea2c8ae..7b35946a1e 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 @@ -56,6 +56,8 @@ class PowMiningForegroundService : FlowProgressForegroundService) = value.any { it.isMining } override fun onStarted() { @@ -147,6 +151,7 @@ class PowMiningForegroundService : FlowProgressForegroundService, miningJobs: ImmutableList = 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, onCancelJob: (String) -> Unit, + onSendWithoutPow: (String) -> Unit, ) { + // the job whose × was tapped and is awaiting the send-or-discard choice. + var confirmJobId by remember { mutableStateOf(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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 2715778e18..44773b05a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -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 -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 49b2351b5d..630dea488f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4386,6 +4386,11 @@ Proof of work mining Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app. Cancel + Send now + Stop mining? + This post is still mining its proof of work. You can send it now without proof of work, or discard it. + Send without PoW + Discard post ≈ %1$s per post on this device <1 s diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index 38bef8672e..cce1d27f43 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -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(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(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. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt index d77c7c4ba8..74535d9bf1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueueTest.kt @@ -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>() + + // 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() + 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 {