This commit is contained in:
Vitor Pamplona
2026-05-07 17:13:04 -04:00
34 changed files with 3180 additions and 13 deletions
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.resourceCacheInit
@@ -446,6 +448,11 @@ class AppModules(
// subscriptions, and NotificationRelayService.
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
val scheduledPostStore =
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
// Organizes cache clearing
val trimmingService by
lazy {
@@ -564,6 +571,12 @@ class AppModules(
// starts observing LocalCache for notification-worthy events
notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
@@ -0,0 +1,50 @@
/*
* 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
enum class ScheduledPostStatus {
PENDING,
PUBLISHING,
SENT,
FAILED,
CANCELLED,
}
data class ScheduledPost(
val id: String,
val accountPubkey: String,
val signedEventJson: String,
val relayUrls: List<String>,
val extraEventsJson: List<String>,
val publishAtSec: Long,
val createdAtSec: Long,
val status: ScheduledPostStatus = ScheduledPostStatus.PENDING,
val lastAttemptAtSec: Long? = null,
val attemptCount: Int = 0,
val lastError: String? = null,
// Set when the row enters a terminal state (SENT/CANCELLED). Drives retention.
val terminatedAtSec: Long? = null,
)
data class ScheduledPostFile(
val version: Int = 1,
val posts: List<ScheduledPost> = emptyList(),
)
@@ -0,0 +1,133 @@
/*
* 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.screen.loggedIn.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.ui.stringRes
/**
* 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 {
// @Volatile so the channel reference is visible across the WorkManager IO
// thread pool — two workers firing in the same window can race on
// ensureChannel. createNotificationChannel itself is idempotent.
@Volatile
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 = extractContentPreview(post, 120),
)
}
fun notifyFailed(
context: Context,
post: ScheduledPost,
error: String?,
) {
ensureChannel(context)
val snippet = extractContentPreview(post, 120)
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!!)
}
// 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()
}
@@ -0,0 +1,274 @@
/*
* 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 com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class ScheduledPostStore(
private val storageFile: File,
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
) {
private val mapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val mutex = Mutex()
private var loaded = false
private var posts: MutableList<ScheduledPost> = mutableListOf()
private val _flow = MutableStateFlow<List<ScheduledPost>>(emptyList())
/** Live snapshot of all stored posts. Updated on every mutation. */
val flow: StateFlow<List<ScheduledPost>> = _flow.asStateFlow()
suspend fun add(post: ScheduledPost) =
mutex.withLock {
ensureLoaded()
posts.add(post)
persist()
}
suspend fun cancel(id: String): Boolean =
mutex.withLock {
ensureLoaded()
val now = nowSec()
val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) }
if (updated) persist()
updated
}
suspend fun list(): List<ScheduledPost> =
mutex.withLock {
ensureLoaded()
posts.toList()
}
suspend fun listFor(accountPubkey: String): List<ScheduledPost> =
mutex.withLock {
ensureLoaded()
posts.filter { it.accountPubkey == accountPubkey }
}
/**
* Atomically claim posts due at or before [nowSec]: each PENDING post with
* publishAtSec <= now is flipped to PUBLISHING and returned. A concurrent
* claim from another worker will see those posts as PUBLISHING and skip them.
*/
suspend fun claimDuePosts(nowSec: Long): List<ScheduledPost> =
mutex.withLock {
ensureLoaded()
val dueIds =
posts
.filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec }
.map { it.id }
.toSet()
if (dueIds.isEmpty()) return@withLock emptyList()
dueIds.forEach { id ->
mutate(id) {
it.copy(
status = ScheduledPostStatus.PUBLISHING,
lastAttemptAtSec = nowSec,
attemptCount = it.attemptCount + 1,
)
}
}
persist()
posts.filter { it.id in dueIds }
}
suspend fun markSent(id: String) =
mutex.withLock {
ensureLoaded()
val now = nowSec()
if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist()
}
suspend fun markFailed(
id: String,
error: String?,
) = mutex.withLock {
ensureLoaded()
if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist()
}
/**
* Force a post to publish immediately by setting its publishAtSec to [nowSec]
* and resetting status to PENDING. Handles two cases with one method:
* - PENDING (future-scheduled): user wants to push it out now
* - FAILED: user wants to retry
* Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards
* so the worker picks it up promptly.
*/
suspend fun publishNow(
id: String,
nowSec: Long = System.currentTimeMillis() / 1000,
): Boolean =
mutex.withLock {
ensureLoaded()
val updated =
mutate(id) {
it.copy(
publishAtSec = nowSec,
status = ScheduledPostStatus.PENDING,
lastError = null,
terminatedAtSec = null,
)
}
if (updated) persist()
updated
}
/**
* Remove every row owned by [accountPubkey]. Used when the user deletes
* an account — the account's signed events should not linger. Returns the
* number of rows removed; persists once if any rows matched.
*/
suspend fun removeForAccount(accountPubkey: String): Int =
mutex.withLock {
ensureLoaded()
val before = posts.size
val removed = posts.removeAll { it.accountPubkey == accountPubkey }
val count = before - posts.size
if (removed) persist()
count
}
/**
* Revert a PUBLISHING claim back to PENDING (e.g. when the account is not
* loaded at fire time, so we should retry on the next cycle rather than
* marking the post failed permanently).
*/
suspend fun releaseClaim(id: String) =
mutex.withLock {
ensureLoaded()
val changed =
mutate(id) {
if (it.status == ScheduledPostStatus.PUBLISHING) {
it.copy(status = ScheduledPostStatus.PENDING)
} else {
it
}
}
if (changed) persist()
}
private fun mutate(
id: String,
transform: (ScheduledPost) -> ScheduledPost,
): Boolean {
val idx = posts.indexOfFirst { it.id == id }
if (idx < 0) return false
val before = posts[idx]
val after = transform(before)
if (after == before) return false
posts[idx] = after
return true
}
private fun ensureLoaded() {
if (loaded) return
posts =
try {
if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<ScheduledPostFile>(storageFile).posts.toMutableList()
} else {
mutableListOf()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load scheduled posts from $storageFile", e)
mutableListOf()
}
loaded = true
val purged = purgeStale(nowSec())
_flow.value = posts.toList()
if (purged) persist()
}
/**
* Drop SENT rows older than [SENT_RETENTION_SEC] and CANCELLED rows older
* than [CANCELLED_RETENTION_SEC]. Returns true if any row was removed.
* FAILED rows are kept indefinitely so the user can still see and retry them;
* PENDING / PUBLISHING rows are never purged.
*/
private fun purgeStale(now: Long): Boolean {
val before = posts.size
posts.removeAll { post ->
// terminatedAtSec is the post-PR field. Legacy rows lack it; fall back to
// lastAttemptAtSec (set at SENT) and finally createdAtSec. The fallback
// can purge old CANCELLED rows up to 30d earlier than intended on first
// run after upgrade — self-healing once new rows are written.
val age = now - (post.terminatedAtSec ?: post.lastAttemptAtSec ?: post.createdAtSec)
when (post.status) {
ScheduledPostStatus.SENT -> age > SENT_RETENTION_SEC
ScheduledPostStatus.CANCELLED -> age > CANCELLED_RETENTION_SEC
else -> false
}
}
return posts.size < before
}
/**
* Writes the snapshot to disk *while holding the data mutex*. This is a
* deliberate tradeoff: moving the write outside the lock would require a
* separate write-mutex (or a sequence number) to preserve write ordering
* across concurrent mutations — otherwise an older snapshot can clobber a
* newer one if the OS schedules the second write to finish first. For a
* file that's a few KB and a single-process owner with infrequent writes,
* holding the mutex across the rename is the simpler and correct choice.
* Revisit if the store ever grows past a hundred rows or starts seeing
* concurrent multi-writer pressure.
*/
private fun persist() {
val snapshot = posts.toList()
_flow.value = snapshot
storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try {
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot))
if (!tmp.renameTo(storageFile)) {
storageFile.delete()
if (!tmp.renameTo(storageFile)) {
Log.e(TAG, "Failed to rename $tmp to $storageFile")
tmp.delete()
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
tmp.delete()
}
}
companion object {
private const val TAG = "ScheduledPostStore"
const val FILE_NAME = "scheduled_posts.json"
private const val SENT_RETENTION_SEC = 7L * 24 * 3600
private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600
}
}
@@ -0,0 +1,212 @@
/*
* 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.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
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
/**
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
*
* - schedule(context): periodic, every 15 min (WorkManager minimum).
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
* due while the device was off or while WorkManager
* was deferred by Doze.
*/
class ScheduledPostWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
init {
// Logs every worker instantiation. Without this, "doWork never ran" looks
// identical to "constructor never invoked" — and the latter means the OS
// (Doze, battery-opt, JobScheduler quotas) never woke us at all.
Log.d(TAG) { "Worker instantiated (runAttempt=${workerParams.runAttemptCount}, tags=${workerParams.tags})" }
}
companion object {
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 =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
PeriodicWorkRequestBuilder<ScheduledPostWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
Log.d(TAG) {
"schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP) — KEEP policy preserves any existing schedule"
}
}
fun scheduleCatchUp(context: Context) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
OneTimeWorkRequestBuilder<ScheduledPostWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME_CATCH_UP,
ExistingWorkPolicy.KEEP,
request,
)
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
Log.d(TAG) { "cancel(): cancelled both periodic and catch-up workers" }
}
}
override suspend fun doWork(): Result {
val nowSec = System.currentTimeMillis() / 1000
Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" }
return try {
val appModules = Amethyst.instance
val store = appModules.scheduledPostStore
val all = store.list()
val pending = all.count { it.status == ScheduledPostStatus.PENDING }
Log.d(TAG) { "doWork() store has ${all.size} total, $pending PENDING" }
val claimed = store.claimDuePosts(nowSec)
if (claimed.isEmpty()) {
Log.d(TAG) { "doWork() EXIT no posts due" }
return Result.success()
}
Log.d(TAG) { "doWork() claimed ${claimed.size} due post(s)" }
for (post in claimed) {
val ageSec = nowSec - post.publishAtSec
Log.d(TAG) {
"Publishing post id=${post.id} publishAtSec=${post.publishAtSec} ageSec=$ageSec relays=${post.relayUrls.size}"
}
val account = appModules.accountsCache.accounts.value[post.accountPubkey]
if (account == null) {
Log.w(TAG, "Account ${post.accountPubkey} not loaded; releasing ${post.id} for retry")
store.releaseClaim(post.id)
continue
}
try {
val event = Event.fromJson(post.signedEventJson)
val relays = post.relayUrls.map { NormalizedRelayUrl(it) }.toSet()
val extras = post.extraEventsJson.map { Event.fromJson(it) }
Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" }
account.client.publish(event, relays)
account.consumePostEvent(event, relays, extras)
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)
}
}
Log.d(TAG) { "doWork() EXIT success" }
Result.success()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "doWork() unexpected failure", e)
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
}
}
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
fun SwipeToDeleteWithConfirmation(
modifier: Modifier = Modifier,
onDelete: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
content: @Composable (RowScope.() -> Unit),
) {
val scope = rememberCoroutineScope()
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
onCancel = {
scope.launch { dismissState.reset() }
},
confirmLabelRes = confirmLabelRes,
)
},
enableDismissFromEndToStart = true,
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
dismissState: SwipeToDismissBoxState,
onConfirmDelete: () -> Unit,
onCancel: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
) {
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
@@ -163,7 +166,7 @@ fun ConfirmDeleteBackground(
if (!settled) {
Color(0xFFFF1744)
} else {
MaterialTheme.colorScheme.surfaceVariant
Color.Transparent
},
label = "ConfirmDeleteBackground",
)
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
) {
Icon(
MaterialSymbols.Delete,
contentDescription = stringRes(id = R.string.request_deletion),
contentDescription = stringRes(id = confirmLabelRes),
tint = Color.White,
)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringRes(id = R.string.request_deletion),
text = stringRes(id = confirmLabelRes),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
@@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersSc
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
@@ -304,6 +305,7 @@ fun BuildNavigation(
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
@@ -44,6 +44,7 @@ enum class NavBarItem {
BOOKMARKS,
WEB_BOOKMARKS,
DRAFTS,
SCHEDULED_POSTS,
INTEREST_SETS,
EMOJI_PACKS,
WALLET,
@@ -142,6 +143,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Drafts,
resolveRoute = { Route.Drafts },
),
NavBarItem.SCHEDULED_POSTS to
NavBarItemDef(
id = NavBarItem.SCHEDULED_POSTS,
labelRes = R.string.scheduled_posts,
icon = MaterialSymbols.Schedule,
resolveRoute = { Route.ScheduledPosts },
),
NavBarItem.INTEREST_SETS to
NavBarItemDef(
id = NavBarItem.INTEREST_SETS,
@@ -291,6 +299,7 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
@@ -46,11 +46,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
@@ -261,16 +264,58 @@ private fun LogoutButton(
accountSessionManager: AccountSessionManager,
) {
var logoutDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
if (logoutDialog) {
val accountHex = remember(acc) { decodePublicKeyAsHexOrNull(acc.npub) }
val allPosts by Amethyst.instance.scheduledPostStore.flow
.collectAsStateWithLifecycle()
val unpublishedCount by remember(accountHex) {
derivedStateOf {
if (accountHex == null) {
0
} else {
allPosts.count {
it.accountPubkey == accountHex &&
(
it.status == ScheduledPostStatus.PENDING ||
it.status == ScheduledPostStatus.PUBLISHING ||
it.status == ScheduledPostStatus.FAILED
)
}
}
}
}
AlertDialog(
title = { Text(text = stringRes(R.string.log_out)) },
text = { Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out)) },
text = {
if (unpublishedCount > 0) {
Text(text = stringRes(R.string.scheduled_posts_logout_warning, unpublishedCount))
} else {
Text(text = stringRes(R.string.are_you_sure_you_want_to_log_out))
}
},
onDismissRequest = { logoutDialog = false },
confirmButton = {
TextButton(
onClick = {
// Snapshot the count *now* so the user-facing Toast matches what
// the dialog displayed, even if the store mutates between this
// tap and the cleanup completing.
val confirmedCount = unpublishedCount
logoutDialog = false
// Guard against a malformed npub: skip the Toast so we don't
// claim "Logged out" when logOff's coroutine bails early.
if (accountHex == null) return@TextButton
accountSessionManager.logOff(acc)
val toastMessage =
if (confirmedCount > 0) {
stringRes(context, R.string.scheduled_posts_logout_toast, confirmedCount)
} else {
stringRes(context, R.string.scheduled_posts_logout_toast_zero)
}
android.widget.Toast
.makeText(context, toastMessage, android.widget.Toast.LENGTH_SHORT)
.show()
},
) {
Text(text = stringRes(R.string.log_out))
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -84,6 +85,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -97,6 +99,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
@@ -604,12 +607,86 @@ fun CatalogSection(
ids.forEach { id ->
NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
CatalogNavigationRow(def, tint, accountViewModel, nav)
if (def.id == NavBarItem.SCHEDULED_POSTS) {
ScheduledPostsNavigationRow(def, tint, accountViewModel, nav)
} else {
CatalogNavigationRow(def, tint, accountViewModel, nav)
}
}
}
}
}
@Composable
private fun ScheduledPostsNavigationRow(
def: NavBarItemDef,
tint: Color,
accountViewModel: AccountViewModel,
nav: INav,
) {
val accountHex = accountViewModel.account.signer.pubKey
val allPosts by Amethyst.instance.scheduledPostStore.flow
.collectAsStateWithLifecycle()
val pendingCount by remember(accountHex) {
derivedStateOf {
allPosts.count {
it.accountPubkey == accountHex &&
(
it.status == ScheduledPostStatus.PENDING ||
it.status == ScheduledPostStatus.PUBLISHING ||
it.status == ScheduledPostStatus.FAILED
)
}
}
}
IconRowWithBadge(
title = def.labelRes,
icon = def.icon,
tint = tint,
badgeCount = pendingCount,
onClick = {
nav.closeDrawer()
nav.nav { def.resolveRoute(accountViewModel) }
},
)
}
@Composable
private fun IconRowWithBadge(
title: Int,
icon: MaterialSymbol,
tint: Color,
badgeCount: Int,
onClick: () -> Unit,
) {
val titleStr = stringRes(title)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
onClickLabel = titleStr,
).padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = icon,
contentDescription = titleStr,
modifier = Size22ModifierWith4Padding,
tint = tint,
)
Text(
modifier = IconRowTextModifier,
text = titleStr,
fontSize = Font18SP,
)
if (badgeCount > 0) {
Badge { Text(badgeCount.toString()) }
}
}
}
@Composable
fun CatalogNavigationRow(
def: NavBarItemDef,
@@ -205,6 +205,8 @@ sealed class Route {
@Serializable object Drafts : Route()
@Serializable object ScheduledPosts : Route()
@Serializable object AllSettings : Route()
@Serializable object AccountBackup : Route()
@@ -0,0 +1,50 @@
/*
* 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.ui.note.creators.scheduling
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ScheduleAtButton(
isActive: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription =
stringRes(
if (isActive) R.string.schedule_post_button_remove else R.string.schedule_post_button_add,
),
modifier = Modifier.size(20.dp),
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
)
}
}
@@ -0,0 +1,319 @@
/*
* 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.ui.note.creators.scheduling
import android.text.format.DateFormat
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerDialog
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.TemporalAdjusters
/**
* Two-stage date + time picker for scheduling a post for future publication.
*
* The selected time is rounded up to the next quarter-hour to set realistic
* expectations: the periodic worker that fires scheduled posts runs at
* WorkManager's minimum 15-min interval, so per-minute precision is misleading.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleAtPicker(
scheduledForSec: Long,
onChanged: (Long) -> Unit,
alwaysOnEnabled: Boolean = true,
hasMultipleAccounts: Boolean = false,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val currentTime =
Instant
.ofEpochMilli(scheduledForSec * 1000)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = scheduledForSec * 1000,
yearRange = currentTime.year..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = DateFormat.is24HourFormat(context),
)
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
) {
Icon(
symbol = MaterialSymbols.Timer,
contentDescription = stringRes(R.string.schedule_post_time_label),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.schedule_post),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringRes(R.string.schedule_post_helper),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
if (!alwaysOnEnabled) {
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
}
PresetChips(onPick = onChanged)
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(MaterialSymbols.Timer, contentDescription = stringRes(R.string.schedule_post_pick_time))
Spacer(Modifier.width(12.dp))
if (scheduledForSec < TimeUtils.oneMinuteFromNow()) {
Text(stringRes(R.string.schedule_post_pick_label), style = MaterialTheme.typography.bodyLarge)
} else {
Text(
text = stringRes(R.string.schedule_post_publishes_in, timeAheadNoDot(scheduledForSec, context)),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
showDatePicker = false
showTimePicker = true
}) { Text(stringRes(R.string.next)) }
},
) {
DatePicker(state = datePickerState)
}
}
if (showTimePicker) {
TimePickerDialog(
title = { Text(stringRes(R.string.schedule_post_picker_time_title)) },
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(
onClick = {
val datetimeLocalTimeZone =
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
(localDayAtZeroHourMillis / 1000) +
(timePickerState.hour * TimeUtils.ONE_HOUR) +
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
val rawSec = datetimeLocalTimeZone - offset.totalSeconds
onChanged(roundUpToNextQuarterHour(rawSec))
showTimePicker = false
},
) { Text(stringRes(R.string.confirm)) }
},
) {
TimePicker(state = timePickerState)
}
}
}
@Composable
private fun ReliabilityWarning(hasMultipleAccounts: Boolean) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Timer,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringRes(R.string.schedule_post_warning_title),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text =
stringRes(
if (hasMultipleAccounts) {
R.string.schedule_post_warning_multi
} else {
R.string.schedule_post_warning_single
},
),
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
@Composable
private fun PresetChips(onPick: (Long) -> Unit) {
val scroll = rememberScrollState()
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(scroll)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AssistChip(
onClick = { onPick(roundUpToNextQuarterHour(presetInOneHour())) },
label = { Text(stringRes(R.string.schedule_post_preset_in_one_hour)) },
)
AssistChip(
onClick = { onPick(roundUpToNextQuarterHour(presetTomorrowMorning())) },
label = { Text(stringRes(R.string.schedule_post_preset_tomorrow_morning)) },
)
AssistChip(
onClick = { onPick(roundUpToNextQuarterHour(presetNextMondayMorning())) },
label = { Text(stringRes(R.string.schedule_post_preset_next_monday_morning)) },
)
}
}
private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600
private fun presetTomorrowMorning(): Long {
val zone = ZoneId.systemDefault()
val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0))
return tomorrow9am.atZone(zone).toEpochSecond()
}
private fun presetNextMondayMorning(): Long {
val zone = ZoneId.systemDefault()
// Always step at least one day forward — if today is Monday, return next Monday.
val target =
LocalDate
.now(zone)
.plusDays(1)
.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY))
.atTime(LocalTime.of(9, 0))
return target.atZone(zone).toEpochSecond()
}
/**
* Rounds [epochSec] up to the next 15-minute boundary. If already on a boundary,
* returns the boundary itself. Edge case: if rounding yields a moment in the past
* (rare — only if the user picks the exact current quarter-hour), bump forward
* one slot.
*/
internal fun roundUpToNextQuarterHour(epochSec: Long): Long {
val quarter = 15 * 60L
val rounded = ((epochSec + quarter - 1) / quarter) * quarter
val nowSec = System.currentTimeMillis() / 1000
return if (rounded <= nowSec) rounded + quarter else rounded
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account
@@ -29,14 +30,14 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -140,8 +141,11 @@ class AccountSessionManager(
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
)
} else if (key.startsWith("nsec")) {
val privHex =
decodePrivateKeyAsHexOrNull(key)
?: throw Exception("Invalid nsec key")
AccountSettings(
keyPair = KeyPair(privKey = key.bechToBytes()),
keyPair = KeyPair(privKey = privHex.hexToByteArray()),
transientAccount = transientAccount,
)
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
@@ -356,6 +360,11 @@ class AccountSessionManager(
fun logOff(accountInfo: AccountInfo) {
scope.launch(Dispatchers.IO) {
val hex = decodePublicKeyAsHexOrNull(accountInfo.npub)
if (hex == null) {
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
return@launch
}
if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't
@@ -364,12 +373,14 @@ class AccountSessionManager(
.clear()
// log off and relogin with the 0 account
localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
loginWithDefaultAccount()
} else {
// delete without switching logins
localPreferences.deleteAccount(accountInfo)
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
accountsCache.removeAccount(hex)
Amethyst.instance.scheduledPostStore.removeForAccount(hex)
}
}
}
@@ -114,6 +114,7 @@ private fun PreloadFor(
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
@@ -48,11 +49,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -61,6 +67,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -79,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -97,6 +105,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
@@ -404,6 +415,26 @@ private fun NewPostScreenBody(
}
}
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences
.accountsFlow()
.collectAsStateWithLifecycle()
val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1
postViewModel.scheduledForSec?.let { current ->
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ScheduleAtPicker(
scheduledForSec = current,
onChanged = { postViewModel.scheduledForSec = it },
alwaysOnEnabled = alwaysOnEnabled,
hasMultipleAccounts = hasMultipleAccounts,
)
}
}
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = CenterVertically,
@@ -574,12 +605,63 @@ private fun NewPostScreenBody(
onDismiss = postViewModel::dismissAiResult,
)
BottomRowActions(postViewModel)
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
var showAlwaysOnPrompt by remember { mutableStateOf(false) }
BottomRowActions(
postViewModel = postViewModel,
onScheduleClicked = {
if (postViewModel.scheduledForSec != null) {
postViewModel.scheduledForSec = null
} else if (!alwaysOnEnabled) {
showAlwaysOnPrompt = true
} else {
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}
},
)
if (showAlwaysOnPrompt) {
AlertDialog(
onDismissRequest = { showAlwaysOnPrompt = false },
title = { Text(stringRes(R.string.schedule_post_always_on_prompt_title)) },
text = { Text(stringRes(R.string.schedule_post_always_on_prompt_message)) },
confirmButton = {
TextButton(onClick = {
showAlwaysOnPrompt = false
nav.nav(Route.Settings)
}) {
Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings))
}
},
dismissButton = {
TextButton(onClick = {
showAlwaysOnPrompt = false
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}) {
Text(stringRes(R.string.schedule_post_always_on_prompt_continue))
}
},
)
}
}
}
@Composable
private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
private fun BottomRowActions(
postViewModel: ShortNotePostViewModel,
onScheduleClicked: () -> Unit = {
postViewModel.scheduledForSec =
if (postViewModel.scheduledForSec != null) {
null
} else {
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}
},
) {
val scrollState = rememberScrollState()
Row(
modifier =
@@ -656,6 +738,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
postViewModel.toggleExpirationDate()
}
ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked)
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.service.ai.WritingResult
import com.vitorpamplona.amethyst.service.ai.WritingTone
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
// Scheduled posting: epoch seconds (UTC) when the post should be published.
// Null = post immediately on Send (existing behavior).
var scheduledForSec by mutableStateOf<Long?>(null)
// AI Writing Help for testing
private val useMockAi = false
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
val version = draftTag.current
val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
cancel()
if (scheduledFor != null && !anonymous) {
// Re-stamp the template with created_at = scheduled time so the post,
// when published later, shows up at its scheduled moment in feeds
// rather than as N minutes/hours old (= compose time).
val rescheduledTemplate =
EventTemplate<Event>(
createdAt = scheduledFor,
kind = template.kind,
tags = template.tags,
content = template.content,
)
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
Amethyst.instance.scheduledPostStore.add(
ScheduledPost(
id =
java.util.UUID
.randomUUID()
.toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extras.map { it.toJson() },
publishAtSec = scheduledFor,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
return
}
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
scheduledForSec = null
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -0,0 +1,139 @@
/*
* 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.
*/
@file:Suppress("ktlint:standard:filename")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
private const val TAG = "ScheduledPostMedia"
sealed class MediaUrl {
abstract val url: String
data class Image(
override val url: String,
) : MediaUrl()
data class Video(
override val url: String,
) : MediaUrl()
}
/**
* Parse the signed-event JSON, run [block], and return its result. Returns null on
* any non-cancellation parse failure and logs a warning. The shared shape for
* [extractFirstMediaUrl], [extractEventId], and [extractContentPreview].
*/
private inline fun <T : Any> parseSignedEvent(
post: ScheduledPost,
caller: String,
block: (Event) -> T?,
): T? =
try {
block(Event.fromJson(post.signedEventJson))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" }
null
}
/**
* Returns the first media URL referenced via an imeta tag in the post's signed event,
* or null when there is no imeta tag or the JSON cannot be parsed.
*
* Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime)
* -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always
* carry an `m` property.
*/
fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? =
parseSignedEvent(post, "extractFirstMediaUrl") { event ->
val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null
val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty()
when {
mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url)
else -> MediaUrl.Image(firstImeta.url)
}
}
/**
* Returns the signed event's id, or null when the JSON cannot be parsed.
*/
fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id }
/**
* Returns the first [maxLen] chars of the signed event's content (trimmed) or
* an empty string when the JSON cannot be parsed.
*/
fun extractContentPreview(
post: ScheduledPost,
maxLen: Int,
): String =
parseSignedEvent(post, "extractContentPreview") { event ->
event.content.take(maxLen).trim()
} ?: ""
@Composable
fun MediaThumbnail(media: MediaUrl) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = media.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp),
)
if (media is MediaUrl.Video) {
Icon(
symbol = MaterialSymbols.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
@@ -0,0 +1,636 @@
/*
* 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.ui.screen.loggedIn.scheduledposts
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ScheduledPostsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val accountPubkey = accountViewModel.account.signer.pubKey
val viewModel: ScheduledPostsViewModel =
viewModel(key = "scheduled-posts-$accountPubkey") {
ScheduledPostsViewModel.create(accountPubkey)
}
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
val context = LocalContext.current
var expandedId by remember { mutableStateOf<String?>(null) }
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
// refresh on a long-open list instead of being frozen at first composition.
val nowSec by produceState(initialValue = System.currentTimeMillis() / 1000) {
while (true) {
delay(60_000)
value = System.currentTimeMillis() / 1000
}
}
val dueSoonCount by remember {
derivedStateOf {
groups
.flatMap { it.posts }
.count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC }
}
}
val barHeight = if (totalActive > 0) 64.dp else TopBarSize
Scaffold(
topBar = {
ShorterTopAppBar(
expandedHeight = barHeight,
title = {
Column(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Text(stringRes(R.string.scheduled_posts))
if (totalActive > 0) {
val queuedText =
pluralStringResource(
id = R.plurals.scheduled_posts_subtitle_queued,
count = totalActive,
totalActive,
)
val dueText =
if (dueSoonCount > 0) {
pluralStringResource(
id = R.plurals.scheduled_posts_subtitle_due_suffix,
count = dueSoonCount,
dueSoonCount,
)
} else {
""
}
Text(
text = queuedText + dueText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
},
)
},
) { padding ->
if (groups.isEmpty()) {
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
} else {
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
groups.forEach { group ->
stickyHeader(key = group.day) {
DayHeader(group.day, today, context)
}
items(group.posts, key = { it.id }) { post ->
val isExpanded = expandedId == post.id
val rowAlpha by animateFloatAsState(
targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f,
label = "row-alpha",
)
Box(modifier = Modifier.alpha(rowAlpha)) {
if (isExpanded) {
Column(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
) {
ScheduledPostCardCollapsed(
post = post,
nowSec = nowSec,
onClick = { expandedId = null },
)
ScheduledPostCardExpandedPanel(
post = post,
onPublishNow = {
viewModel.publishNow(post.id)
ScheduledPostWorker.scheduleCatchUp(context)
expandedId = null
},
onDelete = {
viewModel.cancel(post.id)
expandedId = null
},
)
}
} else {
SwipeToDeleteWithConfirmation(
modifier = Modifier.fillMaxWidth().animateContentSize(),
onDelete = { viewModel.cancel(post.id) },
confirmLabelRes = R.string.quick_action_delete,
) {
ScheduledPostCardCollapsed(
post = post,
nowSec = nowSec,
onClick = { expandedId = post.id },
)
}
}
}
}
}
}
}
}
}
private const val URGENT_THRESHOLD_SEC = 3600L
// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse
// reads better than `tertiary` against a violet card.
private val PublishingAmber = Color(0xFFFBBF24)
@Composable
private fun Modifier.urgentEdge(enabled: Boolean): Modifier {
if (!enabled) return this
val gradientStart = MaterialTheme.colorScheme.primary
val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
val brush =
remember(gradientStart, gradientEnd) {
Brush.verticalGradient(listOf(gradientStart, gradientEnd))
}
return this.drawWithContent {
drawContent()
drawRect(
brush = brush,
topLeft = Offset(0f, 8.dp.toPx()),
size = Size(3.dp.toPx(), size.height - 16.dp.toPx()),
)
}
}
@Composable
private fun ScheduledPostCardCollapsed(
post: ScheduledPost,
nowSec: Long,
onClick: () -> Unit,
) {
val context = LocalContext.current
val preview = remember(post.id) { extractContentPreview(post, 200) }
val media = remember(post.id) { extractFirstMediaUrl(post) }
val relayCountText =
pluralStringResource(
id = R.plurals.scheduled_posts_relay_count,
count = post.relayUrls.size,
post.relayUrls.size,
)
val isFailed = post.status == ScheduledPostStatus.FAILED
// Composite the tint over surface so the card is opaque — otherwise the
// SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest.
val surface = MaterialTheme.colorScheme.surface
val containerColor =
if (isFailed) {
MaterialTheme.colorScheme.error
.copy(alpha = 0.06f)
.compositeOver(surface)
} else {
MaterialTheme.colorScheme.primary
.copy(alpha = 0.06f)
.compositeOver(surface)
}
val borderColor =
if (isFailed) {
MaterialTheme.colorScheme.error.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
}
Card(
onClick = onClick,
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC),
colors = CardDefaults.cardColors(containerColor = containerColor),
border = BorderStroke(1.dp, borderColor),
shape = RoundedCornerShape(14.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
StatusPill(post.status)
Text(
text = formatAtTime(post.publishAtSec, nowSec, context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (media != null) {
MediaThumbnail(media)
}
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = relayCountText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ScheduledPostCardExpandedPanel(
post: ScheduledPost,
onPublishNow: () -> Unit,
onDelete: () -> Unit,
) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val eventId = remember(post.id) { extractEventId(post) }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
Column {
SectionLabel(stringRes(R.string.relays))
post.relayUrls.forEach { url ->
Text(
text = url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f),
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
if (eventId != null) {
Column {
SectionLabel(stringRes(R.string.quick_action_copy_note_id))
Text(
text = eventId,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier =
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {},
onLongClick = {
clipboard.setText(AnnotatedString(eventId))
Toast
.makeText(
context,
stringRes(context, R.string.scheduled_posts_event_id_copied),
Toast.LENGTH_SHORT,
).show()
},
),
)
}
}
val err = post.lastError
if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) {
Text(
text = stringRes(R.string.scheduled_posts_error_prefix, err),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onPublishNow,
enabled = post.status != ScheduledPostStatus.PUBLISHING,
modifier = Modifier.weight(1f),
) {
val labelRes =
when (post.status) {
ScheduledPostStatus.FAILED -> R.string.retry
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing
else -> R.string.scheduled_posts_action_send_now
}
Text(stringRes(labelRes))
}
OutlinedButton(
onClick = onDelete,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.quick_action_delete))
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
val locale = LocalConfiguration.current.locales[0]
Text(
text = text.uppercase(locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
}
@Composable
private fun DayHeader(
day: LocalDate,
today: LocalDate,
context: android.content.Context,
) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = formatDayHeader(day, today, context),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun StatusPill(status: ScheduledPostStatus) {
val (labelRes, color, pulse) =
when (status) {
ScheduledPostStatus.PENDING -> {
Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false)
}
ScheduledPostStatus.PUBLISHING -> {
Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true)
}
ScheduledPostStatus.FAILED -> {
Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false)
}
ScheduledPostStatus.SENT -> {
Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false)
}
ScheduledPostStatus.CANCELLED -> {
Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false)
}
}
val dotAlpha =
if (pulse) {
val transition = rememberInfiniteTransition(label = "publishing-pulse")
transition
.animateFloat(
initialValue = 1f,
targetValue = 0.35f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "publishing-pulse-alpha",
).value
} else {
1f
}
Surface(
shape = RoundedCornerShape(50),
color = color.copy(alpha = 0.18f),
) {
Row(
modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(6.dp)
.clip(CircleShape)
.background(color.copy(alpha = dotAlpha)),
)
Spacer(Modifier.width(6.dp))
Text(
text = stringRes(labelRes),
color = color,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
)
}
}
}
@Composable
private fun EmptyState(
onCompose: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier =
Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = stringRes(R.string.scheduled_posts_empty_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringRes(R.string.scheduled_posts_empty_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onCompose) {
Text(stringRes(R.string.new_post))
}
}
}
}
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
private fun formatAtTime(
publishAtSec: Long,
nowSec: Long,
context: android.content.Context,
): String {
val absolute =
Instant
.ofEpochSecond(publishAtSec)
.atZone(ZoneId.systemDefault())
.toLocalTime()
.format(shortTimeFormatter)
return if (publishAtSec > nowSec) {
stringRes(context, R.string.scheduled_posts_at_time, absolute, timeAheadNoDot(publishAtSec, context))
} else {
stringRes(context, R.string.scheduled_posts_at_time_past, absolute, timeAgoNoDot(publishAtSec, context).trim())
}
}
private fun formatDayHeader(
day: LocalDate,
today: LocalDate,
context: android.content.Context,
): String =
when (day) {
today -> stringRes(context, R.string.today)
today.plusDays(1) -> stringRes(context, R.string.scheduled_posts_day_tomorrow)
else -> day.format(fullDateFormatter)
}
@@ -0,0 +1,107 @@
/*
* 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.ui.screen.loggedIn.scheduledposts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/** A day-bucket of posts for the scheduled-posts list screen. */
data class ScheduledPostDayGroup(
val day: LocalDate,
val posts: List<ScheduledPost>,
)
/**
* Drives the "Scheduled posts" screen for a single account. Filters the global
* ScheduledPostStore down to posts owned by [accountPubkey] that are still
* "in progress" — PENDING, PUBLISHING, or FAILED. SENT and CANCELLED rows are
* hidden from the list (they're "done").
*/
class ScheduledPostsViewModel(
private val store: ScheduledPostStore,
private val accountPubkey: String,
) : ViewModel() {
private val activeStatuses =
setOf(
ScheduledPostStatus.PENDING,
ScheduledPostStatus.PUBLISHING,
ScheduledPostStatus.FAILED,
)
/** Posts for [accountPubkey] in active statuses, grouped by local-day, sorted ascending. */
val groupedPosts: StateFlow<List<ScheduledPostDayGroup>> =
store.flow
.map { all ->
val zone = ZoneId.systemDefault()
all
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
.sortedBy { it.publishAtSec }
.groupBy { Instant.ofEpochSecond(it.publishAtSec).atZone(zone).toLocalDate() }
.map { (day, list) -> ScheduledPostDayGroup(day, list) }
.sortedBy { it.day }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val totalActive: StateFlow<Int> =
groupedPosts
.map { groups -> groups.sumOf { it.posts.size } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
fun cancel(id: String) {
viewModelScope.launch(Dispatchers.IO) {
store.cancel(id)
}
}
fun publishNow(id: String) {
viewModelScope.launch(Dispatchers.IO) {
store.publishNow(id)
}
}
companion object {
fun create(accountPubkey: String): ScheduledPostsViewModel =
ScheduledPostsViewModel(
store = Amethyst.instance.scheduledPostStore,
accountPubkey = accountPubkey,
)
}
}
@@ -2440,4 +2440,63 @@
<string name="emoji_public_explainer">Veřejná emoji jsou viditelná pro všechny a objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\", když je tento balíček ve vašem seznamu emoji.</string>
<string name="emoji_private_explainer">Soukromá emoji jsou šifrovaně uložena na relayích a viditelná pouze pro vás. Objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\" stejně jako veřejná.</string>
<string name="gif">Gif</string>
<string name="scheduled_posts">Naplánované příspěvky</string>
<string name="schedule_post">Naplánovat</string>
<string name="schedule_post_time_label">Naplánovaný čas</string>
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
<string name="schedule_post_pick_label">Naplánovat na…</string>
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
<string name="schedule_post_was_due">Mělo být před %1$s</string>
<string name="schedule_post_picker_time_title">Čas</string>
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
<string name="schedule_post_button_remove">Zrušit plánování</string>
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
<string name="scheduled_posts_day_tomorrow">Zítra</string>
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
<string name="scheduled_posts_logout_toast">Odhlášeno · smazáno %1$d naplánovaných příspěvků</string>
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
<string name="scheduled_posts_status_pending">Naplánováno</string>
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
<string name="scheduled_posts_status_failed">Selhalo</string>
<string name="scheduled_posts_status_sent">Odesláno</string>
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
<string name="scheduled_posts_logout_warning">Máte %1$d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</string>
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d ve frontě</item>
<item quantity="few">%d ve frontě</item>
<item quantity="many">%d ve frontě</item>
<item quantity="other">%d ve frontě</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 do 1 h</item>
<item quantity="few"> · %d do 1 h</item>
<item quantity="many"> · %d do 1 h</item>
<item quantity="other"> · %d do 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">na 1 relay</item>
<item quantity="few">na %d relaye</item>
<item quantity="many">na %d relayů</item>
<item quantity="other">na %d relayů</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
</resources>
@@ -2431,4 +2431,57 @@ anz der Bedingungen ist erforderlich</string>
<string name="emoji_public_explainer">Öffentliche Emojis sind für alle sichtbar und erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigungsauswahl, wenn dieses Paket in deiner Emoji-Liste ist.</string>
<string name="emoji_private_explainer">Private Emojis werden verschlüsselt auf Relays gespeichert und sind nur für dich sichtbar. Sie erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigung wie öffentliche.</string>
<string name="gif">Gif</string>
<string name="scheduled_posts">Geplante Beiträge</string>
<string name="schedule_post">Planen</string>
<string name="schedule_post_time_label">Geplante Zeit</string>
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
<string name="schedule_post_pick_label">Planen für…</string>
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
<string name="schedule_post_was_due">Fällig vor %1$s</string>
<string name="schedule_post_picker_time_title">Zeit</string>
<string name="schedule_post_button_add">Beitrag planen</string>
<string name="schedule_post_button_remove">Planung abbrechen</string>
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
<string name="scheduled_posts_day_tomorrow">Morgen</string>
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
<string name="scheduled_posts_logout_toast">Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht</string>
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
<string name="scheduled_posts_status_pending">Geplant</string>
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
<string name="scheduled_posts_status_sent">Gesendet</string>
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
<string name="scheduled_posts_logout_warning">Du hast %1$d geplante(n) Beitrag/Beiträge, der/die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</string>
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d in Warteschlange</item>
<item quantity="other">%d in Warteschlange</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 fällig in 1 Std.</item>
<item quantity="other"> · %d fällig in 1 Std.</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">an 1 Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
</resources>
@@ -2426,4 +2426,57 @@
<string name="emoji_public_explainer">Emojis públicos são visíveis para todos e aparecem no seu menu de reações e no seletor de autocompletar \":\" quando este pacote está na sua lista de emojis.</string>
<string name="emoji_private_explainer">Emojis privados são armazenados criptografados em relays e visíveis apenas para você. Eles aparecem no seu menu de reações e no autocompletar \":\" assim como os públicos.</string>
<string name="gif">Gif</string>
<string name="scheduled_posts">Posts agendados</string>
<string name="schedule_post">Agendar</string>
<string name="schedule_post_time_label">Hora agendada</string>
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
<string name="schedule_post_pick_time">Escolher horário agendado</string>
<string name="schedule_post_pick_label">Agendar para…</string>
<string name="schedule_post_publishes_in">Publica em %1$s</string>
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
<string name="schedule_post_picker_time_title">Hora</string>
<string name="schedule_post_button_add">Agendar post</string>
<string name="schedule_post_button_remove">Cancelar agendamento</string>
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
<string name="scheduled_posts_logout_toast">Desconectado · %1$d post(s) agendado(s) excluído(s)</string>
<string name="scheduled_posts_action_send_now">Enviar agora</string>
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
<string name="scheduled_posts_status_pending">Agendado</string>
<string name="scheduled_posts_status_publishing">Enviando…</string>
<string name="scheduled_posts_status_failed">Falhou</string>
<string name="scheduled_posts_status_sent">Enviado</string>
<string name="scheduled_posts_status_cancelled">Cancelado</string>
<string name="scheduled_posts_logout_warning">Você tem %1$d post(s) agendado(s) que ainda não foram publicados. Sair excluirá esses posts permanentemente.</string>
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d na fila</item>
<item quantity="other">%d na fila</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para 1 relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
</resources>
@@ -2425,4 +2425,57 @@
<string name="emoji_public_explainer">Offentliga emojis är synliga för alla och visas i din reaktionsmeny och i \":\"-autokompletteringen när detta paket finns i din emoji-lista.</string>
<string name="emoji_private_explainer">Privata emojis lagras krypterade på relän och är endast synliga för dig. De visas i din reaktionsmeny och i autokomplettering med \":\" precis som offentliga.</string>
<string name="gif">Gif</string>
<string name="scheduled_posts">Schemalagda inlägg</string>
<string name="schedule_post">Schemalägg</string>
<string name="schedule_post_time_label">Schemalagd tid</string>
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
<string name="schedule_post_pick_label">Schemalägg för…</string>
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
<string name="schedule_post_picker_time_title">Tid</string>
<string name="schedule_post_button_add">Schemalägg inlägg</string>
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
<string name="scheduled_posts_logout_toast">Utloggad · %1$d schemalagda inlägg raderade</string>
<string name="scheduled_posts_action_send_now">Skicka nu</string>
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
<string name="scheduled_posts_status_pending">Schemalagd</string>
<string name="scheduled_posts_status_publishing">Skickar…</string>
<string name="scheduled_posts_status_failed">Misslyckades</string>
<string name="scheduled_posts_status_sent">Skickat</string>
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
<string name="scheduled_posts_logout_warning">Du har %1$d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</string>
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d i kö</item>
<item quantity="other">%d i kö</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till 1 relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
</resources>
+54
View File
@@ -428,6 +428,60 @@
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
<string name="drafts">Drafts</string>
<string name="scheduled_posts">Scheduled posts</string>
<string name="schedule_post">Schedule</string>
<string name="schedule_post_time_label">Scheduled time</string>
<string name="schedule_post_helper">Posts publish within ~15 minutes of the scheduled time.</string>
<string name="schedule_post_pick_time">Pick scheduled time</string>
<string name="schedule_post_pick_label">Schedule for…</string>
<string name="schedule_post_publishes_in">Publishes in %1$s</string>
<string name="schedule_post_was_due">Was due %1$s ago</string>
<string name="schedule_post_picker_time_title">Time</string>
<string name="schedule_post_button_add">Schedule post</string>
<string name="schedule_post_button_remove">Cancel scheduling</string>
<string name="schedule_post_warning_title">Always-on notifications disabled</string>
<string name="schedule_post_warning_single">Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_warning_multi">Scheduled posts may not publish until you reopen the app. Other accounts\' scheduled posts won\'t fire while this account is active. Enable always-on in Settings → UI Preferences for reliable background scheduling.</string>
<string name="schedule_post_preset_in_one_hour">In 1 hour</string>
<string name="schedule_post_preset_tomorrow_morning">Tomorrow 9 AM</string>
<string name="schedule_post_preset_next_monday_morning">Next Monday 9 AM</string>
<string name="schedule_post_always_on_prompt_title">Enable always-on notifications?</string>
<string name="schedule_post_always_on_prompt_message">Scheduled posts publish reliably only when always-on notifications are enabled. Otherwise, they may not fire until you next reopen the app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Open settings</string>
<string name="schedule_post_always_on_prompt_continue">Continue anyway</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ago</string>
<string name="scheduled_posts_day_tomorrow">Tomorrow</string>
<string name="scheduled_posts_logout_toast_zero">Logged out</string>
<string name="scheduled_posts_logout_toast">Logged out · %1$d scheduled post(s) deleted</string>
<string name="scheduled_posts_notification_sent_title">Scheduled post published</string>
<string name="scheduled_posts_notification_failed_title">Scheduled post failed</string>
<string name="app_notification_scheduled_posts_channel_id" translatable="false">ScheduledPostsID</string>
<string name="app_notification_scheduled_posts_channel_name">Scheduled posts</string>
<string name="app_notification_scheduled_posts_channel_description">Notifications when a scheduled post is published or fails to publish.</string>
<string name="scheduled_posts_action_send_now">Send now</string>
<string name="scheduled_posts_empty_title">No scheduled posts</string>
<string name="scheduled_posts_empty_hint">Compose a note and tap the clock icon to schedule it for later.</string>
<string name="scheduled_posts_error_prefix">Error: %1$s</string>
<string name="scheduled_posts_status_pending">Scheduled</string>
<string name="scheduled_posts_status_publishing">Sending…</string>
<string name="scheduled_posts_status_failed">Failed</string>
<string name="scheduled_posts_status_sent">Sent</string>
<string name="scheduled_posts_status_cancelled">Cancelled</string>
<string name="scheduled_posts_logout_warning">You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d queued</item>
<item quantity="other">%d queued</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 due in 1h</item>
<item quantity="other"> · %d due in 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">to 1 relay</item>
<item quantity="other">to %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
<string name="polls">Polls</string>
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
@@ -0,0 +1,542 @@
/*
* 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 kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ScheduledPostStoreTest {
@get:Rule
val temp = TemporaryFolder()
private lateinit var file: File
@Before
fun setUp() {
file = File(temp.root, "scheduled_posts.json")
}
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
private fun samplePost(
id: String = "id-1",
publishAtSec: Long = 1_000,
accountPubkey: String = "pk1",
) = ScheduledPost(
id = id,
accountPubkey = accountPubkey,
signedEventJson = "{}",
relayUrls = listOf("wss://relay.example/"),
extraEventsJson = emptyList(),
publishAtSec = publishAtSec,
createdAtSec = 500,
)
@Test
fun add_persists_to_disk() =
runTest {
val store = newStore()
store.add(samplePost())
assertTrue("storage file should exist after add", file.exists())
val reloaded = newStore().list()
assertEquals(1, reloaded.size)
assertEquals("id-1", reloaded[0].id)
assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status)
}
@Test
fun claimDuePosts_returns_only_due_pending_posts() =
runTest {
val store = newStore()
store.add(samplePost(id = "due", publishAtSec = 1000))
store.add(samplePost(id = "future", publishAtSec = 5000))
store.add(samplePost(id = "also-due", publishAtSec = 999))
val claimed = store.claimDuePosts(nowSec = 1000)
val ids = claimed.map { it.id }.toSet()
assertEquals(setOf("due", "also-due"), ids)
}
@Test
fun claimDuePosts_flips_status_to_publishing() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 1000))
store.claimDuePosts(nowSec = 1000)
val all = store.list()
assertEquals(ScheduledPostStatus.PUBLISHING, all[0].status)
assertEquals(1, all[0].attemptCount)
assertEquals(1000L, all[0].lastAttemptAtSec)
}
@Test
fun claimDuePosts_second_call_returns_empty() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 1000))
val first = store.claimDuePosts(nowSec = 1000)
val second = store.claimDuePosts(nowSec = 1000)
assertEquals(1, first.size)
assertEquals(0, second.size)
}
@Test
fun concurrent_claimDuePosts_only_one_wins() =
runTest {
val store = newStore()
store.add(samplePost(id = "race", publishAtSec = 1000))
val results =
(1..10)
.map { async { store.claimDuePosts(nowSec = 1000) } }
.awaitAll()
val totalClaimed = results.sumOf { it.size }
assertEquals("exactly one concurrent caller should claim the post", 1, totalClaimed)
}
@Test
fun markSent_updates_status_and_clears_error() =
runTest {
val store = newStore()
store.add(samplePost())
store.markFailed("id-1", "earlier error")
store.markSent("id-1")
val all = store.list()
assertEquals(ScheduledPostStatus.SENT, all[0].status)
assertNull(all[0].lastError)
}
@Test
fun markFailed_records_error() =
runTest {
val store = newStore()
store.add(samplePost())
store.markFailed("id-1", "boom")
val all = store.list()
assertEquals(ScheduledPostStatus.FAILED, all[0].status)
assertEquals("boom", all[0].lastError)
}
@Test
fun releaseClaim_reverts_publishing_to_pending() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 1000))
store.claimDuePosts(nowSec = 1000)
store.releaseClaim("id-1")
val all = store.list()
assertEquals(ScheduledPostStatus.PENDING, all[0].status)
}
@Test
fun releaseClaim_does_not_touch_non_publishing() =
runTest {
val store = newStore()
store.add(samplePost())
store.releaseClaim("id-1")
assertEquals(ScheduledPostStatus.PENDING, store.list()[0].status)
}
@Test
fun cancel_sets_cancelled_status_and_returns_true() =
runTest {
val store = newStore()
store.add(samplePost())
val ok = store.cancel("id-1")
assertTrue(ok)
assertEquals(ScheduledPostStatus.CANCELLED, store.list()[0].status)
}
@Test
fun cancel_unknown_id_returns_false() =
runTest {
val store = newStore()
assertEquals(false, store.cancel("nope"))
}
@Test
fun listFor_filters_by_account() =
runTest {
val store = newStore()
store.add(samplePost(id = "a", accountPubkey = "pk-a"))
store.add(samplePost(id = "b", accountPubkey = "pk-b"))
store.add(samplePost(id = "c", accountPubkey = "pk-a"))
val filtered = store.listFor("pk-a").map { it.id }.toSet()
assertEquals(setOf("a", "c"), filtered)
}
@Test
fun cancelled_posts_are_not_claimed() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 1000))
store.cancel("id-1")
val claimed = store.claimDuePosts(nowSec = 5000)
assertEquals(0, claimed.size)
}
@Test
fun missing_file_loads_as_empty() =
runTest {
assertTrue("file should not exist before first read", !file.exists())
val store = newStore()
assertEquals(0, store.list().size)
}
@Test
fun corrupt_file_loads_as_empty() =
runTest {
file.writeText("not valid json {{{")
val store = newStore()
assertEquals(0, store.list().size)
}
@Test
fun publishNow_sets_publishAtSec_to_now_and_status_pending() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 9_999_999))
val ok = store.publishNow("id-1", nowSec = 1234)
assertTrue(ok)
val updated = store.list().single()
assertEquals(ScheduledPostStatus.PENDING, updated.status)
assertEquals(1234L, updated.publishAtSec)
}
@Test
fun publishNow_clears_failed_state_for_retry() =
runTest {
val store = newStore()
store.add(samplePost())
store.markFailed("id-1", "earlier failure")
store.publishNow("id-1", nowSec = 5000)
val updated = store.list().single()
assertEquals(ScheduledPostStatus.PENDING, updated.status)
assertNull(updated.lastError)
}
@Test
fun publishNow_unknown_id_returns_false() =
runTest {
val store = newStore()
assertEquals(false, store.publishNow("nope"))
}
@Test
fun publishNow_makes_post_immediately_claimable() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 9_999_999))
assertEquals(0, store.claimDuePosts(nowSec = 1000).size)
store.publishNow("id-1", nowSec = 1000)
val claimed = store.claimDuePosts(nowSec = 1000)
assertEquals(1, claimed.size)
assertEquals("id-1", claimed[0].id)
}
@Test
fun flow_emits_initial_empty_then_post_after_add() =
runTest {
val store = newStore()
assertEquals(0, store.flow.value.size)
store.add(samplePost())
assertEquals(1, store.flow.value.size)
assertEquals("id-1", store.flow.value[0].id)
}
@Test
fun flow_reflects_status_transitions() =
runTest {
val store = newStore()
store.add(samplePost(publishAtSec = 1000))
assertEquals(ScheduledPostStatus.PENDING, store.flow.value[0].status)
store.claimDuePosts(nowSec = 1000)
assertEquals(ScheduledPostStatus.PUBLISHING, store.flow.value[0].status)
store.markSent("id-1")
assertEquals(ScheduledPostStatus.SENT, store.flow.value[0].status)
}
@Test
fun flow_seeded_from_disk_on_first_access() =
runTest {
// Pre-populate the file via a first store instance
newStore().add(samplePost())
// Second store starts with empty in-memory flow until first access
val store = newStore()
assertEquals(0, store.flow.value.size)
// Triggering any read method causes ensureLoaded() to seed the flow
store.list()
assertEquals(1, store.flow.value.size)
}
@Test
fun removeForAccount_removes_all_matching_rows_and_returns_count() =
runTest {
val store = newStore()
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
store.add(samplePost(id = "a2", accountPubkey = "pk-a"))
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
val removed = store.removeForAccount("pk-a")
assertEquals(2, removed)
val remaining = store.list()
assertEquals(1, remaining.size)
assertEquals("b1", remaining[0].id)
}
@Test
fun removeForAccount_no_match_returns_zero_and_does_not_persist() =
runTest {
val store = newStore()
store.add(samplePost(accountPubkey = "pk-a"))
val bytesBefore = file.readBytes()
val removed = store.removeForAccount("pk-other")
assertEquals(0, removed)
assertEquals(1, store.list().size)
assertTrue("file should not be rewritten on no-op", bytesBefore.contentEquals(file.readBytes()))
}
@Test
fun removeForAccount_persists_to_disk() =
runTest {
val store = newStore()
store.add(samplePost(id = "a1", accountPubkey = "pk-a"))
store.add(samplePost(id = "b1", accountPubkey = "pk-b"))
store.removeForAccount("pk-a")
val reloaded = newStore().list()
assertEquals(1, reloaded.size)
assertEquals("b1", reloaded[0].id)
}
@Test
fun removeForAccount_purges_terminal_states_too() =
runTest {
val store = newStore()
store.add(samplePost(id = "p1", accountPubkey = "pk-a"))
store.add(samplePost(id = "p2", accountPubkey = "pk-a"))
store.markSent("p1")
store.cancel("p2")
val removed = store.removeForAccount("pk-a")
assertEquals(2, removed)
assertEquals(0, store.list().size)
}
@Test
fun cancel_stamps_terminatedAtSec() =
runTest {
val clock = 1_700_000_000L
val store = newStore { clock }
store.add(samplePost(id = "x"))
store.cancel("x")
assertEquals(clock, store.list().single().terminatedAtSec)
}
@Test
fun markSent_stamps_terminatedAtSec() =
runTest {
val clock = 1_700_000_000L
val store = newStore { clock }
store.add(samplePost(id = "x", publishAtSec = clock))
store.claimDuePosts(clock)
store.markSent("x")
assertEquals(clock, store.list().single().terminatedAtSec)
}
@Test
fun publishNow_clears_terminatedAtSec() =
runTest {
val clock = 1_700_000_000L
val store = newStore { clock }
store.add(samplePost(id = "x"))
store.cancel("x") // stamps terminatedAtSec
store.publishNow("x", nowSec = clock + 5)
assertNull(store.list().single().terminatedAtSec)
}
@Test
fun ensureLoaded_purges_sent_older_than_seven_days() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also { it.add(samplePost(id = "old-sent", publishAtSec = createTime)) }
newStore { createTime }.also {
it.claimDuePosts(createTime)
it.markSent("old-sent")
}
val eightDaysLater = createTime + 8L * 24 * 3600
val reloaded = newStore { eightDaysLater }
assertEquals(0, reloaded.list().size)
}
@Test
fun ensureLoaded_keeps_recent_sent() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also { it.add(samplePost(id = "fresh", publishAtSec = createTime)) }
newStore { createTime }.also {
it.claimDuePosts(createTime)
it.markSent("fresh")
}
val sixDaysLater = createTime + 6L * 24 * 3600
val reloaded = newStore { sixDaysLater }
assertEquals(1, reloaded.list().size)
assertEquals(ScheduledPostStatus.SENT, reloaded.list().single().status)
}
@Test
fun ensureLoaded_purges_cancelled_older_than_thirty_days() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also {
it.add(samplePost(id = "old-cancel"))
it.cancel("old-cancel")
}
val thirtyOneDaysLater = createTime + 31L * 24 * 3600
val reloaded = newStore { thirtyOneDaysLater }
assertEquals(0, reloaded.list().size)
}
@Test
fun ensureLoaded_keeps_recent_cancelled() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also {
it.add(samplePost(id = "recent-cancel"))
it.cancel("recent-cancel")
}
val twentyDaysLater = createTime + 20L * 24 * 3600
val reloaded = newStore { twentyDaysLater }
assertEquals(1, reloaded.list().size)
assertEquals(ScheduledPostStatus.CANCELLED, reloaded.list().single().status)
}
@Test
fun ensureLoaded_keeps_failed_indefinitely() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also { it.add(samplePost(id = "fail", publishAtSec = createTime)) }
newStore { createTime }.also {
it.claimDuePosts(createTime)
it.markFailed("fail", "boom")
}
val ninetyDaysLater = createTime + 90L * 24 * 3600
val reloaded = newStore { ninetyDaysLater }
assertEquals(1, reloaded.list().size)
assertEquals(ScheduledPostStatus.FAILED, reloaded.list().single().status)
}
@Test
fun ensureLoaded_persists_purge_to_disk() =
runTest {
val createTime = 1_700_000_000L
newStore { createTime }.also { it.add(samplePost(id = "old", publishAtSec = createTime)) }
newStore { createTime }.also {
it.claimDuePosts(createTime)
it.markSent("old")
}
val sizeBefore = file.length()
val eightDaysLater = createTime + 8L * 24 * 3600
newStore { eightDaysLater }.list() // triggers ensureLoaded + purge + persist
assertTrue("file should shrink after purge", file.length() < sizeBefore)
}
@Test
fun roundtrip_preserves_all_fields() =
runTest {
val original =
ScheduledPost(
id = "roundtrip",
accountPubkey = "pk-x",
signedEventJson = """{"kind":1,"content":"hi"}""",
relayUrls = listOf("wss://a/", "wss://b/"),
extraEventsJson = listOf("{}", "{}"),
publishAtSec = 1_700_000_000,
createdAtSec = 1_699_900_000,
status = ScheduledPostStatus.PENDING,
lastAttemptAtSec = null,
attemptCount = 0,
lastError = null,
)
newStore().add(original)
val reloaded = newStore().list().single()
assertEquals(original, reloaded)
assertNotNull(reloaded.relayUrls)
assertEquals(2, reloaded.relayUrls.size)
}
}
@@ -0,0 +1,117 @@
/*
* 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.ui.screen.loggedIn.scheduledposts
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ScheduledPostMediaTest {
private fun postWithJson(json: String) =
ScheduledPost(
id = "id",
accountPubkey = "pk",
signedEventJson = json,
relayUrls = emptyList(),
extraEventsJson = emptyList(),
publishAtSec = 0,
createdAtSec = 0,
status = ScheduledPostStatus.PENDING,
)
@Test
fun extractFirstMediaUrl_returns_null_when_no_imeta() {
val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
assertNull(extractFirstMediaUrl(postWithJson(json)))
}
@Test
fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Video)
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
}
@Test
fun extractFirstMediaUrl_defaults_to_image_when_no_mime() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_picks_first_when_multiple_imeta() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_returns_null_when_json_malformed() {
assertNull(extractFirstMediaUrl(postWithJson("{not json}")))
}
@Test
fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}"""
assertNull(extractFirstMediaUrl(postWithJson(json)))
}
@Test
fun extractFirstMediaUrl_picks_first_when_video_precedes_image() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Video)
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
}
@Test
fun extractEventId_returns_id_for_well_formed_json() {
val json =
"""{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
assertEquals("abc123", extractEventId(postWithJson(json)))
}
@Test
fun extractEventId_returns_null_when_json_malformed() {
assertNull(extractEventId(postWithJson("{not json}")))
}
}
@@ -112,6 +112,8 @@ class CoordinatorPipelineTest {
relayList: Set<NormalizedRelayUrl>,
) {}
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
@@ -72,6 +72,13 @@ interface INostrClient : AutoCloseable {
relayList: Set<NormalizedRelayUrl>,
)
/**
* 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<NormalizedRelayUrl>?
fun addConnectionListener(listener: RelayConnectionListener)
fun removeConnectionListener(listener: RelayConnectionListener)
@@ -123,6 +130,8 @@ class EmptyNostrClient : INostrClient {
relayList: Set<NormalizedRelayUrl>,
) { }
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
@@ -358,6 +358,8 @@ class NostrClient(
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = eventOutbox.activeOutboxCacheFor(url)
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = eventOutbox.pendingRelaysFor(eventId)
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = activeRequests.getSubscriptionFiltersOrNull(subId)
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = activeCounts.getSubscriptionFiltersOrNull(subId)
@@ -31,6 +31,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class PoolEventOutbox {
// @Volatile so the polling path (INostrClient.pendingPublishRelaysFor)
// sees current state from threads that didn't write the map. Mutations
// still happen on NostrClient's IO scope; this only closes the
// visibility gap for cross-thread readers.
@Volatile
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
@@ -90,6 +95,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<NormalizedRelayUrl>? = eventOutbox[eventId]?.relaysLeft()
fun markAsSending(
event: Event,
relays: Set<NormalizedRelayUrl>,
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class PoolEventOutboxState(
val event: Event,
var relaysRemaining: Set<NormalizedRelayUrl>,
@Volatile var relaysRemaining: Set<NormalizedRelayUrl>,
) {
private var failures = mapOf<NormalizedRelayUrl, Tries>()
@@ -88,6 +88,8 @@ private class TrackingNostrClient : INostrClient {
sentEvents.add(event to relayList)
}
override fun pendingPublishRelaysFor(eventId: String): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
@@ -207,6 +207,8 @@ private class CountingNostrClient(
onPublish()
}
override fun pendingPublishRelaysFor(eventId: String): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}