From e7d1db446b821bd107c38465b29671a16b2bae25 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 4 Mar 2026 19:28:55 -0500 Subject: [PATCH] Add support for story archiving. --- app/src/main/AndroidManifest.xml | 10 + .../thoughtcrime/securesms/MainActivity.kt | 5 + .../securesms/database/MessageTable.kt | 77 ++- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../migration/V305_AddStoryArchivedColumn.kt | 21 + .../securesms/dependencies/AppDependencies.kt | 7 + .../ApplicationDependencyProvider.java | 6 + .../securesms/keyvalue/StoryValues.kt | 21 +- .../securesms/main/MainToolbar.kt | 14 + .../service/ExpiringArchivedStoriesManager.kt | 78 +++ .../service/ExpiringStoriesManager.kt | 13 +- .../securesms/stories/StoryViewerArgs.kt | 3 +- .../stories/archive/StoryArchiveActivity.kt | 31 + .../stories/archive/StoryArchiveDuration.kt | 20 + .../archive/StoryArchivePagedDataSource.kt | 52 ++ .../stories/archive/StoryArchiveRepository.kt | 13 + .../stories/archive/StoryArchiveScreen.kt | 552 ++++++++++++++++++ .../stories/archive/StoryArchiveState.kt | 25 + .../stories/archive/StoryArchiveViewModel.kt | 111 ++++ .../story/StoriesPrivacySettingsFragment.kt | 39 ++ .../story/StoriesPrivacySettingsState.kt | 5 +- .../story/StoriesPrivacySettingsViewModel.kt | 24 +- .../stories/viewer/StoryViewerFragment.kt | 3 +- .../stories/viewer/StoryViewerViewModel.kt | 8 +- .../viewer/page/StoryViewerPageArgs.kt | 3 +- .../viewer/page/StoryViewerPageRepository.kt | 20 +- .../viewer/page/StoryViewerPageViewModel.kt | 2 +- .../res/drawable/symbol_story_archive_24.xml | 15 + app/src/main/res/values/strings.xml | 47 ++ .../MockApplicationDependencyProvider.kt | 5 + lib/paging/build.gradle.kts | 1 + .../java/org/signal/paging/PagedData.java | 10 + .../org/signal/paging/StateFlowPagedData.kt | 11 + 33 files changed, 1237 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V305_AddStoryArchivedColumn.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/ExpiringArchivedStoriesManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveDuration.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchivePagedDataSource.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveViewModel.kt create mode 100644 app/src/main/res/drawable/symbol_story_archive_24.xml create mode 100644 lib/paging/src/main/java/org/signal/paging/StateFlowPagedData.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f2d99bc0d..5a61d6ab45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -535,6 +535,12 @@ android:theme="@style/Signal.DayNight.NoActionBar" android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 11b4f9cf94..d75fca96f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -170,6 +170,7 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment import org.thoughtcrime.securesms.service.BackupMediaRestoreService import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity import org.thoughtcrime.securesms.util.AppForegroundObserver @@ -1179,6 +1180,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner startActivity(StorySettingsActivity.getIntent(this@MainActivity)) } + override fun onStoryArchiveClick() { + startActivity(StoryArchiveActivity.createIntent(this@MainActivity)) + } + override fun onCloseSearchClick() { toolbarViewModel.setToolbarMode(MainToolbarMode.FULL) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 56bf1a5b64..e3d32bcd75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -226,6 +226,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val PINNING_MESSAGE_ID = "pinning_message_id" const val PINNED_AT = "pinned_at" const val DELETED_BY = "deleted_by" + const val STORY_ARCHIVED = "story_archived" const val QUOTE_NOT_PRESENT_ID = 0L const val QUOTE_TARGET_MISSING_ID = -1L @@ -297,7 +298,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $PINNED_UNTIL INTEGER DEFAULT 0, $PINNING_MESSAGE_ID INTEGER DEFAULT 0, $PINNED_AT INTEGER DEFAULT 0, - $DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE + $DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, + $STORY_ARCHIVED INTEGER DEFAULT 0 ) """ @@ -331,7 +333,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat "CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)", "CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)", "CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)", - "CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)" + "CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)", + "CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0" ) private val MMS_PROJECTION_BASE = arrayOf( @@ -433,7 +436,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS} """.toSingleLine() - private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" + private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL AND $STORY_ARCHIVED = 0" + private const val IS_ARCHIVED_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL AND $STORY_ARCHIVED > 0" private const val RAW_ID_WHERE = "$TABLE_NAME.$ID = ?" private val SNIPPET_QUERY = @@ -1683,9 +1687,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .run() } - fun deleteStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int { + fun deleteUnarchivedStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int { return writableDatabase.withinTransaction { db -> val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories) + val storiesBeforeTimestampWhere = "$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ?" val sharedArgs = buildArgs(timestamp, releaseChannelThreadId) @@ -1759,6 +1764,65 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun archiveStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int { + val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories) + val outgoingFilter = "($outgoingTypeClause)" + + return writableDatabase + .update(TABLE_NAME) + .values(STORY_ARCHIVED to 1) + .where("$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ? AND $outgoingFilter", timestamp, releaseChannelThreadId) + .run() + } + + fun getArchiveScreenStoriesCount(includeActive: Boolean): Int { + val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE + val where = "$storyClause AND ($outgoingTypeClause)" + return readableDatabase.select("COUNT(*)").from(TABLE_NAME).where(where).run().readToSingleInt() + } + + fun getArchiveScreenStoriesPage(includeActive: Boolean, sortNewest: Boolean, offset: Int, limit: Int): Reader { + val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE + val where = "$storyClause AND ($outgoingTypeClause)" + val order = if (sortNewest) "$TABLE_NAME.$DATE_SENT DESC" else "$TABLE_NAME.$DATE_SENT ASC" + return MmsReader(rawQueryWithAttachments(where, null, orderBy = order, limit = limit.toLong(), offset = offset.toLong())) + } + + fun getOldestArchivedStorySentTimestamp(): Long? { + return readableDatabase + .select(DATE_SENT) + .from(TABLE_NAME) + .where(IS_ARCHIVED_STORY_CLAUSE) + .limit(1) + .orderBy("$DATE_SENT ASC") + .run() + .readToSingleObject { it.getLong(0) } + } + + fun deleteArchivedStoriesOlderThan(timestamp: Long): Int { + return writableDatabase.withinTransaction { db -> + val where = "$IS_ARCHIVED_STORY_CLAUSE AND $DATE_SENT < ?" + val args = buildArgs(timestamp) + + val deletedCount = db.select(ID) + .from(TABLE_NAME) + .where(where, args) + .run() + .use { cursor -> + while (cursor.moveToNext()) { + deleteMessage(cursor.requireLong(ID)) + } + cursor.count + } + + if (deletedCount > 0) { + OptimizeMessageSearchIndexJob.enqueue() + } + + deletedCount + } + } + /** * Delete all the stories received from the recipient in 1:1 stories */ @@ -2057,7 +2121,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat /** * Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC' */ - private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor { + private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0, offset: Long = 0, orderBy: String = ""): Cursor { val database = databaseHelper.signalReadableDatabase var rawQueryString = """ SELECT @@ -2078,6 +2142,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat if (limit > 0) { rawQueryString += " LIMIT $limit" + if (offset > 0) { + rawQueryString += " OFFSET $offset" + } } return database.rawQuery(rawQueryString, arguments) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index d707337c7a..8d0d6c6a3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -158,6 +158,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLink import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiveUsernames import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNotificationSettings +import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -322,10 +323,11 @@ object SignalDatabaseMigrations { 301 to V301_RemoveCallLinkEpoch, 302 to V302_AddDeletedByColumn, 303 to V303_CaseInsensitiveUsernames, - 304 to V304_CallAndReplyNotificationSettings + 304 to V304_CallAndReplyNotificationSettings, + 305 to V305_AddStoryArchivedColumn ) - const val DATABASE_VERSION = 304 + const val DATABASE_VERSION = 305 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V305_AddStoryArchivedColumn.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V305_AddStoryArchivedColumn.kt new file mode 100644 index 0000000000..3b2da9d4c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V305_AddStoryArchivedColumn.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds a story_archived column to the message table so that outgoing stories + * can be preserved in an archive after they leave the 24-hour active feed. + */ +@Suppress("ClassName") +object V305_AddStoryArchivedColumn : SignalDatabaseMigration { + + private val TAG = Log.tag(V305_AddStoryArchivedColumn::class.java) + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE message ADD COLUMN story_archived INTEGER DEFAULT 0") + db.execSQL("CREATE INDEX IF NOT EXISTS message_story_archived_index ON message (story_archived, story_type, date_sent) WHERE story_type > 0 AND story_archived > 0") + Log.i(TAG, "Added story_archived column and index.") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 0e9bc13d54..6c96b73749 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess import org.thoughtcrime.securesms.recipients.LiveRecipientCache import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager import org.thoughtcrime.securesms.service.DeletedCallEventManager +import org.thoughtcrime.securesms.service.ExpiringArchivedStoriesManager import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.service.ExpiringStoriesManager import org.thoughtcrime.securesms.service.PendingRetryReceiptManager @@ -228,6 +229,11 @@ object AppDependencies { provider.provideExpiringStoriesManager() } + @JvmStatic + val expireArchivedStoriesManager: ExpiringArchivedStoriesManager by lazy { + provider.provideExpiringArchivedStoriesManager() + } + @JvmStatic val scheduledMessageManager: ScheduledMessageManager by lazy { provider.provideScheduledMessageManager() @@ -438,6 +444,7 @@ object AppDependencies { fun provideTrimThreadsByDateManager(): TrimThreadsByDateManager fun provideViewOnceMessageManager(): ViewOnceMessageManager fun provideExpiringStoriesManager(): ExpiringStoriesManager + fun provideExpiringArchivedStoriesManager(): ExpiringArchivedStoriesManager fun provideExpiringMessageManager(): ExpiringMessageManager fun provideDeletedCallEventManager(): DeletedCallEventManager fun provideTypingStatusRepository(): TypingStatusRepository diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 103bd0c6ff..fcc43840e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; import org.thoughtcrime.securesms.service.DeletedCallEventManager; +import org.thoughtcrime.securesms.service.ExpiringArchivedStoriesManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; @@ -257,6 +258,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { return new ExpiringStoriesManager(context); } + @Override + public @NonNull ExpiringArchivedStoriesManager provideExpiringArchivedStoriesManager() { + return new ExpiringArchivedStoriesManager(context); + } + @Override public @NonNull ExpiringMessageManager provideExpiringMessageManager() { return new ExpiringMessageManager(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt index 659a3cc40f..a0a7b2bae2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -4,6 +4,8 @@ import org.json.JSONObject import org.signal.core.util.StringSerializer import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration +import org.thoughtcrime.securesms.util.RemoteConfig class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { @@ -49,6 +51,9 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { * Whether or not the user has seen the group story education sheet */ private const val USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET = "stories.user.has.seen.group.story.education.sheet" + + private const val ARCHIVE_ENABLED = "stories.archive.enabled" + private const val ARCHIVE_DURATION = "stories.archive.duration" } public override fun onFirstEverAppLaunch() { @@ -62,7 +67,9 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { HAS_DOWNLOADED_ONBOARDING_STORY, USER_HAS_VIEWED_ONBOARDING_STORY, STORY_VIEWED_RECEIPTS, - USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET + USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET, + ARCHIVE_ENABLED, + ARCHIVE_DURATION ) var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false) @@ -81,6 +88,18 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { var userHasSeenGroupStoryEducationSheet: Boolean by booleanValue(USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET, false) + var isArchiveEnabled: Boolean + get() = RemoteConfig.internalUser && getBoolean(ARCHIVE_ENABLED, false) + set(value) { + putBoolean(ARCHIVE_ENABLED, value) + } + + var archiveDuration: StoryArchiveDuration + get() = StoryArchiveDuration.deserialize(getLong(ARCHIVE_DURATION, StoryArchiveDuration.THIRTY_DAYS.serialize())) + set(value) { + putLong(ARCHIVE_DURATION, value.serialize()) + } + fun isViewedReceiptsStateSet(): Boolean { return store.containsKey(STORY_VIEWED_RECEIPTS) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index 6742e14f99..4afd1eaf11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -83,6 +83,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImag import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.rememberRecipientField +import org.thoughtcrime.securesms.util.RemoteConfig interface MainToolbarCallback { fun onNewGroupClick() @@ -99,6 +100,7 @@ interface MainToolbarCallback { fun onFilterMissedCallsClick() fun onClearCallFilterClick() fun onStoryPrivacyClick() + fun onStoryArchiveClick() fun onCloseSearchClick() fun onCloseArchiveClick() fun onCloseActionModeClick() @@ -120,6 +122,7 @@ interface MainToolbarCallback { override fun onFilterMissedCallsClick() = Unit override fun onClearCallFilterClick() = Unit override fun onStoryPrivacyClick() = Unit + override fun onStoryArchiveClick() = Unit override fun onCloseSearchClick() = Unit override fun onCloseArchiveClick() = Unit override fun onCloseActionModeClick() = Unit @@ -405,6 +408,17 @@ private fun PrimaryToolbar( NotificationProfileAction(state, callback) ProxyAction(state, callback) + if (state.destination == MainNavigationListLocation.STORIES && RemoteConfig.internalUser) { + IconButtons.IconButton( + onClick = callback::onStoryArchiveClick + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_story_archive_24), + contentDescription = stringResource(R.string.StoryArchive__story_archive) + ) + } + } + IconButtons.IconButton( onClick = callback::onSearchClick, modifier = Modifier.onPlaced { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringArchivedStoriesManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringArchivedStoriesManager.kt new file mode 100644 index 0000000000..26e5459269 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringArchivedStoriesManager.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.service + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration + +/** + * Manages deleting archived stories after the user-configured retention duration. + */ +class ExpiringArchivedStoriesManager( + application: Application +) : TimedEventManager(application, "ExpiringArchivedStoriesManager") { + + companion object { + private val TAG = Log.tag(ExpiringArchivedStoriesManager::class.java) + } + + init { + scheduleIfNecessary() + } + + @WorkerThread + override fun getNextClosestEvent(): Event? { + if (!SignalStore.story.isArchiveEnabled) return null + + val duration = SignalStore.story.archiveDuration + if (duration == StoryArchiveDuration.FOREVER) return null + + val oldestTimestamp = SignalDatabase.messages.getOldestArchivedStorySentTimestamp() ?: return null + + val expirationTime = oldestTimestamp + duration.durationMs + val delay = (expirationTime - System.currentTimeMillis()).coerceAtLeast(0) + Log.i(TAG, "The oldest archived story needs to be deleted in $delay ms.") + + return Event(delay) + } + + @WorkerThread + override fun executeEvent(event: Event) { + if (!SignalStore.story.isArchiveEnabled) return + + val duration = SignalStore.story.archiveDuration + if (duration == StoryArchiveDuration.FOREVER) return + + val threshold = System.currentTimeMillis() - duration.durationMs + val deletes = SignalDatabase.messages.deleteArchivedStoriesOlderThan(threshold) + Log.i(TAG, "Deleted $deletes archived stories before $threshold") + } + + @WorkerThread + override fun getDelayForEvent(event: Event): Long = event.delay + + @WorkerThread + override fun scheduleAlarm(application: Application, event: Event, delay: Long) { + setAlarm(application, delay, ExpireArchivedStoriesAlarm::class.java) + } + + data class Event(val delay: Long) + + class ExpireArchivedStoriesAlarm : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ExpireArchivedStoriesAlarm::class.java) + } + + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "onReceive()") + AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt index 00ae8532b3..583614485e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt @@ -44,8 +44,19 @@ class ExpiringStoriesManager( @WorkerThread override fun executeEvent(event: Event) { val threshold = System.currentTimeMillis() - STORY_LIFESPAN - val deletes = mmsDatabase.deleteStoriesOlderThan(threshold, SignalStore.story.userHasViewedOnboardingStory) + val hasSeenOnboarding = SignalStore.story.userHasViewedOnboardingStory + + if (SignalStore.story.isArchiveEnabled) { + val archived = mmsDatabase.archiveStoriesOlderThan(threshold, hasSeenOnboarding) + Log.i(TAG, "Archived $archived outgoing stories before $threshold") + } + + val deletes = mmsDatabase.deleteUnarchivedStoriesOlderThan(threshold, hasSeenOnboarding) Log.i(TAG, "Deleted $deletes stories before $threshold") + + if (SignalStore.story.isArchiveEnabled) { + AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary() + } } @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt index e480a31b4c..ccef2f8041 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt @@ -23,7 +23,8 @@ data class StoryViewerArgs( val isFromInfoContextMenuAction: Boolean = false, val isFromQuote: Boolean = false, val isFromMyStories: Boolean = false, - val isJumpToUnviewed: Boolean = false + val isJumpToUnviewed: Boolean = false, + val isFromArchive: Boolean = false ) : Parcelable { class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveActivity.kt new file mode 100644 index 0000000000..d9f71d1981 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveActivity.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.stories.archive + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.PassphraseRequiredActivity + +class StoryArchiveActivity : PassphraseRequiredActivity() { + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, StoryArchiveActivity::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + enableEdgeToEdge() + super.onCreate(savedInstanceState, ready) + + setContent { + SignalTheme { + StoryArchiveScreen( + onNavigationClick = { onBackPressedDispatcher.onBackPressed() } + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveDuration.kt new file mode 100644 index 0000000000..1925593e64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveDuration.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.stories.archive + +import androidx.annotation.StringRes +import org.thoughtcrime.securesms.R +import kotlin.time.Duration.Companion.days + +enum class StoryArchiveDuration(val durationMs: Long, @StringRes val labelRes: Int) { + FOREVER(-1L, R.string.StoryArchive__forever), + ONE_YEAR(365.days.inWholeMilliseconds, R.string.StoryArchive__1_year), + SIX_MONTHS(182.days.inWholeMilliseconds, R.string.StoryArchive__6_months), + THIRTY_DAYS(30.days.inWholeMilliseconds, R.string.StoryArchive__30_days); + + fun serialize(): Long = durationMs + + companion object { + fun deserialize(value: Long): StoryArchiveDuration { + return entries.firstOrNull { it.durationMs == value } ?: throw IllegalArgumentException("Unknown value: $value") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchivePagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchivePagedDataSource.kt new file mode 100644 index 0000000000..84b8fc5caf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchivePagedDataSource.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.stories.archive + +import org.signal.paging.PagedDataSource +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.keyvalue.SignalStore + +class StoryArchivePagedDataSource( + private val sortNewest: Boolean +) : PagedDataSource { + + private val includeActive = SignalStore.story.isArchiveEnabled + + override fun size(): Int { + return SignalDatabase.messages.getArchiveScreenStoriesCount(includeActive) + } + + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { + return SignalDatabase.messages.getArchiveScreenStoriesPage(includeActive, sortNewest, start, length).use { reader -> + reader.mapNotNull { record -> + if (cancellationSignal.isCanceled) return@use emptyList() + val mmsRecord = record as? MmsMessageRecord + ArchivedStoryItem( + messageId = record.id, + dateSent = record.dateSent, + thumbnailUri = mmsRecord?.slideDeck?.thumbnailSlide?.uri, + blurHash = mmsRecord?.slideDeck?.thumbnailSlide?.placeholderBlur, + storyType = mmsRecord?.storyType ?: StoryType.NONE, + body = record.body + ) + } + } + } + + override fun load(key: Long): ArchivedStoryItem? { + val record = SignalDatabase.messages.getMessageRecordOrNull(key) ?: return null + val mmsRecord = record as? MmsMessageRecord + return ArchivedStoryItem( + messageId = record.id, + dateSent = record.dateSent, + thumbnailUri = mmsRecord?.slideDeck?.thumbnailSlide?.uri, + blurHash = mmsRecord?.slideDeck?.thumbnailSlide?.placeholderBlur, + storyType = mmsRecord?.storyType ?: StoryType.NONE, + body = record.body + ) + } + + override fun getKey(data: ArchivedStoryItem): Long { + return data.messageId + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveRepository.kt new file mode 100644 index 0000000000..c07e9114ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveRepository.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.stories.archive + +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob + +class StoryArchiveRepository { + + fun deleteStories(messageIds: Set) { + val records = messageIds.mapNotNull { SignalDatabase.messages.getMessageRecordOrNull(it) }.toSet() + messageIds.forEach { SignalDatabase.messages.deleteMessage(it) } + MultiDeviceDeleteSyncJob.enqueueMessageDeletes(records) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveScreen.kt new file mode 100644 index 0000000000..399fc0d130 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveScreen.kt @@ -0,0 +1,552 @@ +package org.thoughtcrime.securesms.stories.archive + +import android.view.View +import android.widget.ImageView +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.bumptech.glide.Glide +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.glide.decryptableuri.DecryptableUri +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.StoryTextPostModel +import org.thoughtcrime.securesms.stories.StoryViewerArgs +import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity +import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import org.signal.core.ui.R as CoreUiR + +@Composable +fun StoryArchiveScreen( + onNavigationClick: () -> Unit, + viewModel: StoryArchiveViewModel = viewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose { } + } + + BackHandler(enabled = state.multiSelectEnabled) { + viewModel.clearSelection() + } + + val sortMenuController = remember { DropdownMenus.MenuController() } + + Scaffolds.Settings( + title = if (state.multiSelectEnabled) { + pluralStringResource(R.plurals.StoryArchive__d_selected, state.selectedIds.size, state.selectedIds.size) + } else { + stringResource(R.string.StoryArchive__story_archive) + " (Internal Only)" + }, + onNavigationClick = if (state.multiSelectEnabled) { + { viewModel.clearSelection() } + } else { + onNavigationClick + }, + navigationIcon = if (state.multiSelectEnabled) SignalIcons.X.imageVector else null, + actions = { + if (!state.multiSelectEnabled) { + Box { + IconButton(onClick = { sortMenuController.show() }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical), + contentDescription = stringResource(R.string.StoryArchive__sort_by) + ) + } + + DropdownMenus.Menu( + controller = sortMenuController, + offsetX = 0.dp + ) { + DropdownMenus.Item( + text = { Text(text = stringResource(R.string.StoryArchive__newest)) }, + onClick = { + viewModel.setSortOrder(SortOrder.NEWEST) + sortMenuController.hide() + } + ) + DropdownMenus.Item( + text = { Text(text = stringResource(R.string.StoryArchive__oldest)) }, + onClick = { + viewModel.setSortOrder(SortOrder.OLDEST) + sortMenuController.hide() + } + ) + } + } + } + } + ) { paddingValues -> + StoryArchiveContent( + state = state, + pagingController = viewModel.pagingController, + onToggleSelection = { messageId -> viewModel.toggleSelection(messageId) }, + onDeleteClick = { viewModel.requestDeleteSelected() }, + modifier = Modifier.padding(paddingValues) + ) + + if (state.showDeleteConfirmation) { + Dialogs.SimpleAlertDialog( + title = Dialogs.NoTitle, + body = pluralStringResource(R.plurals.StoryArchive__delete_n_stories, state.selectedIds.size, state.selectedIds.size), + confirm = stringResource(R.string.StoryArchive__delete), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { viewModel.confirmDeleteSelected() }, + onDeny = { viewModel.cancelDelete() }, + onDismiss = { viewModel.cancelDelete() } + ) + } + } +} + +@Composable +private fun StoryArchiveContent( + state: StoryArchiveState, + pagingController: org.signal.paging.PagingController, + onToggleSelection: (Long) -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier +) { + when { + state.isLoading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + state.stories.isEmpty() -> { + val context = LocalContext.current + + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.StoryArchive__no_archived_stories), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.StoryArchive__no_archived_stories_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp) + ) + Buttons.Small( + onClick = { context.startActivity(StorySettingsActivity.getIntent(context)) }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + shape = RoundedCornerShape(100), + elevation = null, + modifier = Modifier.padding(top = 16.dp) + ) { + Text(text = stringResource(R.string.StoryArchive__go_to_settings)) + } + } + } + + else -> { + StoryArchiveGrid( + stories = state.stories, + multiSelectEnabled = state.multiSelectEnabled, + selectedIds = state.selectedIds, + pagingController = pagingController, + onToggleSelection = onToggleSelection, + onDeleteClick = onDeleteClick, + modifier = modifier + ) + } + } +} + +@Composable +private fun StoryArchiveGrid( + stories: List, + multiSelectEnabled: Boolean, + selectedIds: Set, + pagingController: org.signal.paging.PagingController, + onToggleSelection: (Long) -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = LocalActivity.current + val density = LocalDensity.current + var bottomActionBarPadding by remember { mutableStateOf(0.dp) } + + Box(modifier = modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(bottom = bottomActionBarPadding), + modifier = Modifier.padding(horizontal = 2.dp) + ) { + items( + count = stories.size, + key = { index -> stories[index]?.messageId ?: -index.toLong() } + ) { index -> + LaunchedEffect(index) { + pagingController.onDataNeededAroundIndex(index) + } + + val item = stories[index] + if (item == null) { + StoryArchivePlaceholder() + } else { + val isFirstOfDate = computeDateIndicator(stories, index) + + StoryArchiveTile( + item = item, + showDateIndicator = isFirstOfDate, + isSelected = selectedIds.contains(item.messageId), + onClick = { view -> + if (multiSelectEnabled) { + onToggleSelection(item.messageId) + } else { + val textModel = if (item.storyType.isTextStory && item.body != null) { + StoryTextPostModel.parseFrom( + body = item.body, + storySentAtMillis = item.dateSent, + storyAuthor = Recipient.self().id, + bodyRanges = null + ) + } else { + null + } + + val args = StoryViewerArgs( + recipientId = Recipient.self().id, + isInHiddenStoryMode = false, + storyId = item.messageId, + isFromArchive = true, + storyThumbTextModel = textModel, + storyThumbUri = if (textModel == null) item.thumbnailUri else null, + storyThumbBlur = if (textModel == null) item.blurHash else null + ) + val intent = StoryViewerActivity.createIntent(context, args) + if (view != null && activity != null) { + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, view, ViewCompat.getTransitionName(view) ?: "") + context.startActivity(intent, options.toBundle()) + } else { + context.startActivity(intent) + } + } + }, + onLongClick = { onToggleSelection(item.messageId) } + ) + } + } + } + + SignalBottomActionBar( + visible = multiSelectEnabled, + items = listOf( + ActionItem( + iconRes = CoreUiR.drawable.symbol_trash_24, + title = stringResource(R.string.StoryArchive__delete), + action = { onDeleteClick() } + ) + ), + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { layoutCoordinates -> + bottomActionBarPadding = with(density) { layoutCoordinates.size.height.toDp() } + } + ) + } +} + +@Composable +private fun StoryArchivePlaceholder() { + Box( + modifier = Modifier + .aspectRatio(9f / 16f) + .padding(1.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StoryArchiveTile( + item: ArchivedStoryItem, + showDateIndicator: Boolean, + isSelected: Boolean, + onClick: (View?) -> Unit, + onLongClick: () -> Unit +) { + var imageViewRef by remember { mutableStateOf(null) } + val haptics = LocalHapticFeedback.current + + Box( + modifier = Modifier + .aspectRatio(9f / 16f) + .padding(1.dp) + .combinedClickable( + onClick = { onClick(imageViewRef) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + }, + onLongClickLabel = stringResource(R.string.StoryArchive__select_story) + ) + ) { + if (item.storyType.isTextStory && item.body != null) { + val textModel = StoryTextPostModel.parseFrom( + body = item.body, + storySentAtMillis = item.dateSent, + storyAuthor = Recipient.self().id, + bodyRanges = null + ) + AndroidView( + factory = { context -> + ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + ViewCompat.setTransitionName(this, "story") + }.also { imageViewRef = it } + }, + update = { iv -> + Glide.with(iv).load(textModel).centerCrop().into(iv) + }, + onReset = { Glide.with(it).clear(it) }, + modifier = Modifier.fillMaxSize() + ) + } else if (item.thumbnailUri != null) { + AndroidView( + factory = { context -> + ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + ViewCompat.setTransitionName(this, "story") + }.also { imageViewRef = it } + }, + update = { iv -> + Glide.with(iv).load(DecryptableUri(item.thumbnailUri)).centerCrop().into(iv) + }, + onReset = { Glide.with(it).clear(it) }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } + + if (showDateIndicator) { + val (day, month) = remember(item.dateSent) { formatDateIndicator(item.dateSent) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .defaultMinSize(40.dp) + .background( + color = Color.White.copy(alpha = 0.8f), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = day, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = Color.Black, + textAlign = TextAlign.Center + ) + Text( + text = month, + fontSize = 10.sp, + style = MaterialTheme.typography.bodySmall, + color = Color.Black, + textAlign = TextAlign.Center + ) + } + } + + if (isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = SignalIcons.CheckCircle.imageVector, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + } +} + +private fun computeDateIndicator(stories: List, index: Int): Boolean { + val item = stories[index] ?: return false + if (index == 0) return true + val prev = stories[index - 1] ?: return true + val cal = Calendar.getInstance() + cal.timeInMillis = item.dateSent + val year = cal.get(Calendar.YEAR) + val day = cal.get(Calendar.DAY_OF_YEAR) + cal.timeInMillis = prev.dateSent + return year != cal.get(Calendar.YEAR) || day != cal.get(Calendar.DAY_OF_YEAR) +} + +private fun formatDateIndicator(timestamp: Long): Pair { + val date = Date(timestamp) + val day = SimpleDateFormat("d", Locale.getDefault()).format(date) + val month = SimpleDateFormat("MMM", Locale.getDefault()).format(date) + return day to month +} + +@DayNightPreviews +@Composable +private fun StoryArchiveLoadingPreview() { + Previews.Preview { + StoryArchiveContent( + state = StoryArchiveState(isLoading = true), + pagingController = NoOpPagingController, + onToggleSelection = {}, + onDeleteClick = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun StoryArchiveEmptyPreview() { + Previews.Preview { + StoryArchiveContent( + state = StoryArchiveState(isLoading = false, stories = emptyList()), + pagingController = NoOpPagingController, + onToggleSelection = {}, + onDeleteClick = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun StoryArchiveTilePreview() { + Previews.Preview { + Box(modifier = Modifier.size(120.dp, 213.dp)) { + StoryArchiveTile( + item = ArchivedStoryItem( + messageId = 1L, + dateSent = System.currentTimeMillis(), + thumbnailUri = null, + blurHash = null, + storyType = StoryType.NONE, + body = null + ), + showDateIndicator = true, + isSelected = false, + onClick = { _ -> }, + onLongClick = {} + ) + } + } +} + +@DayNightPreviews +@Composable +private fun StoryArchiveSelectedTilePreview() { + Previews.Preview { + Box(modifier = Modifier.size(120.dp, 213.dp)) { + StoryArchiveTile( + item = ArchivedStoryItem( + messageId = 1L, + dateSent = System.currentTimeMillis(), + thumbnailUri = null, + blurHash = null, + storyType = StoryType.NONE, + body = null + ), + showDateIndicator = false, + isSelected = true, + onClick = { _ -> }, + onLongClick = {} + ) + } + } +} + +private object NoOpPagingController : org.signal.paging.PagingController { + override fun onDataNeededAroundIndex(aroundIndex: Int) = Unit + override fun onDataInvalidated() = Unit + override fun onDataItemChanged(key: Long) = Unit + override fun onDataItemInserted(key: Long, position: Int) = Unit +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveState.kt new file mode 100644 index 0000000000..5d0dab47ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveState.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.stories.archive + +import android.net.Uri +import org.signal.blurhash.BlurHash +import org.thoughtcrime.securesms.database.model.StoryType + +data class StoryArchiveState( + val stories: List = emptyList(), + val sortOrder: SortOrder = SortOrder.NEWEST, + val isLoading: Boolean = true, + val multiSelectEnabled: Boolean = false, + val selectedIds: Set = emptySet(), + val showDeleteConfirmation: Boolean = false +) + +enum class SortOrder { NEWEST, OLDEST } + +data class ArchivedStoryItem( + val messageId: Long, + val dateSent: Long, + val thumbnailUri: Uri?, + val blurHash: BlurHash?, + val storyType: StoryType, + val body: String? +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveViewModel.kt new file mode 100644 index 0000000000..de0e136886 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/archive/StoryArchiveViewModel.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.stories.archive + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.signal.paging.PagedData +import org.signal.paging.PagingConfig +import org.signal.paging.PagingController +import org.signal.paging.ProxyPagingController +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.dependencies.AppDependencies + +class StoryArchiveViewModel : ViewModel() { + + private val repository = StoryArchiveRepository() + + private val _state = MutableStateFlow(StoryArchiveState()) + val state: StateFlow = _state + + private var pagedDataJob: Job? = null + private val proxyPagingController = ProxyPagingController() + val pagingController: PagingController get() = proxyPagingController + + private val databaseObserver = DatabaseObserver.Observer { + proxyPagingController.onDataInvalidated() + } + + init { + AppDependencies.databaseObserver.registerConversationListObserver(databaseObserver) + loadPagedData() + } + + fun refresh() { + loadPagedData() + } + + fun setSortOrder(sortOrder: SortOrder) { + _state.value = _state.value.copy(sortOrder = sortOrder, isLoading = true) + loadPagedData() + } + + fun toggleSelection(messageId: Long) { + val current = _state.value + val newSelection = if (current.selectedIds.contains(messageId)) { + current.selectedIds - messageId + } else { + current.selectedIds + messageId + } + _state.value = current.copy( + selectedIds = newSelection, + multiSelectEnabled = newSelection.isNotEmpty() + ) + } + + fun clearSelection() { + _state.value = _state.value.copy( + multiSelectEnabled = false, + selectedIds = emptySet(), + showDeleteConfirmation = false + ) + } + + fun requestDeleteSelected() { + _state.value = _state.value.copy(showDeleteConfirmation = true) + } + + fun cancelDelete() { + _state.value = _state.value.copy(showDeleteConfirmation = false) + } + + fun confirmDeleteSelected() { + val idsToDelete = _state.value.selectedIds + _state.value = _state.value.copy( + multiSelectEnabled = false, + selectedIds = emptySet(), + showDeleteConfirmation = false + ) + viewModelScope.launch(Dispatchers.IO) { + repository.deleteStories(idsToDelete) + } + } + + private fun loadPagedData() { + val sortNewest = _state.value.sortOrder == SortOrder.NEWEST + val dataSource = StoryArchivePagedDataSource(sortNewest) + val config = PagingConfig.Builder() + .setPageSize(30) + .setBufferPages(1) + .setStartIndex(0) + .build() + + val newPagedData = PagedData.createForStateFlow(dataSource, config) + proxyPagingController.set(newPagedData.controller) + + pagedDataJob?.cancel() + pagedDataJob = viewModelScope.launch { + newPagedData.data.collectLatest { stories -> + _state.value = _state.value.copy(stories = stories, isLoading = false) + } + } + } + + override fun onCleared() { + AppDependencies.databaseObserver.unregisterObserver(databaseObserver) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt index e706a09429..c4dc81a191 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt @@ -4,6 +4,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ConcatAdapter +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.signal.core.ui.BottomSheetUtil import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.dp @@ -22,9 +23,11 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet +import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -167,6 +170,42 @@ class StoriesPrivacySettingsFragment : } ) + if (RemoteConfig.internalUser) { + dividerPref() + + sectionHeaderPref(R.string.StoryArchive__archive) + + switchPref( + title = DSLSettingsText.from(R.string.StoryArchive__keep_stories_in_archive), + summary = DSLSettingsText.from(R.string.StoryArchive__save_stories_after_they_expire), + isChecked = state.isArchiveEnabled, + onClick = { + viewModel.toggleArchiveEnabled() + } + ) + + if (state.isArchiveEnabled) { + clickPref( + title = DSLSettingsText.from(R.string.StoryArchive__keep_stories_for), + summary = DSLSettingsText.from(state.archiveDuration.labelRes), + onClick = { + val durations = StoryArchiveDuration.entries.toTypedArray() + val labels = durations.map { getString(it.labelRes) }.toTypedArray() + val checkedIndex = durations.indexOf(state.archiveDuration) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.StoryArchive__keep_stories_for) + .setSingleChoiceItems(labels, checkedIndex) { dialog, which -> + viewModel.setArchiveDuration(durations[which]) + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + ) + } + } + dividerPref() clickPref( diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsState.kt index 9d3b58132c..a31a564346 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsState.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.stories.settings.story import org.thoughtcrime.securesms.contacts.paged.ContactSearchData +import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration data class StoriesPrivacySettingsState( val areStoriesEnabled: Boolean, val areViewReceiptsEnabled: Boolean, val isUpdatingEnabledState: Boolean = false, val storyContactItems: List = emptyList(), - val userHasStories: Boolean = false + val userHasStories: Boolean = false, + val isArchiveEnabled: Boolean = false, + val archiveDuration: StoryArchiveDuration = StoryArchiveDuration.THIRTY_DAYS ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsViewModel.kt index a6bba1ca94..7cdd5ac3b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsViewModel.kt @@ -14,9 +14,11 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSource import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration import org.thoughtcrime.securesms.util.rx.RxStore class StoriesPrivacySettingsViewModel( @@ -28,7 +30,9 @@ class StoriesPrivacySettingsViewModel( private val store = RxStore( StoriesPrivacySettingsState( areStoriesEnabled = Stories.isFeatureEnabled(), - areViewReceiptsEnabled = SignalStore.story.viewedReceiptsEnabled + areViewReceiptsEnabled = SignalStore.story.viewedReceiptsEnabled, + isArchiveEnabled = SignalStore.story.isArchiveEnabled, + archiveDuration = SignalStore.story.archiveDuration ) ) @@ -89,6 +93,24 @@ class StoriesPrivacySettingsViewModel( repository.onSettingsChanged() } + fun toggleArchiveEnabled() { + SignalStore.story.isArchiveEnabled = !SignalStore.story.isArchiveEnabled + store.update { + it.copy(isArchiveEnabled = SignalStore.story.isArchiveEnabled) + } + if (SignalStore.story.isArchiveEnabled) { + AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary() + } + } + + fun setArchiveDuration(duration: StoryArchiveDuration) { + SignalStore.story.archiveDuration = duration + store.update { + it.copy(archiveDuration = duration) + } + AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary() + } + fun displayGroupsAsStories(recipientIds: List) { disposables += repository.markGroupsAsStories(recipientIds).subscribe { pagingController.onDataInvalidated() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt index 126949a40c..8abfd78040 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -74,7 +74,8 @@ class StoryViewerFragment : storyViewerArgs.isFromNotification -> StoryViewerPageArgs.Source.NOTIFICATION else -> StoryViewerPageArgs.Source.UNKNOWN }, - groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition + groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition, + isFromArchive = storyViewerArgs.isFromArchive ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt index 196aa77578..7502087169 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt @@ -36,7 +36,9 @@ class StoryViewerViewModel( storyViewerArgs.storyThumbUri != null -> StoryViewerState.CrossfadeSource.ImageUri(storyViewerArgs.storyThumbUri, storyViewerArgs.storyThumbBlur) else -> StoryViewerState.CrossfadeSource.None }, - skipCrossfade = storyViewerArgs.isFromNotification || storyViewerArgs.isFromQuote + skipCrossfade = storyViewerArgs.isFromNotification || + storyViewerArgs.isFromQuote || + (storyViewerArgs.isFromArchive && storyViewerArgs.storyThumbTextModel == null && storyViewerArgs.storyThumbUri == null) ) ) @@ -128,7 +130,9 @@ class StoryViewerViewModel( } private fun getStories(): Single> { - return if (storyViewerArgs.recipientIds.isNotEmpty()) { + return if (storyViewerArgs.isFromArchive) { + Single.just(listOf(storyViewerArgs.recipientId)) + } else if (storyViewerArgs.recipientIds.isNotEmpty()) { Single.just(storyViewerArgs.recipientIds - hidden) } else { repository.getStories( diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageArgs.kt index 5a78a7c38d..53150080cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageArgs.kt @@ -11,7 +11,8 @@ data class StoryViewerPageArgs( val isOutgoingOnly: Boolean, val isJumpForwardToUnviewed: Boolean, val source: Source, - val groupReplyStartPosition: Int + val groupReplyStartPosition: Int, + val isFromArchive: Boolean = false ) : Parcelable { enum class Source { UNKNOWN, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index 13d73d9fd5..ae18f1eba5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -151,10 +151,22 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat return Stories.enqueueAttachmentsFromStoryForDownload(post.conversationMessage.messageRecord as MmsMessageRecord, true) } - fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean): Observable> { - return getStoryRecords(recipientId, isOutgoingOnly) - .switchMap { records -> - val posts: List> = records.map { + fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean, isFromArchive: Boolean = false, initialStoryId: Long = -1L): Observable> { + val records = if (isFromArchive && initialStoryId > 0) { + Observable.fromCallable { + try { + listOf(SignalDatabase.messages.getMessageRecord(initialStoryId)) + } catch (e: NoSuchMessageException) { + emptyList() + } + } + } else { + getStoryRecords(recipientId, isOutgoingOnly) + } + + return records + .switchMap { recordList -> + val posts: List> = recordList.map { getStoryPostFromRecord(recipientId, it).distinctUntilChanged() } if (posts.isEmpty()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 80defd90bf..fe52784d07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -71,7 +71,7 @@ class StoryViewerPageViewModel( fun refresh() { disposables.clear() - disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly) + disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly, args.isFromArchive, args.initialStoryId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { posts -> diff --git a/app/src/main/res/drawable/symbol_story_archive_24.xml b/app/src/main/res/drawable/symbol_story_archive_24.xml new file mode 100644 index 0000000000..cae3f8b770 --- /dev/null +++ b/app/src/main/res/drawable/symbol_story_archive_24.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c00d1a646..7bb0884fea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6798,6 +6798,53 @@ This story will be deleted for you and everyone who received it. Unable to save + + + + Story archive + + Archive + + Keep stories in archive + + Save your sent stories after they leave the active feed. + + Keep stories for + + Forever + + 1 year + + 6 months + + 30 days + + No archived stories + + Turn on \"Save Stories to Archive\" in story settings to auto-archive your stories. + + Go to settings + + Sort by + + Newest + + Oldest + + Delete + + Select story + + + Delete %d story? This cannot be undone. + Delete %d stories? This cannot be undone. + + + + %d selected + %d selected + + %1$d view diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index c313f2468a..de833e99df 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess import org.thoughtcrime.securesms.recipients.LiveRecipientCache import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager import org.thoughtcrime.securesms.service.DeletedCallEventManager +import org.thoughtcrime.securesms.service.ExpiringArchivedStoriesManager import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.service.ExpiringStoriesManager import org.thoughtcrime.securesms.service.PendingRetryReceiptManager @@ -135,6 +136,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } + override fun provideExpiringArchivedStoriesManager(): ExpiringArchivedStoriesManager { + return mockk(relaxed = true) + } + override fun provideExpiringMessageManager(): ExpiringMessageManager { return mockk(relaxed = true) } diff --git a/lib/paging/build.gradle.kts b/lib/paging/build.gradle.kts index c0fe195b10..196436b6fe 100644 --- a/lib/paging/build.gradle.kts +++ b/lib/paging/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { implementation(project(":core:util")) + implementation(libs.kotlinx.coroutines.core) } diff --git a/lib/paging/src/main/java/org/signal/paging/PagedData.java b/lib/paging/src/main/java/org/signal/paging/PagedData.java index 89fa2e02d5..268cb5f616 100644 --- a/lib/paging/src/main/java/org/signal/paging/PagedData.java +++ b/lib/paging/src/main/java/org/signal/paging/PagedData.java @@ -8,6 +8,8 @@ import java.util.List; import io.reactivex.rxjava3.subjects.BehaviorSubject; import io.reactivex.rxjava3.subjects.Subject; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.StateFlow; /** * The primary entry point for creating paged data. @@ -36,6 +38,14 @@ public class PagedData { return new ObservablePagedData<>(subject, controller); } + @AnyThread + public static StateFlowPagedData createForStateFlow(@NonNull PagedDataSource dataSource, @NonNull PagingConfig config) { + MutableStateFlow> stateFlow = kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow(java.util.Collections.emptyList()); + PagingController controller = new BufferedPagingController<>(dataSource, config, stateFlow::setValue); + + return new StateFlowPagedData<>(stateFlow, controller); + } + public PagingController getController() { return controller; } diff --git a/lib/paging/src/main/java/org/signal/paging/StateFlowPagedData.kt b/lib/paging/src/main/java/org/signal/paging/StateFlowPagedData.kt new file mode 100644 index 0000000000..17db8eb9ef --- /dev/null +++ b/lib/paging/src/main/java/org/signal/paging/StateFlowPagedData.kt @@ -0,0 +1,11 @@ +package org.signal.paging + +import kotlinx.coroutines.flow.StateFlow + +/** + * An implementation of [PagedData] that will provide data as a [StateFlow]. + */ +class StateFlowPagedData( + val data: StateFlow>, + controller: PagingController +) : PagedData(controller)