Merge branch 'main' into claude/add-blossom-cache-support-ts8mK

This commit is contained in:
Vitor Pamplona
2026-05-08 08:15:44 -04:00
committed by GitHub
315 changed files with 46606 additions and 941 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.app.Application
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -41,6 +42,17 @@ class Amethyst : Application() {
Log.d("AmethystApp") { "onCreate $this" }
instance = AppModules(this)
// After-background foreground recycle: when the app returns to
// the foreground after spending more than ~5 s in the
// background, publish a network-change event so every active
// NestViewModel recycles its underlying QUIC session. Covers
// the case where Android reclaims our UDP socket FD while
// backgrounded — the connectivity callback in
// `NestForegroundService` doesn't fire there because the
// network itself is still up. See `AppForegroundRecycleHook`'s
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug
@@ -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.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -469,6 +471,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 {
@@ -587,6 +594,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 ->
@@ -139,7 +139,7 @@ fun debugState(context: Context) {
Log.d(
STATE_DUMP_TAG,
"Observables: " +
LocalCache.observables.size,
LocalCache.observables.size(),
)
Log.d(
@@ -529,6 +529,14 @@ class Account(
return false
}
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
if (settings.updateDisableClientTag(disable)) {
sendNewAppSpecificData()
return true
}
return false
}
suspend fun updateFilterSpam(filterSpam: Boolean): Boolean {
if (settings.updateFilterSpam(filterSpam)) {
if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
@@ -418,6 +418,14 @@ class AccountSettings(
saveAccountSettings()
}
}
fun updateDisableClientTag(disable: Boolean): Boolean =
if (syncedSettings.security.updateDisableClientTag(disable)) {
saveAccountSettings()
true
} else {
false
}
// ---
// list names
@@ -55,6 +55,7 @@ class AccountSyncedSettings(
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
MutableStateFlow(internalSettings.security.maxHashtagLimit),
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
MutableStateFlow(internalSettings.security.disableClientTag),
)
val videoPlayer =
AccountVideoPlayerPreferences(
@@ -82,6 +83,7 @@ class AccountSyncedSettings(
security.filterSpamFromStrangers.value,
security.maxHashtagLimit.value,
security.sendKind0EventsToLocalRelay.value,
security.disableClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
)
@@ -138,6 +140,10 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
}
if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) {
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
}
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
@@ -233,6 +239,7 @@ class AccountSecurityPreferences(
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
) {
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) {
@@ -273,4 +280,12 @@ class AccountSecurityPreferences(
} else {
false
}
fun updateDisableClientTag(disable: Boolean): Boolean =
if (disable != disableClientTag.value) {
disableClientTag.tryEmit(disable)
true
} else {
false
}
}
@@ -147,4 +147,5 @@ class AccountSecurityPreferencesInternal(
var filterSpamFromStrangers: Boolean = true,
val maxHashtagLimit: Int = 5,
var sendKind0EventsToLocalRelay: Boolean = false,
var disableClientTag: Boolean = false,
)
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
@@ -261,7 +262,6 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
interface ILocalCache {
fun markAsSeen(
@@ -290,7 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
val deletionIndex = DeletionIndex()
val observables = ConcurrentHashMap<Observable, Observable>(10)
/**
* Inverted index over the active [Observable]s. New events fan
* out only to observers whose filter actually narrows on a field
* the event carries (author, kind, single-letter tag), instead
* of waking every observer per event. Predicate-only observers
* (no underlying [Filter]) live in the unindexed pool and still
* see every event.
*/
val observables = FilterIndex<Observable>()
fun Filter.match(note: Note): Boolean {
val event = note.event
@@ -361,10 +369,10 @@ object LocalCache : ILocalCache, ICacheProvider {
newFilter.init()
observables[newFilter] = newFilter
observables.register(filter, newFilter)
awaitClose {
observables.remove(newFilter)
observables.unregister(newFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -377,10 +385,10 @@ object LocalCache : ILocalCache, ICacheProvider {
cachedFilter.init()
observables.put(cachedFilter, cachedFilter)
observables.register(filter, cachedFilter)
awaitClose {
observables.remove(cachedFilter)
observables.unregister(cachedFilter)
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
@@ -402,14 +410,28 @@ object LocalCache : ILocalCache, ICacheProvider {
trySend(it)
}
observables.put(newFilter, newFilter)
// Unindexed: predicate is opaque, the index can't narrow.
// Caller is delivered every event and runs the predicate.
observables.registerUnindexed(newFilter)
awaitClose {
observables.remove(newFilter)
observables.unregister(newFilter)
}
}
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
callbackFlow {
val newFilter =
NewEventMatchingFilter<T>(filter::match) {
trySend(it)
}
observables.register(filter, newFilter)
awaitClose {
observables.unregister(newFilter)
}
}
@Suppress("UNCHECKED_CAST")
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
@@ -2514,22 +2536,21 @@ object LocalCache : ILocalCache, ICacheProvider {
private fun refreshNewNoteObservers(newNote: Note) {
val event = newNote.event as Event
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { _, u ->
u.new(event, newNote)
}
observables.forEach(observableBiConsumer)
// Index-driven fanout: only observers whose filter narrows
// on a field this event carries (or that registered as
// unindexed) get woken up. The match check inside each
// observer's `new()` still enforces negative constraints.
for (observer in observables.candidatesFor(event)) {
observer.new(event, newNote)
}
live.newNote(newNote)
}
private fun refreshDeletedNoteObservers(newNote: Note) {
val observableBiConsumer =
java.util.function.BiConsumer<Observable, Observable> { _, u ->
u.remove(newNote)
}
observables.forEach(observableBiConsumer)
// Deletes don't have a filterable shape — every observer
// might hold this note in its result set, so iterate them
// all. The index doesn't help here.
observables.forEach { it.remove(newNote) }
live.removedNote(newNote)
}
@@ -135,7 +135,12 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey]
if (cached != null) return cached
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
val signerWithClientTag =
NostrSignerWithClientTag(
inner = signer,
clientName = CLIENT_TAG_NAME,
disabled = { accountSettings.syncedSettings.security.disableClientTag.value },
)
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
@@ -0,0 +1,173 @@
/*
* 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.nests
import android.app.Activity
import android.app.Application
import android.os.Bundle
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
import com.vitorpamplona.quartz.utils.Log
/**
* Pure-state foreground/background tracker, decoupled from
* `android.app.Activity` so the unit tests can drive it without
* Robolectric or Mockito.
*
* See [AppForegroundRecycleHook] for the production motivation /
* threshold-rationale kdoc this class is just the testable core.
*
* Threading: all state-mutating methods are documented to run on the
* Android main thread (Application lifecycle callbacks fire there);
* tests call them serially, so no synchronisation is needed.
*/
class AppForegroundCounter(
private val backgroundThresholdMs: Long = AppForegroundRecycleHook.DEFAULT_BACKGROUND_THRESHOLD_MS,
private val publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
private val nowMillis: () -> Long = { System.currentTimeMillis() },
) {
private var startedActivities = 0
private var lastBackgroundedAtMillis: Long = -1L
/**
* Count of recycle events fired since construction. Diagnostic
* surface for tests; production code observes the side-effect via
* [NestNetworkChangeBus] instead.
*/
var recyclesFired: Int = 0
private set
/**
* Increment the started-activity counter and, if this is the
* 0 1 transition AND the app spent [backgroundThresholdMs]
* in the background, fire [publishEvent]. The first
* onActivityStarted after process start is a no-op (no prior
* background timestamp to compare against).
*/
fun onActivityStarted() {
val wasBackgrounded = startedActivities == 0
startedActivities++
if (!wasBackgrounded) return
val backgroundedAt = lastBackgroundedAtMillis
if (backgroundedAt < 0L) return
val backgroundedFor = nowMillis() - backgroundedAt
if (backgroundedFor < backgroundThresholdMs) {
Log.d("AppForegroundCounter") {
"skipping recycle on resume after only ${backgroundedFor}ms background " +
"(threshold=${backgroundThresholdMs}ms)"
}
return
}
Log.d("AppForegroundCounter") {
"publishing recycle event on resume after ${backgroundedFor}ms background"
}
recyclesFired++
publishEvent()
}
/**
* Decrement the counter; on N 0 transition, record the
* background timestamp.
*/
fun onActivityStopped() {
startedActivities--
if (startedActivities <= 0) {
startedActivities = 0
lastBackgroundedAtMillis = nowMillis()
}
}
}
/**
* Application-wide observer that publishes a
* [NestNetworkChangeBus] event when the app returns to the foreground
* after spending more than [backgroundThresholdMs] in the background.
*
* Production motivation: Android may reclaim a backgrounded app's
* UDP-socket file descriptors as it ages out of the foreground app
* pool (the kernel's watcher trims after roughly 30 s of no foreground
* activity, with quite a bit of variance per OEM). When the user
* resumes the app, the QUIC connection sitting on the now-reclaimed
* socket has dead OS-level state, but the connection-level FSM
* doesn't know that yet the next `socket.send` throws, and only
* then does the send-loop catch surface CLOSED.
*
* The downstream
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
* already observes [NestNetworkChangeBus] for the network-handover
* case (Wi-Fi cellular). We piggy-back on the same bus here: a
* "long enough background" event has the same shape from the QUIC
* driver's perspective as a network change recycle the underlying
* session and let the [com.vitorpamplona.nestsclient.connectReconnectingNestsListener]
* / `connectReconnectingNestsSpeaker` orchestrators reconnect.
*
* Why a threshold instead of fire-on-every-resume:
* - A short background (notification pulldown, biometric auth, lock-
* screen glance) lasts < 1 s and the socket is still healthy. A
* forced recycle there is a wasted ~1 s re-handshake gap of audio
* silence annoying to users for no benefit.
* - A long background (call from another app, screen off > 30 s,
* home-button-then-back) commonly leaves the socket dead. Better
* to eat one re-handshake gap than 30 s of silence while the QUIC
* PTO times out.
* 5 seconds is the sweet spot: well over typical UI transitions but
* well under any plausible socket-reclaim window.
*/
class AppForegroundRecycleHook(
backgroundThresholdMs: Long = DEFAULT_BACKGROUND_THRESHOLD_MS,
publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
nowMillis: () -> Long = { System.currentTimeMillis() },
) : Application.ActivityLifecycleCallbacks {
private val counter = AppForegroundCounter(backgroundThresholdMs, publishEvent, nowMillis)
override fun onActivityStarted(activity: Activity) {
counter.onActivityStarted()
}
override fun onActivityStopped(activity: Activity) {
counter.onActivityStopped()
}
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?,
) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(
activity: Activity,
outState: Bundle,
) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
companion object {
/**
* Default 5 000 ms well above the longest plausible UI
* transition (notification pull, biometric prompt) and well
* below the 30 s timing the Android kernel uses to reclaim
* idle UDP sockets.
*/
const val DEFAULT_BACKGROUND_THRESHOLD_MS = 5_000L
}
}
@@ -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,141 @@
/*
* 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 notificationManager = NotificationManagerCompat.from(context)
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail out
// explicitly so lint doesn't flag the notify() call and so a denied
// user doesn't see a misleading no-op log.
if (!notificationManager.areNotificationsEnabled()) return
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())
try {
notificationManager.notify(notId, builder.build())
} catch (_: SecurityException) {
// Race: permission revoked between the check above and notify().
}
}
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,280 @@
/*
* 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)) {
if (!storageFile.delete()) {
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
}
if (!tmp.renameTo(storageFile)) {
Log.e(TAG, "Failed to rename $tmp to $storageFile")
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
}
}
}
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,211 @@
/*
* 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) {
// 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.
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
val acked = totalRelays - pending.size
if (acked > 0) return acked
delay(OK_POLL_MS)
}
val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays
return totalRelays - pending.size
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.util.LruCache
import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalConfiguration
@@ -131,6 +132,15 @@ fun stringRes(
)
}
// Plural resolver for non-composable scope (e.g. onClick callbacks). Not cached:
// the resolved string varies by `count` quantity and the resourceCache is keyed by id.
fun pluralStringRes(
ctx: Context,
@PluralsRes id: Int,
count: Int,
vararg formatArgs: Any?,
): String = ctx.resources.getQuantityString(id, count, *formatArgs)
/**
* This cache can only be used if the painter is the only copy on the screen
* It should store a separate Painter for each size. It's safe to just assume
@@ -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,14 @@ 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.res.pluralStringResource
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,9 +61,11 @@ 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
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
@@ -261,16 +266,70 @@ 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 =
pluralStringResource(
id = R.plurals.scheduled_posts_logout_warning,
count = unpublishedCount,
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) {
pluralStringRes(
context,
R.plurals.scheduled_posts_logout_toast,
confirmedCount,
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()
@@ -82,6 +82,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
@@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
@@ -346,6 +348,8 @@ fun PayReaction(
val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
LoadAddressableNote(address, accountViewModel) { note ->
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
@@ -395,6 +399,16 @@ fun PayReaction(
}
},
)
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
}
}
}
@@ -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)
}
}
}
@@ -1152,6 +1152,8 @@ class AccountViewModel(
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
fun updateFilterSpam(filterSpam: Boolean) =
launchSigner {
if (account.updateFilterSpam(filterSpam)) {
@@ -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("")
@@ -329,7 +329,7 @@ private fun OnStageIdleControls(
TalkButton(
onClick = {
if (context.hasMicPermission()) {
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = true)
viewModel.startBroadcast(speakerPubkeyHex, initialMuted = false)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
@@ -28,8 +28,10 @@ import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -40,6 +42,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import kotlinx.coroutines.launch
@Composable
fun PaymentButton(
@@ -72,6 +76,8 @@ fun PaymentButton(
@Composable
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -111,6 +117,16 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
}
},
)
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
}
}
}
@@ -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,639 @@
/*
* 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.rememberCoroutineScope
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.LocalClipboard
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.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.components.util.setText
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 kotlinx.coroutines.launch
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 clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
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 = {
scope.launch { clipboardManager.setText(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,
)
}
}
@@ -371,6 +371,21 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) {
)
}
SettingsRow(
R.string.disable_client_tag_title,
R.string.disable_client_tag_explainer,
) {
var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) }
Switch(
checked = disableClientTag,
onCheckedChange = {
disableClientTag = it
accountViewModel.updateDisableClientTag(disableClientTag)
},
)
}
SettingsRow(
R.string.show_sensitive_content_title,
R.string.show_sensitive_content_explainer,
@@ -396,6 +396,75 @@
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
<string name="drafts">Koncepty</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>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Odhlášeno · smazán %d naplánovaný příspěvek</item>
<item quantity="few">Odhlášeno · smazány %d naplánované příspěvky</item>
<item quantity="many">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
<item quantity="other">Odhlášeno · smazáno %d naplánovaných příspěvků</item>
</plurals>
<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>
<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>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Máte %d naplánovaný příspěvek, který ještě nebyl publikován. Odhlášením bude trvale smazán.</item>
<item quantity="few">Máte %d naplánované příspěvky, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
<item quantity="many">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
<item quantity="other">Máte %d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</item>
</plurals>
<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"> · %d 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 %d 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>
<string name="polls">Ankety</string>
<string name="open_polls">Otevřené</string>
<string name="closed_polls">Uzavřené</string>
@@ -402,6 +402,64 @@ anz der Bedingungen ist erforderlich</string>
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
<string name="drafts">Entwürfe</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_past">%1$s · vor %2$s</string>
<string name="scheduled_posts_day_tomorrow">Morgen</string>
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Abgemeldet · %d geplanter Beitrag gelöscht</item>
<item quantity="other">Abgemeldet · %d geplante Beiträge gelöscht</item>
</plurals>
<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>
<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>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Du hast %d geplanten Beitrag, der noch nicht veröffentlicht wurde. Beim Abmelden wird er dauerhaft gelöscht.</item>
<item quantity="other">Du hast %d geplante Beiträge, die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</item>
</plurals>
<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"> · %d 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 %d Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
<string name="polls">Umfragen</string>
<string name="open_polls">Offen</string>
<string name="closed_polls">Geschlossen</string>
@@ -398,6 +398,59 @@
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
<string name="drafts">Piszkozatok</string>
<string name="scheduled_posts">Ütemezett bejegyzések</string>
<string name="schedule_post">Ütemezés</string>
<string name="schedule_post_time_label">Ütemezés ideje</string>
<string name="schedule_post_helper">A bejegyzések a tervezett időponttól számított körülbelül 15 percen belül jelennek meg.</string>
<string name="schedule_post_pick_time">Válasszon ki egy időpontot</string>
<string name="schedule_post_pick_label">Ütemezés…</string>
<string name="schedule_post_publishes_in">Közzététel: %1$s</string>
<string name="schedule_post_was_due">Már %1$s ezelőtt esedékes volt</string>
<string name="schedule_post_picker_time_title">Időpont</string>
<string name="schedule_post_button_add">Bejegyzés ütemezése</string>
<string name="schedule_post_button_remove">Ütemezés visszavonása</string>
<string name="schedule_post_warning_title">Folyamatos értesítési szolgáltatás letiltva</string>
<string name="schedule_post_warning_single">Az ütemezett bejegyzések csak akkor jelennek meg, ha legközelebb újra megnyitja az alkalmazást. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
<string name="schedule_post_warning_multi">Az ütemezett bejegyzések csak akkor jelennek meg, ha újra megnyitja az alkalmazást. Más fiókok ütemezett bejegyzései nem jelennek meg, amíg ez a fiók aktív. A megbízható háttérbeli ütemezés érdekében engedélyezze a „Folyamatos értesítési szolgáltatás” beállítását a Beállítások → Felhasználói felület beállítása menüpontban.</string>
<string name="schedule_post_preset_in_one_hour">1 órán belül</string>
<string name="schedule_post_preset_tomorrow_morning">Holnap délelőtt 9 órakor</string>
<string name="schedule_post_preset_next_monday_morning">Következő hétfő délelőtt 9 órakor</string>
<string name="schedule_post_always_on_prompt_title">Bekapcsolja a folyamatos értesítési szolgáltatást?</string>
<string name="schedule_post_always_on_prompt_message">Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg.</string>
<string name="schedule_post_always_on_prompt_open_settings">Beállítások menyitása</string>
<string name="schedule_post_always_on_prompt_continue">Folytatás mindenképpen</string>
<string name="scheduled_posts_at_time">%1$s → %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s ezelőtt</string>
<string name="scheduled_posts_day_tomorrow">Holnap</string>
<string name="scheduled_posts_logout_toast_zero">Kijelentkezve</string>
<string name="scheduled_posts_logout_toast">Ön kijelentkezett · %1$d ütemezett bejegyzés törölve</string>
<string name="scheduled_posts_notification_sent_title">Ütemezett bejegyzés közzétéve</string>
<string name="scheduled_posts_notification_failed_title">Nem sikerült közzétenni egy ütemezett bejegyzést</string>
<string name="app_notification_scheduled_posts_channel_name">Ütemezett bejegyzések</string>
<string name="app_notification_scheduled_posts_channel_description">Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni.</string>
<string name="scheduled_posts_action_send_now">Küldés most</string>
<string name="scheduled_posts_empty_title">Nincsenek ütemezett bejegyzések</string>
<string name="scheduled_posts_empty_hint">Írja meg az üzenetet, majd koppintson az óraikonra, hogy a közzétételét későbbre ütemezhesse.</string>
<string name="scheduled_posts_error_prefix">Hiba: %1$s</string>
<string name="scheduled_posts_status_pending">Ütemezve</string>
<string name="scheduled_posts_status_publishing">Küldés…</string>
<string name="scheduled_posts_status_failed">Sikertelen</string>
<string name="scheduled_posts_status_sent">Elküldve</string>
<string name="scheduled_posts_status_cancelled">Megszakitva</string>
<string name="scheduled_posts_logout_warning">Önnek %1$d ütemezett bejegyzése van, amelyeket még nem tett közzé. Kijelentkezés esetén ezek véglegesen törlődnek.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d sorbaállítva</item>
<item quantity="other">%d sorbaállítva</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 bejegyzés 1 órán belül</item>
<item quantity="other"> · %d bejegyzés 1 órán belül</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">1 átjátszóhoz</item>
<item quantity="other">%d átjátszóhoz</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Bejegyzés azonosítója másolva</string>
<string name="polls">Szavazások</string>
<string name="open_polls">Megnyitás</string>
<string name="closed_polls">Lezárva</string>
@@ -396,6 +396,67 @@
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
<string name="drafts">Rascunhos</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>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Desconectado · %d post agendado excluído</item>
<item quantity="many">Desconectado · %d posts agendados excluídos</item>
<item quantity="other">Desconectado · %d posts agendados excluídos</item>
</plurals>
<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>
<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>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Você tem %d post agendado que ainda não foi publicado. Sair excluirá esse post permanentemente.</item>
<item quantity="many">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
<item quantity="other">Você tem %d posts agendados que ainda não foram publicados. Sair excluirá esses posts permanentemente.</item>
</plurals>
<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"> · %d em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para %d relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
<string name="polls">Enquetes</string>
<string name="open_polls">Abertas</string>
<string name="closed_polls">Encerradas</string>
@@ -396,6 +396,65 @@
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
<string name="migrate_bookmarks_success">Bokmärken migrerade</string>
<string name="drafts">Utkast</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>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Utloggad · %d schemalagt inlägg raderat</item>
<item quantity="other">Utloggad · %d schemalagda inlägg raderade</item>
</plurals>
<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>
<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>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">Du har %d schemalagt inlägg som inte har publicerats än. Att logga ut raderar det permanent.</item>
<item quantity="other">Du har %d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</item>
</plurals>
<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"> · %d inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till %d relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
<string name="polls">Omröstningar</string>
<string name="open_polls">Öppna</string>
<string name="closed_polls">Stängda</string>
@@ -14,7 +14,7 @@
<string name="referenced_event_not_found">未找到相关事件</string>
<string name="could_not_decrypt_the_message">无法解密消息</string>
<string name="group_picture">群聊图片</string>
<string name="explicit_content">明确内容</string>
<string name="explicit_content">露骨内容</string>
<string name="relay_notice">中继通知</string>
<string name="duplicated_post">重复的贴文</string>
<string name="spam">垃圾信息</string>
@@ -39,7 +39,7 @@
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
<string name="report_impersonation">举报冒充</string>
<string name="report_explicit_content">举报明确内容</string>
<string name="report_explicit_content">举报露骨内容</string>
<string name="report_illegal_behaviour">举报非法行为</string>
<string name="report_malware">举报恶意软件</string>
<string name="report_mod">举报 Mod</string>
@@ -398,6 +398,56 @@
<string name="migrate_bookmarks_button">移动全部到新书签</string>
<string name="migrate_bookmarks_success">书签迁移成功</string>
<string name="drafts">草稿</string>
<string name="scheduled_posts">定时帖子</string>
<string name="schedule_post">计划</string>
<string name="schedule_post_time_label">计划的时间</string>
<string name="schedule_post_helper">帖子发布时间计划时间的 ~15 分钟内。</string>
<string name="schedule_post_pick_time">选择计划时间</string>
<string name="schedule_post_pick_label">选择计划项目…</string>
<string name="schedule_post_publishes_in">在 %1$s 内发布</string>
<string name="schedule_post_was_due">%1$s 前到期</string>
<string name="schedule_post_picker_time_title">时间</string>
<string name="schedule_post_button_add">定时帖</string>
<string name="schedule_post_button_remove">取消时间安排</string>
<string name="schedule_post_warning_title">禁用了始终显示的通知</string>
<string name="schedule_post_warning_single">下次重新打开应用前可能不会发布定时贴。要获得稳定的后台定时功能在“设置” → “用户界面偏好” 中启用“始终显示”。</string>
<string name="schedule_post_warning_multi">在重新打开应用前可能不会发布定时帖。此账户活跃时,其他账户的定时帖不会发布。要获得稳定的后台定时功能,在“设置” →\"用户界面首选项“ 中开启”始终显示“。</string>
<string name="schedule_post_preset_in_one_hour">1小时内</string>
<string name="schedule_post_preset_tomorrow_morning">明天上午9点</string>
<string name="schedule_post_preset_next_monday_morning">下周一上午9点</string>
<string name="schedule_post_always_on_prompt_title">开启”始终显示的通知“?</string>
<string name="schedule_post_always_on_prompt_message">只有在启用了”始终显示的通知“时才能可靠地发布定时帖。否则,在下次重新打开应用前定时帖可能不会发布。</string>
<string name="schedule_post_always_on_prompt_open_settings">打开设置</string>
<string name="schedule_post_always_on_prompt_continue">仍然继续</string>
<string name="scheduled_posts_at_time">%1$s ·在 %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · %2$s 前</string>
<string name="scheduled_posts_day_tomorrow">明天</string>
<string name="scheduled_posts_logout_toast_zero">已退出登录</string>
<string name="scheduled_posts_logout_toast">已退出登录 删除了 %1$d 个定时帖</string>
<string name="scheduled_posts_notification_sent_title">发布了定时帖</string>
<string name="scheduled_posts_notification_failed_title">未能发布定时帖</string>
<string name="app_notification_scheduled_posts_channel_name">定时帖</string>
<string name="app_notification_scheduled_posts_channel_description">当成功发布了定时帖或未能发布定时帖时发出通知。</string>
<string name="scheduled_posts_action_send_now">立即发送</string>
<string name="scheduled_posts_empty_title">无定时帖</string>
<string name="scheduled_posts_empty_hint">撰写笔记并轻触时钟图标稍后安排时间。</string>
<string name="scheduled_posts_error_prefix">错误:%1$s</string>
<string name="scheduled_posts_status_pending">已安排时间</string>
<string name="scheduled_posts_status_publishing">正在发送…</string>
<string name="scheduled_posts_status_failed">失败</string>
<string name="scheduled_posts_status_sent">已发送</string>
<string name="scheduled_posts_status_cancelled">已取消</string>
<string name="scheduled_posts_logout_warning">您有尚未发布的 %1$d 个定时帖。退出登录将永久删除它们。</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="other">%d 个已加入队列</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="other"> • %d 个于1小时内到期</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="other">到 %d 个中继</item>
</plurals>
<string name="scheduled_posts_event_id_copied">已复制笔记 ID</string>
<string name="polls">投票</string>
<string name="open_polls">开启</string>
<string name="closed_polls">已关闭</string>
@@ -739,7 +789,7 @@
<string name="zap_type_nonzap">非打闪</string>
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
<string name="post_anonymously">匿名</string>
<string name="post_anonymously_explainer">使用新的一次性身份发。您的帐户将不会被链接到这个回复。</string>
<string name="post_anonymously_explainer">使用新的一次性身份发。您的帐户将不会被链接到这个回复。</string>
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
<string name="file_server">文件服务器</string>
<string name="file_server_description">选择上传文件时使用的服务器</string>
+62
View File
@@ -428,6 +428,66 @@
<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>
<plurals name="scheduled_posts_logout_toast">
<item quantity="one">Logged out · %d scheduled post deleted</item>
<item quantity="other">Logged out · %d scheduled posts deleted</item>
</plurals>
<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>
<plurals name="scheduled_posts_logout_warning">
<item quantity="one">You have %d scheduled post that hasn\'t been published yet. Logging out will permanently delete it.</item>
<item quantity="other">You have %d scheduled posts that haven\'t been published yet. Logging out will permanently delete them.</item>
</plurals>
<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"> · %d due in 1h</item>
<item quantity="other"> · %d due in 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">to %d 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>
@@ -1125,6 +1185,8 @@
<string name="filter_spam_from_strangers_title">Filter spam</string>
<string name="filter_spam_from_strangers_explainer">Hides posts from strangers that were exactly the same for 5 or more times</string>
<string name="disable_client_tag_title">Don\'t add client tag to my events</string>
<string name="disable_client_tag_explainer">When enabled, Amethyst will not append a NIP-89 client tag to events you publish.</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
<string name="show_sensitive_content_title">Show sensitive content</string>
@@ -0,0 +1,180 @@
/*
* 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.nests
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [AppForegroundCounter] the pure-state core of
* [AppForegroundRecycleHook]. Drives the lifecycle transitions
* directly with a controllable clock so the threshold logic doesn't
* need a real wall-clock wait.
*/
class AppForegroundRecycleHookTest {
@Test
fun firstForegroundAfterProcessStartDoesNotPublish() {
// The very first onActivityStarted has no prior background to
// recycle from — recycling here would fire a redundant
// re-handshake on every cold start, which is wasteful.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
assertEquals("first onActivityStarted must not publish — no prior background", 0, publishCount)
assertEquals(0, counter.recyclesFired)
}
@Test
fun shortBackgroundDoesNotTriggerRecycle() {
// 1 s background (notification pull, biometric prompt) is
// typical UI noise and the QUIC socket is still healthy.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 3_000L // backgrounded for 1 s only
counter.onActivityStarted()
assertEquals(
"background < threshold must not publish — short transitions don't reclaim sockets",
0,
publishCount,
)
}
@Test
fun backgroundLongerThanThresholdTriggersRecycle() {
// 6 s background crosses the 5 s default threshold — Android
// may have reclaimed the socket FD by now, so recycle the
// QUIC session on resume.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 8_000L // backgrounded for 6 s
counter.onActivityStarted()
assertEquals(
"background ≥ threshold must publish exactly once on resume",
1,
publishCount,
)
assertEquals(1, counter.recyclesFired)
}
@Test
fun multipleActivitiesTrackTransitionCorrectly() {
// Picture-in-picture mode is implemented as a second activity
// overlaid on the main activity. While both are started the
// app is in foreground; only when both stop does the app
// truly background.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
// First activity starts (cold start), no publish.
fakeNow = 1_000L
counter.onActivityStarted()
// Second activity (e.g. PIP / dialog) starts on top. Still
// foreground; no publish — `wasBackgrounded` was false.
fakeNow = 2_000L
counter.onActivityStarted()
// First activity stops (e.g. user backs out of main).
// counter = 1, still foreground.
fakeNow = 3_000L
counter.onActivityStopped()
assertEquals(
"intermediate stop with another activity still started must not background",
0,
publishCount,
)
// Second stops → app truly backgrounds.
fakeNow = 4_000L
counter.onActivityStopped()
// 6 s later, an activity restarts → recycle.
fakeNow = 10_000L
counter.onActivityStarted()
assertEquals(1, publishCount)
}
@Test
fun consecutiveLongBackgroundsEachPublishOnce() {
// Two separate back-and-forth cycles must each fire exactly
// one publish. A regression that misses to refresh the
// last-backgrounded timestamp on second-stop would either
// double-fire on the second resume or skip it.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
// Cycle 1: cold start → 6 s background → resume (publish #1)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 8_000L
counter.onActivityStarted()
assertEquals("first resume after long background must publish", 1, publishCount)
// Cycle 2: 8 s background again → resume (publish #2)
fakeNow = 10_000L
counter.onActivityStopped()
fakeNow = 18_000L
counter.onActivityStarted()
assertEquals("second resume after long background must also publish", 2, publishCount)
}
}
@@ -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}")))
}
}