Add support for story archiving.

This commit is contained in:
Greyson Parrelli
2026-03-04 19:28:55 -05:00
committed by jeffrey-signal
parent ff50755ba2
commit e7d1db446b
33 changed files with 1237 additions and 21 deletions

View File

@@ -535,6 +535,12 @@
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity
android:name=".stories.archive.StoryArchiveActivity"
android:exported="false"
android:parentActivityName=".MainActivity"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity
android:name=".stories.viewer.StoryViewerActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -1430,6 +1436,10 @@
android:name=".service.ExpiringStoriesManager$ExpireStoriesAlarm"
android:exported="false" />
<receiver
android:name=".service.ExpiringArchivedStoriesManager$ExpireArchivedStoriesAlarm"
android:exported="false" />
<receiver
android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm"
android:exported="false" />

View File

@@ -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)
}

View File

@@ -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<String>?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor {
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, 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)

View File

@@ -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) {

View File

@@ -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.")
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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<ExpiringArchivedStoriesManager.Event>(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()
}
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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() }
)
}
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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<Long, ArchivedStoryItem> {
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<ArchivedStoryItem> {
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
}
}

View File

@@ -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<Long>) {
val records = messageIds.mapNotNull { SignalDatabase.messages.getMessageRecordOrNull(it) }.toSet()
messageIds.forEach { SignalDatabase.messages.deleteMessage(it) }
MultiDeviceDeleteSyncJob.enqueueMessageDeletes(records)
}
}

View File

@@ -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<Long>,
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<ArchivedStoryItem?>,
multiSelectEnabled: Boolean,
selectedIds: Set<Long>,
pagingController: org.signal.paging.PagingController<Long>,
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<View?>(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<ArchivedStoryItem?>, 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<String, String> {
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<Long> {
override fun onDataNeededAroundIndex(aroundIndex: Int) = Unit
override fun onDataInvalidated() = Unit
override fun onDataItemChanged(key: Long) = Unit
override fun onDataItemInserted(key: Long, position: Int) = Unit
}

View File

@@ -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<ArchivedStoryItem?> = emptyList(),
val sortOrder: SortOrder = SortOrder.NEWEST,
val isLoading: Boolean = true,
val multiSelectEnabled: Boolean = false,
val selectedIds: Set<Long> = 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?
)

View File

@@ -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<StoryArchiveState> = _state
private var pagedDataJob: Job? = null
private val proxyPagingController = ProxyPagingController<Long>()
val pagingController: PagingController<Long> 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)
}
}

View File

@@ -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(

View File

@@ -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<ContactSearchData> = emptyList(),
val userHasStories: Boolean = false
val userHasStories: Boolean = false,
val isArchiveEnabled: Boolean = false,
val archiveDuration: StoryArchiveDuration = StoryArchiveDuration.THIRTY_DAYS
)

View File

@@ -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<RecipientId>) {
disposables += repository.markGroupsAsStories(recipientIds).subscribe {
pagingController.onDataInvalidated()

View File

@@ -74,7 +74,8 @@ class StoryViewerFragment :
storyViewerArgs.isFromNotification -> StoryViewerPageArgs.Source.NOTIFICATION
else -> StoryViewerPageArgs.Source.UNKNOWN
},
groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition
groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition,
isFromArchive = storyViewerArgs.isFromArchive
)
)

View File

