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)