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,