@@ -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<List<RecipientId>> {
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(

View File

@@ -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,

View File

@@ -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<List<StoryPost>> {
return getStoryRecords(recipientId, isOutgoingOnly)
.switchMap { records ->
val posts: List<Observable<StoryPost>> = records.map {
fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean, isFromArchive: Boolean = false, initialStoryId: Long = -1L): Observable<List<StoryPost>> {
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<Observable<StoryPost>> = recordList.map {
getStoryPostFromRecord(recipientId, it).distinctUntilChanged()
}
if (posts.isEmpty()) {

View File

@@ -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 ->

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M0.966,10.329H2.386C3.313,5.615 7.523,2 12.478,2C18.087,2 22.75,6.653 22.75,12.273C22.75,17.882 18.087,22.545 12.478,22.545C9.084,22.545 6.082,20.813 4.169,18.245C3.827,17.802 3.937,17.247 4.34,16.996C4.763,16.744 5.237,16.875 5.549,17.288C7.09,19.433 9.607,20.823 12.478,20.833C17.231,20.853 21.038,17.026 21.038,12.273C21.038,7.519 17.231,3.722 12.478,3.722C8.389,3.722 5.005,6.522 4.129,10.329H5.589C6.304,10.329 6.485,10.822 6.103,11.376L3.847,14.589C3.524,15.042 3.041,15.052 2.709,14.589L0.453,11.386C0.06,10.822 0.241,10.329 0.966,10.329ZM12.095,6.371C12.538,6.371 12.891,6.723 12.891,7.166V12.514L15.328,15.797C15.63,16.21 15.6,16.684 15.227,16.945C14.814,17.227 14.351,17.157 14.018,16.724L11.531,13.441C11.36,13.219 11.289,13.018 11.289,12.786V7.166C11.289,6.723 11.652,6.371 12.095,6.371Z"
android:strokeWidth="0.1"
android:fillColor="#000000"
android:strokeColor="#000000"/>
</group>
</vector>

View File

@@ -6798,6 +6798,53 @@
<string name="MyStories__this_story_will_be_deleted">This story will be deleted for you and everyone who received it.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Unable to save</string>
<!-- StoryArchive -->
<!-- Title for the story archive screen -->
<string name="StoryArchive__story_archive">Story archive</string>
<!-- Section header in story settings -->
<string name="StoryArchive__archive">Archive</string>
<!-- Label for switch to enable story archiving -->
<string name="StoryArchive__keep_stories_in_archive">Keep stories in archive</string>
<!-- Description for the archive toggle -->
<string name="StoryArchive__save_stories_after_they_expire">Save your sent stories after they leave the active feed.</string>
<!-- Label for archive duration preference -->
<string name="StoryArchive__keep_stories_for">Keep stories for</string>
<!-- Archive duration option: forever -->
<string name="StoryArchive__forever">Forever</string>
<!-- Archive duration option: 1 year -->
<string name="StoryArchive__1_year">1 year</string>
<!-- Archive duration option: 6 months -->
<string name="StoryArchive__6_months">6 months</string>
<!-- Archive duration option: 30 days -->
<string name="StoryArchive__30_days">30 days</string>
<!-- Empty state title when no archived stories exist -->
<string name="StoryArchive__no_archived_stories">No archived stories</string>
<!-- Empty state message when no archived stories exist -->
<string name="StoryArchive__no_archived_stories_message">Turn on \"Save Stories to Archive\" in story settings to auto-archive your stories.</string>
<!-- Empty state button to navigate to story settings -->
<string name="StoryArchive__go_to_settings">Go to settings</string>
<!-- Label for sort order menu -->
<string name="StoryArchive__sort_by">Sort by</string>
<!-- Sort order option: newest first -->
<string name="StoryArchive__newest">Newest</string>
<!-- Sort order option: oldest first -->
<string name="StoryArchive__oldest">Oldest</string>
<!-- Delete action in story archive multi-select bottom bar -->
<string name="StoryArchive__delete">Delete</string>
<!-- Content description for selecting a story in multi-select mode -->
<string name="StoryArchive__select_story">Select story</string>
<!-- Confirmation dialog body when deleting stories from archive -->
<plurals name="StoryArchive__delete_n_stories">
<item quantity="one">Delete %d story? This cannot be undone.</item>
<item quantity="other">Delete %d stories? This cannot be undone.</item>
</plurals>
<!-- Title shown in toolbar when in multi-select mode in story archive, %d is count of selected items -->
<plurals name="StoryArchive__d_selected">
<item quantity="one">%d selected</item>
<item quantity="other">%d selected</item>
</plurals>
<!-- Displayed at bottom of story viewer when current item has views -->
<plurals name="StoryViewerFragment__d_views">
<item quantity="one">%1$d view</item>

View File

@@ -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)
}

View File

@@ -8,4 +8,5 @@ android {
dependencies {
implementation(project(":core:util"))
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -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<Key> {
return new ObservablePagedData<>(subject, controller);
}
@AnyThread
public static <Key, Data> StateFlowPagedData<Key, Data> createForStateFlow(@NonNull PagedDataSource<Key, Data> dataSource, @NonNull PagingConfig config) {
MutableStateFlow<List<Data>> stateFlow = kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow(java.util.Collections.emptyList());
PagingController<Key> controller = new BufferedPagingController<>(dataSource, config, stateFlow::setValue);
return new StateFlowPagedData<>(stateFlow, controller);
}
public PagingController<Key> getController() {
return controller;
}

View File

@@ -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<Key, Data>(
val data: StateFlow<List<Data>>,
controller: PagingController<Key>
) : PagedData<Key>(controller)