diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5f0ce959a1..3ba243658a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) { + override suspend fun sendNip17PrivateMessage(template: EventTemplate) = 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, + 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, 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) { 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 dc90e77531..296b149393 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 @@ -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 : 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 /** Keep the service (and notification) alive while this is true; stop once it goes false. */ @@ -222,9 +235,15 @@ abstract class FlowProgressForegroundService : 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 : 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 : 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) 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 8b89244710..08d040d01c 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 @@ -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) { + 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 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 1f3da7d2aa..ad9e3471ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 7c3575ad77..043b16cef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 83e6eb1a9d..d03ab0074b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index a2692ce30d..72bc69b916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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, chosenPow) - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + accountViewModel.account.sendPrivateNote(template as EventTemplate, chosenPow) { + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } return } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1104e9be34..1219814557 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -623,6 +623,7 @@ Mining proof of work… (%1$d posts in queue) Mining proof of work + Sending to relays… Cancel ≈ %1$s left any moment now