diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt new file mode 100644 index 000000000..7748ff1c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -0,0 +1,138 @@ +/* + * 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.scheduledposts + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Posts user-visible system notifications when a scheduled post completes + * (sent or failed). Without this, a worker firing in background offers zero + * diagnostic to the user — see the silent-publish bug the ack-aware worker + * now guards against; the notification closes the loop. + */ +object ScheduledPostNotifier { + private var channel: NotificationChannel? = null + private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000 + + fun notifySent( + context: Context, + post: ScheduledPost, + ) { + ensureChannel(context) + post( + context = context, + notId = idFor(post.id), + title = stringRes(context, R.string.scheduled_posts_notification_sent_title), + body = previewOf(post), + ) + } + + fun notifyFailed( + context: Context, + post: ScheduledPost, + error: String?, + ) { + ensureChannel(context) + val snippet = previewOf(post) + val body = + if (error.isNullOrBlank()) { + snippet + } else { + "$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}" + } + post( + context = context, + notId = idFor(post.id), + title = stringRes(context, R.string.scheduled_posts_notification_failed_title), + body = body, + ) + } + + private fun post( + context: Context, + notId: Int, + title: String, + body: String, + ) { + val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id) + val tapIntent = + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val tapPendingIntent = + PendingIntent.getActivity( + context, + notId, + tapIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val builder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(tapPendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + // Silently no-ops on Android 13+ if POST_NOTIFICATIONS isn't granted. + NotificationManagerCompat.from(context).notify(notId, builder.build()) + } + + private fun ensureChannel(context: Context) { + if (channel != null) return + channel = + NotificationChannel( + stringRes(context, R.string.app_notification_scheduled_posts_channel_id), + stringRes(context, R.string.app_notification_scheduled_posts_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description) + } + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel!!) + } + + private fun previewOf(post: ScheduledPost): String = + runCatching { + Event + .fromJson(post.signedEventJson) + .content + .take(120) + .trim() + }.getOrDefault("") + + // Distinct id per post so multiple completions don't collapse onto one row. + private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index d96a3838b..d32293035 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -32,9 +32,11 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import java.util.concurrent.TimeUnit /** @@ -60,6 +62,8 @@ class ScheduledPostWorker( private const val TAG = "ScheduledPostWorker" private const val WORK_NAME = "scheduled_post_worker" private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up" + private const val OK_TIMEOUT_SEC = 30L + private const val OK_POLL_MS = 500L fun schedule(context: Context) { val constraints = @@ -151,13 +155,23 @@ class ScheduledPostWorker( account.client.publish(event, relays) account.consumePostEvent(event, relays, extras) - store.markSent(post.id) - Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" } + val acks = waitForOk(account.client, event.id, relays.size) + if (acks > 0) { + store.markSent(post.id) + ScheduledPostNotifier.notifySent(applicationContext, post) + Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" } + } else { + val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s" + store.markFailed(post.id, msg) + ScheduledPostNotifier.notifyFailed(applicationContext, post, msg) + Log.w(TAG, "client.publish(${post.id}) failed: $msg") + } } catch (e: CancellationException) { throw e } catch (e: Exception) { Log.e(TAG, "Failed to publish scheduled post ${post.id}", e) store.markFailed(post.id, e.message) + ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message) } } @@ -170,4 +184,29 @@ class ScheduledPostWorker( Result.retry() } } + + /** + * Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends + * an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that + * acked. The publish call itself is fire-and-forget; without this wait the + * worker can be torn down before the websocket finishes delivery. + */ + private suspend fun waitForOk( + client: INostrClient, + eventId: String, + totalRelays: Int, + ): Int { + val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000 + while (System.currentTimeMillis() < deadline) { + val pending = client.pendingPublishRelaysFor(eventId) + // null means the outbox dropped the entry — every relay either OK'd + // or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack. + if (pending == null) return totalRelays + val acked = totalRelays - pending.size + if (acked > 0) return acked + delay(OK_POLL_MS) + } + val pending = client.pendingPublishRelaysFor(eventId) + return if (pending == null) totalRelays else totalRelays - pending.size + } } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index d2071a522..6a2356beb 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2484,4 +2484,8 @@ Odesláno Zrušeno Máte %1$d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány. + Naplánovaný příspěvek publikován + Naplánovaný příspěvek selhal + Naplánované příspěvky + Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 820972ad2..1aa4e5199 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2475,4 +2475,8 @@ anz der Bedingungen ist erforderlich Gesendet Abgebrochen Du hast %1$d geplante(n) Beitrag/Beiträge, der/die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht. + Geplanter Beitrag veröffentlicht + Geplanter Beitrag fehlgeschlagen + Geplante Beiträge + Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 9c59c2ce6..30788e286 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2470,4 +2470,8 @@ Enviado Cancelado Você tem %1$d post(s) agendado(s) que ainda não foram publicados. Sair excluirá esses posts permanentemente. + Post agendado publicado + Post agendado falhou + Posts agendados + Notificações quando um post agendado é publicado ou falha ao publicar. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index da590d0f1..1465ce335 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2469,4 +2469,8 @@ Skickat Avbrutet Du har %1$d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent. + Schemalagt inlägg publicerat + Schemalagt inlägg misslyckades + Schemalagda inlägg + Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0f925a22..c9a050e43 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -455,6 +455,11 @@ Tomorrow Logged out Logged out · %1$d scheduled post(s) deleted + Scheduled post published + Scheduled post failed + ScheduledPostsID + Scheduled posts + Notifications when a scheduled post is published or fails to publish. Send now? This post will publish to relays immediately. The original schedule will be discarded. Send diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index f7b111950..02b4ee74b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -72,6 +72,13 @@ interface INostrClient : AutoCloseable { relayList: Set, ) + /** + * Returns the relays that have not yet acknowledged [eventId] with an OK, + * or null if the event is not tracked (never published, or already fully done). + * Use to poll for delivery confirmation after [publish]. + */ + fun pendingPublishRelaysFor(eventId: HexKey): Set? + fun addConnectionListener(listener: RelayConnectionListener) fun removeConnectionListener(listener: RelayConnectionListener) @@ -123,6 +130,8 @@ class EmptyNostrClient : INostrClient { relayList: Set, ) { } + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = null + override fun addConnectionListener(listener: RelayConnectionListener) {} override fun removeConnectionListener(listener: RelayConnectionListener) {} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 84d221806..693aa7466 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -358,6 +358,8 @@ class NostrClient( override fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = eventOutbox.pendingRelaysFor(eventId) + override fun getReqFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) override fun getCountFiltersOrNull(subId: String): Map>? = activeCounts.getSubscriptionFiltersOrNull(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index afcace262..4ff97faed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -90,6 +90,14 @@ class PoolEventOutbox { return myEvents } + /** + * Returns the relays that have NOT yet acknowledged [eventId] with an OK, or + * null if the event is not currently tracked (never sent or already fully done). + * Callers can poll this after publish to detect when relays ack: the set shrinks + * as OKs arrive, then the entry is removed from the outbox (returns null). + */ + fun pendingRelaysFor(eventId: HexKey): Set? = eventOutbox[eventId]?.relaysLeft() + fun markAsSending( event: Event, relays: Set,