feat(scheduled-posts): warn when scheduling without always-on notifications

feat(scheduled-posts): add screen + drawer entry to view, push, or delete
feat(scheduled-posts): use scheduled time as created_at + add diagnostic logs
feat(scheduled-posts): add picker UI and toolbar toggle to post composer
feat(scheduled-posts): wire schedule branch into ShortNotePostViewModel
feat(scheduled-posts): add storage + worker for delayed post publishing
This commit is contained in:
davotoula
2026-05-07 08:13:08 +02:00
parent 9bf17f44af
commit 8e5e3c634a
16 changed files with 1626 additions and 0 deletions
@@ -72,6 +72,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.resourceCacheInit
@@ -446,6 +448,11 @@ class AppModules(
// subscriptions, and NotificationRelayService.
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
val scheduledPostStore =
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
// Organizes cache clearing
val trimmingService by
lazy {
@@ -564,6 +571,12 @@ class AppModules(
// starts observing LocalCache for notification-worthy events
notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
@@ -0,0 +1,48 @@
/*
* 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,
)
data class ScheduledPostFile(
val version: Int = 1,
val posts: List<ScheduledPost> = emptyList(),
)
@@ -0,0 +1,218 @@
/*
* 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 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 updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED) }
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()
if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null) }) 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,
)
}
if (updated) persist()
updated
}
/**
* 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
_flow.value = posts.toList()
}
private fun persist() {
val snapshot = posts.toList()
_flow.value = snapshot
val parent = storageFile.parentFile
if (parent != null && !parent.exists()) parent.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try {
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot))
if (!tmp.renameTo(storageFile)) {
storageFile.delete()
if (!tmp.renameTo(storageFile)) {
Log.e(TAG, "Failed to rename $tmp to $storageFile")
tmp.delete()
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
tmp.delete()
}
}
companion object {
private const val TAG = "ScheduledPostStore"
const val FILE_NAME = "scheduled_posts.json"
}
}
@@ -0,0 +1,168 @@
/*
* 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.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
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"
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)
store.markSent(post.id)
Log.d(TAG) { "client.publish(${post.id}) done; marked SENT" }
} catch (e: Exception) {
Log.e(TAG, "Failed to publish scheduled post ${post.id}", e)
store.markFailed(post.id, e.message)
}
}
Log.d(TAG) { "doWork() EXIT success" }
Result.success()
} catch (e: Exception) {
Log.e(TAG, "doWork() unexpected failure", e)
Result.retry()
}
}
}
@@ -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,
@@ -205,6 +205,8 @@ sealed class Route {
@Serializable object Drafts : Route()
@Serializable object ScheduledPosts : Route()
@Serializable object AllSettings : Route()
@Serializable object AccountBackup : Route()
@@ -0,0 +1,46 @@
/*
* 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.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@Composable
fun ScheduleAtButton(
isActive: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = { onClick() }) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = if (isActive) "Cancel scheduling" else "Schedule post",
modifier = Modifier.size(20.dp),
tint = if (isActive) Color(0xFF1E88E5) else MaterialTheme.colorScheme.onBackground,
)
}
}
@@ -0,0 +1,259 @@
/*
* 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.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.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.graphics.Color
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.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/**
* 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 timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false,
)
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
) {
Icon(
symbol = MaterialSymbols.Timer,
contentDescription = "Scheduled time",
modifier = Modifier.size(20.dp),
tint = Color(0xFF1E88E5),
)
Text(
text = "Schedule",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = "Posts publish within ~15 minutes of the scheduled time.",
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
if (!alwaysOnEnabled) {
ReliabilityWarning(hasMultipleAccounts = hasMultipleAccounts)
}
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(MaterialSymbols.Timer, contentDescription = "Pick scheduled time")
Spacer(Modifier.width(12.dp))
if (scheduledForSec < TimeUtils.oneMinuteFromNow()) {
Text("Schedule for…", style = MaterialTheme.typography.bodyLarge)
} else {
Text(
text = "Publishes in ${timeAheadNoDot(scheduledForSec, context)}",
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
showDatePicker = false
showTimePicker = true
}) { Text("Next") }
},
) {
DatePicker(state = datePickerState)
}
}
if (showTimePicker) {
TimePickerDialog(
title = { Text("Time") },
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("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 = "Always-on notifications disabled",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text =
if (hasMultipleAccounts) {
"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."
} else {
"Scheduled posts may not publish until you next reopen the app. Enable always-on in Settings → UI Preferences for reliable background scheduling."
},
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
/**
* 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
}
@@ -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,
@@ -52,6 +52,7 @@ 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.rememberCoroutineScope
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
@@ -61,6 +62,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
@@ -97,6 +99,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 +409,26 @@ private fun NewPostScreenBody(
}
}
postViewModel.scheduledForSec?.let { current ->
val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences
.accountsFlow()
.collectAsStateWithLifecycle()
val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1
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,
@@ -656,6 +681,16 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
postViewModel.toggleExpirationDate()
}
ScheduleAtButton(postViewModel.scheduledForSec != null) {
postViewModel.scheduledForSec =
if (postViewModel.scheduledForSec != null) {
null
} else {
// Default to 1 hour from now, rounded up to the next 15-min slot
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
}
}
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.service.ai.WritingResult
import com.vitorpamplona.amethyst.service.ai.WritingTone
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -305,6 +306,10 @@ open class ShortNotePostViewModel :
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
// Scheduled posting: epoch seconds (UTC) when the post should be published.
// Null = post immediately on Send (existing behavior).
var scheduledForSec by mutableStateOf<Long?>(null)
// AI Writing Help for testing
private val useMockAi = false
@@ -829,8 +834,41 @@ open class ShortNotePostViewModel :
val version = draftTag.current
val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
cancel()
if (scheduledFor != null && !anonymous) {
// Re-stamp the template with created_at = scheduled time so the post,
// when published later, shows up at its scheduled moment in feeds
// rather than as N minutes/hours old (= compose time).
val rescheduledTemplate =
EventTemplate<Event>(
createdAt = scheduledFor,
kind = template.kind,
tags = template.tags,
content = template.content,
)
val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast)
Amethyst.instance.scheduledPostStore.add(
ScheduledPost(
id =
java.util.UUID
.randomUUID()
.toString(),
accountPubkey = event.pubKey,
signedEventJson = event.toJson(),
relayUrls = relays.map { it.url },
extraEventsJson = extras.map { it.toJson() },
publishAtSec = scheduledFor,
createdAtSec = System.currentTimeMillis() / 1000,
),
)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
return
}
if (anonymous) {
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
@@ -1197,6 +1235,7 @@ open class ShortNotePostViewModel :
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
scheduledForSec = null
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -0,0 +1,342 @@
/*
* 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.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::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 posts by viewModel.posts.collectAsStateWithLifecycle()
val context = LocalContext.current
var pendingPublishId by remember { mutableStateOf<String?>(null) }
var pendingCancelId by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
ShorterTopAppBar(
title = { Text("Scheduled posts") },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
},
)
},
) { padding ->
if (posts.isEmpty()) {
EmptyState(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(posts, key = { it.id }) { post ->
ScheduledPostRow(
post = post,
onPublishNow = { pendingPublishId = post.id },
onCancel = { pendingCancelId = post.id },
)
}
}
}
}
pendingPublishId?.let { id ->
ConfirmDialog(
title = "Send now?",
message = "This post will publish to relays immediately. The original schedule will be discarded.",
confirmLabel = "Send",
onConfirm = {
viewModel.publishNow(id, context)
pendingPublishId = null
},
onDismiss = { pendingPublishId = null },
)
}
pendingCancelId?.let { id ->
ConfirmDialog(
title = "Delete scheduled post?",
message = "The post will not be published. This cannot be undone.",
confirmLabel = "Delete",
destructive = true,
onConfirm = {
viewModel.cancel(id)
pendingCancelId = null
},
onDismiss = { pendingCancelId = null },
)
}
}
@Composable
private fun ScheduledPostRow(
post: ScheduledPost,
onPublishNow: () -> Unit,
onCancel: () -> Unit,
) {
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.outlinedCardColors(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
StatusChip(post.status)
Text(
text = formatPublishMoment(post.publishAtSec, context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
Text(
text = extractPreview(post),
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) {
Text(
text = "Error: ${post.lastError}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(),
) {
IconButton(onClick = onCancel) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = "Delete",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.error,
)
}
IconButton(onClick = onPublishNow) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = "Send now",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun StatusChip(status: ScheduledPostStatus) {
val (label, tint) =
when (status) {
ScheduledPostStatus.PENDING -> "Scheduled" to Color(0xFF1E88E5)
ScheduledPostStatus.PUBLISHING -> "Sending…" to Color(0xFFFFA000)
ScheduledPostStatus.FAILED -> "Failed" to MaterialTheme.colorScheme.error
ScheduledPostStatus.SENT -> "Sent" to Color(0xFF43A047)
ScheduledPostStatus.CANCELLED -> "Cancelled" to MaterialTheme.colorScheme.onSurfaceVariant
}
AssistChip(
onClick = {},
label = { Text(label, fontWeight = FontWeight.Medium) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = tint,
),
)
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "No scheduled posts",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Compose a note and tap the clock icon to schedule it for later.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ConfirmDialog(
title: String,
message: String,
confirmLabel: String,
destructive: Boolean = false,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
confirmLabel,
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
private fun extractPreview(post: ScheduledPost): String {
val json = post.signedEventJson
val needle = "\"content\":\""
val start = json.indexOf(needle)
if (start < 0) return ""
val from = start + needle.length
val sb = StringBuilder()
var i = from
while (i < json.length) {
val c = json[i]
if (c == '\\' && i + 1 < json.length) {
when (json[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'\\' -> sb.append('\\')
'"' -> sb.append('"')
else -> sb.append(json[i + 1])
}
i += 2
} else if (c == '"') {
break
} else {
sb.append(c)
i++
}
if (sb.length > 200) break
}
return sb.toString().trim()
}
private fun formatPublishMoment(
publishAtSec: Long,
context: android.content.Context,
): String {
val nowSec = System.currentTimeMillis() / 1000
val deltaSec = publishAtSec - nowSec
return when {
deltaSec > 0 -> {
"Publishes in ${timeAheadNoDot(publishAtSec, context)}"
}
else -> {
val ago = -deltaSec
val mins = TimeUnit.SECONDS.toMinutes(ago)
when {
mins < 1 -> "Due now"
mins < 60 -> "Was due ${mins}m ago"
else -> "Was due ${TimeUnit.SECONDS.toHours(ago)}h ago"
}
}
}
}
@@ -0,0 +1,91 @@
/*
* 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.content.Context
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 com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
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
/**
* 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,
)
val posts: StateFlow<List<ScheduledPost>> =
store.flow
.map { all ->
all
.filter { it.accountPubkey == accountPubkey && it.status in activeStatuses }
.sortedBy { it.publishAtSec }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun cancel(id: String) {
viewModelScope.launch(Dispatchers.IO) {
store.cancel(id)
}
}
fun publishNow(
id: String,
context: Context,
) {
viewModelScope.launch(Dispatchers.IO) {
if (store.publishNow(id)) {
ScheduledPostWorker.scheduleCatchUp(context)
}
}
}
companion object {
fun create(accountPubkey: String): ScheduledPostsViewModel =
ScheduledPostsViewModel(
store = Amethyst.instance.scheduledPostStore,
accountPubkey = accountPubkey,
)
}
}
+1
View File
@@ -428,6 +428,7 @@
<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="polls">Polls</string>
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
@@ -0,0 +1,352 @@
/*
* 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() = ScheduledPostStore(file)
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 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)
}
}