Merge remote-tracking branch 'origin/main' into claude/sharp-keller-7itny0

This commit is contained in:
Claude
2026-09-23 19:34:20 +00:00
9 changed files with 223 additions and 34 deletions
@@ -1428,6 +1428,9 @@ class Account(
// wraps still register with the delivery-ticks tracker at publish time.
// Null for restart-restored jobs, whose rumor id wasn't persisted.
displayedNoteId: HexKey? = null,
// Runs once the mined wraps are handed to the relays (e.g. to drop the
// composer's draft). Not persisted: a restart-restored job skips it.
onPublished: suspend () -> Unit = {},
): Boolean {
val queue = powQueue() ?: return false
if (seals.isEmpty()) return true
@@ -1462,7 +1465,10 @@ class Account(
}
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
},
publish = { wraps -> broadcastPrivately(wraps, displayedNoteId) },
publish = { wraps ->
broadcastPrivately(wraps, displayedNoteId)
onPublished()
},
)
return true
}
@@ -2944,13 +2950,24 @@ class Account(
broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer))
}
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) = sendNip17PrivateMessage(template) {}
/**
* [onSent] runs once the wraps are handed to the relays — right away when
* the message is not mined, after the nonce search when it is — so the
* composer can keep its draft for as long as the message only exists in
* the mining queue.
*/
suspend fun sendNip17PrivateMessage(
template: EventTemplate<ChatMessageEvent>,
onSent: suspend () -> Unit,
) {
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
if (powDifficulty != null) {
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id, onPublished = onSent)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
@@ -2959,6 +2976,7 @@ class Account(
}
broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer))
onSent()
}
/**
@@ -2970,10 +2988,13 @@ class Account(
*
* [powOverrideDifficulty] is the composer chip's per-post override:
* null follows the account's gift-wrap setting, 0 disables mining.
* [onSent] runs once the wraps are handed to the relays (after mining,
* when mined).
*/
suspend fun sendPrivateNote(
template: EventTemplate<TextNoteEvent>,
powOverrideDifficulty: Int? = null,
onSent: suspend () -> Unit = {},
) {
if (!isWriteable()) return
@@ -2983,10 +3004,11 @@ class Account(
val senderNote = signer.sign(template)
val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet()
val seals = NIP17Factory().createSeals(senderNote, recipients, signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id, onPublished = onSent)) return
}
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
onSent()
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
@@ -42,6 +42,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
@@ -94,6 +95,18 @@ abstract class FlowProgressForegroundService<T> : Service() {
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
protected open val refreshMs: Long? = null
/**
* How long to keep the service (and whatever it holds up) alive after [isActive] turns
* false, before stopping. Lets work that was just handed off asynchronously — e.g. a
* broadcast still leaving through the relay pool — finish before the process loses its
* foreground protection and gets frozen. A new active emission during the grace keeps
* the service running. Asked each time the work drains, so it can depend on how it did.
*/
protected open fun stopGraceMs(): Long = 0L
/** The card shown during [stopGraceMs]; null keeps the last one. */
protected open fun renderDraining(): Content? = null
protected abstract fun state(): StateFlow<T>
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
@@ -222,9 +235,15 @@ abstract class FlowProgressForegroundService<T> : Service() {
onStarted()
watchJob =
scope.launch {
state().collect { value ->
state().collectLatest { value ->
onEmission(value)
if (!isActive(value)) {
// collectLatest: a new active emission cancels this pending stop.
val grace = stopGraceMs()
if (grace > 0) {
renderDraining()?.let { notify(buildNotification(it)) }
delay(grace)
}
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
@@ -246,7 +265,7 @@ abstract class FlowProgressForegroundService<T> : Service() {
private fun startForegroundCompat(value: T) {
ensureChannel()
val notification = buildNotification(value)
val notification = buildNotification(render(value))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, fgsType)
} else {
@@ -254,18 +273,19 @@ abstract class FlowProgressForegroundService<T> : Service() {
}
}
private fun updateNotification(value: T) {
private fun updateNotification(value: T) = notify(buildNotification(render(value)))
private fun notify(notification: Notification) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(notificationId, buildNotification(value))
manager.notify(notificationId, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(value: T): Notification {
val content = render(value)
private fun buildNotification(content: Content): Notification {
val style =
when (val bar = content.bar) {
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
@@ -22,15 +22,20 @@ package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.PowerManager
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.PoWJobPhase
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -44,6 +49,17 @@ import kotlinx.coroutines.launch
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* Staying schedulable is not enough on its own once the user also locks the
* screen or leaves the app, so while it runs the service also:
* - holds a partial wake lock, or the CPU suspends with the screen off and the
* nonce search stalls until the phone is unlocked;
* - keeps the relay pool (and Tor) up. The UI stops holding them ~30 s after
* the app is backgrounded, so a post mined after that would only reach the
* in-memory outbox — while its checkpoint and draft were already deleted —
* and die with the process;
* - lingers [PUBLISH_GRACE_MS] after the queue drains, so the event that was
* just handed to the pool actually leaves before the process is frozen.
*
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
* to that card.
@@ -68,17 +84,60 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private var sessionTotal = 0
private var lastQueueSize = 0
// Whether the last non-empty queue still had a job publishing: a queue that drains by
// publishing needs the grace for the event to leave; one drained by cancel does not.
private var drainedByPublishing = false
override fun stopGraceMs() = if (drainedByPublishing) PUBLISH_GRACE_MS else 0L
override fun renderDraining() = Content(stringRes(this, R.string.pow_notification_sending), null, Bar.Indeterminate)
// Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
running = true
wakeLock =
runCatching {
(getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:pow-mining")
.apply {
setReferenceCounted(false)
acquire(WAKE_LOCK_TIMEOUT_MS)
}
}.onFailure { Log.w(TAG, "Could not acquire the mining wake lock", it) }
.getOrNull()
// Re-arm the timeout while the service lives: before Android 14 there is no FGS budget
// and a hard nonce can mine for hours, but a lock with no timeout could outlive a
// service that died without onDestroy.
scope.launch {
while (true) {
delay(WAKE_LOCK_TIMEOUT_MS / 2)
wakeLock?.let { if (it.isHeld) it.acquire(WAKE_LOCK_TIMEOUT_MS) }
}
}
// Mirrors what the resumed UI collects (AccountScreen's ManageRelayServices +
// ManageWebOkHttp): subscribed, they keep the relay pool connected and, through
// the connector's combine and the proxy-port provider, Tor up.
val app = Amethyst.instance
scope.launch(Dispatchers.IO) {
launch { app.relayProxyClientConnector.relayServices.collect {} }
launch { app.okHttpClients.defaultHttpClient.collect {} }
launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} }
}
}
override fun onDestroy() {
running = false
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
@@ -97,6 +156,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
}
override fun onEmission(value: ImmutableList<PoWJobState>) {
if (value.isNotEmpty()) drainedByPublishing = value.any { it.phase == PoWJobPhase.PUBLISHING }
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
@@ -155,6 +215,13 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private const val PROGRESS_REFRESH_MS = 30_000L
// Time for a just-mined post to connect, send and get its OK before the service
// lets go of the relays and the process becomes freezable.
private const val PUBLISH_GRACE_MS = 20_000L
// Safety net only: onDestroy releases it, and the service re-arms it while alive.
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
@@ -160,8 +160,12 @@ open class CommentPostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
val tag = draftTag.current
draftNote = account.getOrCreateDraftNote(tag)
// Post rotates the tag and then clears the composer. A save still queued from
// before that would see the empty text and delete the draft Post just saved
// for the post that is still mining, so it's skipped once the tag has moved on.
if (draftTag.current == tag) sendDraftSync()
}
}
}
@@ -584,10 +588,20 @@ open class CommentPostViewModel :
}
}
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
// captured before cancel() resets the chip
val chosenPow = powOverride
// A mined post leaves the phone minutes after this returns — much later if the
// app is backgrounded and frozen — and the draft is its only user-visible copy
// until then. The auto-save is debounced and cancel() below drops the pending
// save, so the last second of typing would never reach it: flush it now.
if (accountViewModel.account.powDifficultyFor(template.kind, chosenPow) != null) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
}
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
onUiThread { cancel() }
// Draft deletion lives INSIDE each publish continuation: when the post
@@ -92,6 +92,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -139,9 +140,13 @@ class ChatNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
val tag = draftTag.current
draftNote = account.getOrCreateDraftNote(tag)
accountViewModel.launchSigner {
sendDraftSync()
// Post rotates the tag and then clears the composer. A save still queued from
// before that would see the empty text and delete the draft Post just saved
// for the post that is still mining, so it's skipped once the tag has moved on.
if (draftTag.current == tag) sendDraftSync()
}
}
}
@@ -427,12 +432,22 @@ class ChatNewMessageViewModel :
}
suspend fun sendPostSync() {
val draftToDelete = draftNote
innerSendPost(null)
onUiThread { cancel() }
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
// With PoW on, the gift wraps are mined in the background and leave the phone
// minutes later; until then the draft is the only copy the user can see. The
// auto-save is debounced and cancel() drops the pending save, so flush it now
// and delete it only once the wraps are actually sent.
if (accountViewModel.account.powDifficultyFor(GiftWrapEvent.KIND) != null) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
}
val draftToDelete = draftNote
val account = accountViewModel.account
innerSendPost(null) {
// off the caller: signing the deletion must not hold up clearing the composer.
account.scope.launch(Dispatchers.IO) { account.deleteDraftIgnoreErrors(draftToDelete) }
}
onUiThread { cancel() }
}
suspend fun sendDraftSync() {
@@ -575,7 +590,10 @@ class ChatNewMessageViewModel :
}
}
private suspend fun innerSendPost(draftTag: String?) {
private suspend fun innerSendPost(
draftTag: String?,
onSent: suspend () -> Unit = {},
) {
val room = room.value ?: return
val messageText = message.text.toString()
@@ -628,7 +646,7 @@ class ChatNewMessageViewModel :
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
accountViewModel.account.sendNip17PrivateMessage(template, onSent)
}
if (draftTag == null) {
@@ -152,9 +152,13 @@ open class ChannelNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
val tag = draftTag.current
draftNote = account.getOrCreateDraftNote(tag)
accountViewModel.launchSigner {
sendDraftSync()
// Post rotates the tag and then clears the composer. A save still queued from
// before that would see the empty text and delete the draft Post just saved
// for the post that is still mining, so it's skipped once the tag has moved on.
if (draftTag.current == tag) sendDraftSync()
}
}
}
@@ -403,6 +407,16 @@ open class ChannelNewMessageViewModel :
// the composer, so the user keeps their text (and draft) to retry rather than losing it silently.
if (channel is GeohashChatChannel && channelRelays.isEmpty()) return
// A mined post leaves the phone minutes after this returns — much later if the
// app is backgrounded and frozen — and the draft is its only user-visible copy
// until then. The auto-save is debounced and cancel() below drops the pending
// save, so the last second of typing would never reach it: flush it now.
// Geohash posts mine inline and drop the draft right away, so only the queued path needs it.
if (channel !is GeohashChatChannel && accountViewModel.account.powDifficultyFor(template.kind) != null) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
}
val draftToDelete = draftNote
onUiThread { cancel() }
@@ -147,9 +147,13 @@ class LongFormPostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
val tag = draftTag.current
draftNote = account.getOrCreateDraftNote(tag)
accountViewModel.launchSigner {
sendDraftSync()
// Post rotates the tag and then clears the composer. A save still queued from
// before that would see the empty text and delete the draft Post just saved
// for the post that is still mining, so it's skipped once the tag has moved on.
if (draftTag.current == tag) sendDraftSync()
}
}
}
@@ -356,6 +360,15 @@ class LongFormPostViewModel :
suspend fun sendPostSync() {
val template = createTemplate() ?: return
// A mined post leaves the phone minutes after this returns — much later if the
// app is backgrounded and frozen — and the draft is its only user-visible copy
// until then. The auto-save is debounced and cancel() below drops the pending
// save, so the last second of typing would never reach it: flush it now.
if (accountViewModel.account.powDifficultyFor(template.kind) != null) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
}
val draftToDelete = draftNote
onUiThread { cancel() }
@@ -228,9 +228,13 @@ open class ShortNotePostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
val tag = draftTag.current
draftNote = account.getOrCreateDraftNote(tag)
accountViewModel.launchSigner {
sendDraftSync()
// Post rotates the tag and then clears the composer. A save still queued from
// before that would see the empty text and delete the draft Post just saved
// for the post that is still mining, so it's skipped once the tag has moved on.
if (draftTag.current == tag) sendDraftSync()
}
}
}
@@ -1140,13 +1144,27 @@ open class ShortNotePostViewModel :
}
}
// captured before cancel() resets the chip
val chosenPow = powOverride
// A mined post leaves the phone minutes after this returns — much later if the
// app is backgrounded and frozen — and the draft is its only user-visible copy
// until then. The auto-save is debounced and cancel() below drops the pending
// save, so the last second of typing would never reach it: flush it now.
// A private note mines its gift wraps, not the kind-1 itself.
val minedKind = if (wantsPrivateNote && template.kind == TextNoteEvent.KIND) GiftWrapEvent.KIND else template.kind
if (accountViewModel.settings.automaticallyCreateDrafts() && accountViewModel.account.powDifficultyFor(minedKind, chosenPow) != null) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
// the same template sendDraftSync() would rebuild; reuse it.
val attachments = nip95attachments.flatMapTo(mutableSetOf()) { listOf(it.first, it.second) }
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
val privately = wantsPrivateNote
val threadTarget = groupThreadTarget
// captured before cancel() resets the chip
val chosenPow = powOverride
onUiThread { cancel() }
// Draft deletion lives INSIDE each publish continuation: when the post
@@ -1170,10 +1188,12 @@ open class ShortNotePostViewModel :
// reply must never fall through to a public publish path (the UI
// hides those toggles while private mode is on). The inner note and
// seals are signed inline; only wrap mining is queued — the content
// is committed (and checkpointed) by the time this returns.
// is committed (and checkpointed) by the time this returns. The draft
// goes only once the wraps are sent, like the public paths.
@Suppress("UNCHECKED_CAST")
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>, chosenPow)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>, chosenPow) {
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
return
}
+1
View File
@@ -623,6 +623,7 @@
<item quantity="other">Mining proof of work… (%1$d posts in queue)</item>
</plurals>
<string name="pow_mining_title">Mining proof of work</string>
<string name="pow_notification_sending">Sending to relays…</string>
<string name="pow_notification_cancel_all">Cancel</string>
<string name="pow_time_left">≈ %1$s left</string>
<string name="pow_time_left_soon">any moment now</string>