From 8e5e3c634aeef18d0c5b6e1022f8232d0b098453 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 7 May 2026 08:13:08 +0200 Subject: [PATCH] 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 --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 + .../service/scheduledposts/ScheduledPost.kt | 48 +++ .../scheduledposts/ScheduledPostStore.kt | 218 +++++++++++ .../scheduledposts/ScheduledPostWorker.kt | 168 +++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../creators/scheduling/ScheduleAtButton.kt | 46 +++ .../creators/scheduling/ScheduleAtPicker.kt | 259 +++++++++++++ .../loggedIn/BottomBarFeedPreloaders.kt | 1 + .../loggedIn/home/ShortNotePostScreen.kt | 35 ++ .../loggedIn/home/ShortNotePostViewModel.kt | 39 ++ .../scheduledposts/ScheduledPostsScreen.kt | 342 +++++++++++++++++ .../scheduledposts/ScheduledPostsViewModel.kt | 91 +++++ amethyst/src/main/res/values/strings.xml | 1 + .../scheduledposts/ScheduledPostStoreTest.kt | 352 ++++++++++++++++++ 16 files changed, 1626 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e0e0dc6d0..7cb324347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt new file mode 100644 index 000000000..91ba77a9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt @@ -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, + val extraEventsJson: List, + 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 = emptyList(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt new file mode 100644 index 000000000..888c69e44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -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 = mutableListOf() + + private val _flow = MutableStateFlow>(emptyList()) + + /** Live snapshot of all stored posts. Updated on every mutation. */ + val flow: StateFlow> = _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 = + mutex.withLock { + ensureLoaded() + posts.toList() + } + + suspend fun listFor(accountPubkey: String): List = + 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 = + 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(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" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt new file mode 100644 index 000000000..b2d6332d0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -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(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() + .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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7872e6cbe..dfa37caf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 85bf299b7..0d7838459 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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.BOOKMARKS, NavBarItem.WEB_BOOKMARKS, NavBarItem.DRAFTS, + NavBarItem.SCHEDULED_POSTS, NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index d414e8ad5..3d7127083 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -205,6 +205,8 @@ sealed class Route { @Serializable object Drafts : Route() + @Serializable object ScheduledPosts : Route() + @Serializable object AllSettings : Route() @Serializable object AccountBackup : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt new file mode 100644 index 000000000..d7d1a3633 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt new file mode 100644 index 000000000..dfa539212 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/scheduling/ScheduleAtPicker.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index b0fb4c633..a5de943b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b9dc3d888..0d700e842 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index e92b1ac83..5339a4a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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(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( + 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("") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt new file mode 100644 index 000000000..7eda5a5ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -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(null) } + var pendingCancelId by remember { mutableStateOf(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" + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt new file mode 100644 index 000000000..82f7c35c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -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> = + 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, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0502a8da3..ac0d94e02 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -428,6 +428,7 @@ Move All to New Bookmarks Bookmarks migrated successfully Drafts + Scheduled posts Polls Open Closed diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt new file mode 100644 index 000000000..f1c70c50b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -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) + } +}