Fix all media storage overview performance.

This commit is contained in:
Greyson Parrelli
2026-03-21 23:38:33 -04:00
committed by Cody Henthorne
parent a8ba0dccca
commit 7a2eca3bd5
8 changed files with 139 additions and 35 deletions

View File

@@ -297,7 +297,8 @@ class AttachmentTable(
"CREATE INDEX IF NOT EXISTS $DATA_FILE_INDEX ON $TABLE_NAME ($DATA_FILE);",
"CREATE INDEX IF NOT EXISTS attachment_archive_transfer_state ON $TABLE_NAME ($ARCHIVE_TRANSFER_STATE);",
"CREATE INDEX IF NOT EXISTS attachment_remote_digest_index ON $TABLE_NAME ($REMOTE_DIGEST);",
"CREATE INDEX IF NOT EXISTS attachment_metadata_id ON $TABLE_NAME ($METADATA_ID);"
"CREATE INDEX IF NOT EXISTS attachment_metadata_id ON $TABLE_NAME ($METADATA_ID);",
"CREATE INDEX IF NOT EXISTS attachment_media_overview_size ON $TABLE_NAME ($DATA_SIZE DESC, $DISPLAY_ORDER DESC) WHERE $QUOTE = 0 AND $STICKER_PACK_ID IS NULL AND $DATA_FILE IS NOT NULL"
)
private val DATA_FILE_INFO_PROJECTION = arrayOf(

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.database.Cursor
import androidx.compose.runtime.Immutable
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
@@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.util.MediaUtil.SlideType
class MediaTable internal constructor(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper) {
companion object {
private val TAG = Log.tag(MediaTable::class)
const val ALL_THREADS = -1
private const val THREAD_RECIPIENT_ID = "THREAD_RECIPIENT_ID"
private val BASE_MEDIA_QUERY = """
@@ -64,16 +66,12 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID},
${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} as $THREAD_RECIPIENT_ID,
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS}
FROM
${AttachmentTable.TABLE_NAME}
LEFT JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
LEFT JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} = ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}
WHERE
${AttachmentTable.MESSAGE_ID} IN (
SELECT ${MessageTable.ID}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} __EQUALITY__ ?
) AND
FROM
${AttachmentTable.TABLE_NAME} __INDEX_HINT__
LEFT JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
LEFT JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} = ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}
WHERE
__THREAD_FILTER__ AND
(%s) AND
${MessageTable.VIEW_ONCE} = 0 AND
${MessageTable.STORY_TYPE} = 0 AND
@@ -216,7 +214,25 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
)
private fun applyEqualityOperator(threadId: Long, query: String): String {
return query.replace("__EQUALITY__", if (threadId == ALL_THREADS.toLong()) "!=" else "=")
val isAllThreads = threadId == ALL_THREADS.toLong()
return query
.replace(
"__THREAD_FILTER__",
if (isAllThreads) {
"${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} != ?"
} else {
"${AttachmentTable.MESSAGE_ID} IN (SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.THREAD_ID} = ?)"
}
)
.replace("__EQUALITY__", if (isAllThreads) "!=" else "=")
.replace("__INDEX_HINT__", "")
}
private fun applyIndexHint(query: String, threadId: Long, sorting: Sorting): String {
if (threadId == ALL_THREADS.toLong() && sorting == Sorting.Largest) {
return query.replace("__INDEX_HINT__", "INDEXED BY attachment_media_overview_size")
}
return query.replace("__INDEX_HINT__", "")
}
}
@@ -232,15 +248,27 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
return readableDatabase.rawQuery(query, args)
}
fun getDocumentMediaForThread(threadId: Long, sorting: Sorting): Cursor {
val query = sorting.applyToQuery(applyEqualityOperator(threadId, DOCUMENT_MEDIA_QUERY))
@JvmOverloads
fun getDocumentMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor {
var query = sorting.applyToQuery(applyEqualityOperator(threadId, DOCUMENT_MEDIA_QUERY))
val args = arrayOf(threadId.toString() + "")
if (limit > 0) {
query = "$query LIMIT $limit"
}
return readableDatabase.rawQuery(query, args)
}
fun getAudioMediaForThread(threadId: Long, sorting: Sorting): Cursor {
val query = sorting.applyToQuery(applyEqualityOperator(threadId, AUDIO_MEDIA_QUERY))
@JvmOverloads
fun getAudioMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor {
var query = sorting.applyToQuery(applyEqualityOperator(threadId, AUDIO_MEDIA_QUERY))
val args = arrayOf(threadId.toString() + "")
if (limit > 0) {
query = "$query LIMIT $limit"
}
return readableDatabase.rawQuery(query, args)
}
@@ -255,9 +283,15 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
return readableDatabase.rawQuery(query, args)
}
fun getAllMediaForThread(threadId: Long, sorting: Sorting): Cursor {
val query = sorting.applyToQuery(applyEqualityOperator(threadId, ALL_MEDIA_QUERY))
@JvmOverloads
fun getAllMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor {
var query = sorting.applyToQuery(applyEqualityOperator(threadId, applyIndexHint(ALL_MEDIA_QUERY, threadId, sorting)))
val args = arrayOf(threadId.toString() + "")
if (limit > 0) {
query = "$query LIMIT $limit"
}
return readableDatabase.rawQuery(query, args)
}

View File

@@ -163,6 +163,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDelet
import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminatedColumnMigration
import org.thoughtcrime.securesms.database.helpers.migration.V310_AddStarredColumn
import org.thoughtcrime.securesms.database.helpers.migration.V311_AddAttachmentMediaOverviewSizeIndex
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -333,10 +334,11 @@ object SignalDatabaseMigrations {
// 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future
308 to V308_AddBackRemoteDeletedColumn,
309 to V309_GroupTerminatedColumnMigration,
310 to V310_AddStarredColumn
310 to V310_AddStarredColumn,
311 to V311_AddAttachmentMediaOverviewSizeIndex
)
const val DATABASE_VERSION = 310
const val DATABASE_VERSION = 311
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds a partial index on attachment (data_size, display_order) to speed up
* the "all threads" media overview sorted by largest.
*/
@Suppress("ClassName")
object V311_AddAttachmentMediaOverviewSizeIndex : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("CREATE INDEX IF NOT EXISTS attachment_media_overview_size ON attachment (data_size DESC, display_order DESC) WHERE quote = 0 AND sticker_pack_id IS NULL AND data_file IS NOT NULL")
}
}

View File

@@ -30,16 +30,19 @@ public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThrea
private final MediaLoader.MediaType mediaType;
private final MediaTable.Sorting sorting;
private final long threadId;
private final int limit;
public GroupedThreadMediaLoader(@NonNull Context context,
long threadId,
@NonNull MediaLoader.MediaType mediaType,
@NonNull MediaTable.Sorting sorting)
@NonNull MediaTable.Sorting sorting,
int limit)
{
super(context);
this.threadId = threadId;
this.mediaType = mediaType;
this.sorting = sorting;
this.limit = limit;
this.observer = () -> ThreadUtil.runOnMain(this::onContentChanged);
onContentChanged();
@@ -73,7 +76,7 @@ public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThrea
AppDependencies.getDatabaseObserver().registerAttachmentUpdatedObserver(observer);
try (Cursor cursor = ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting)) {
try (Cursor cursor = ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting, limit)) {
while (cursor != null && cursor.moveToNext()) {
mediaGrouping.add(MediaTable.MediaRecord.from(cursor));
}

View File

@@ -37,7 +37,7 @@ public final class RecipientMediaLoader extends MediaLoader {
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));
return ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting);
return ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting, 0);
}
}

View File

@@ -27,21 +27,22 @@ public final class ThreadMediaLoader extends MediaLoader {
@Override
public Cursor getCursor() {
return createThreadMediaCursor(context, threadId, mediaType, sorting);
return createThreadMediaCursor(context, threadId, mediaType, sorting, 0);
}
static Cursor createThreadMediaCursor(@NonNull Context context,
long threadId,
@NonNull MediaType mediaType,
@NonNull MediaTable.Sorting sorting) {
@NonNull MediaTable.Sorting sorting,
int limit) {
MediaTable mediaDatabase = SignalDatabase.media();
switch (mediaType) {
case GALLERY : return mediaDatabase.getGalleryMediaForThread(threadId, sorting);
case DOCUMENT: return mediaDatabase.getDocumentMediaForThread(threadId, sorting);
case AUDIO : return mediaDatabase.getAudioMediaForThread(threadId, sorting);
case GALLERY : return mediaDatabase.getGalleryMediaForThread(threadId, sorting, limit);
case DOCUMENT: return mediaDatabase.getDocumentMediaForThread(threadId, sorting, limit);
case AUDIO : return mediaDatabase.getAudioMediaForThread(threadId, sorting, limit);
case LINK : return mediaDatabase.getLinkMediaForThread(threadId, sorting);
case ALL : return mediaDatabase.getAllMediaForThread(threadId, sorting);
case ALL : return mediaDatabase.getAllMediaForThread(threadId, sorting, limit);
default : throw new AssertionError();
}
}

View File

@@ -70,9 +70,11 @@ public final class MediaOverviewPageFragment extends LoggingFragment
private static final String TAG = Log.tag(MediaOverviewPageFragment.class);
private static final String THREAD_ID_EXTRA = "thread_id";
private static final String MEDIA_TYPE_EXTRA = "media_type";
private static final String GRID_MODE = "grid_mode";
private static final String THREAD_ID_EXTRA = "thread_id";
private static final String MEDIA_TYPE_EXTRA = "media_type";
private static final String GRID_MODE = "grid_mode";
private static final int INITIAL_LOAD_LIMIT = 500;
private static final int LOAD_MORE_THRESHOLD = 50;
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
private MediaTable.Sorting sorting = MediaTable.Sorting.Newest;
@@ -88,6 +90,10 @@ public final class MediaOverviewPageFragment extends LoggingFragment
private VoiceNoteMediaController voiceNoteMediaController;
private SignalBottomActionBar bottomActionBar;
private LifecycleDisposable lifecycleDisposable;
private boolean pendingLoad = true;
private int loadLimit;
private boolean allLoaded;
private boolean loadingMore;
public static @NonNull Fragment newInstance(long threadId,
@NonNull MediaLoader.MediaType mediaType,
@@ -115,7 +121,8 @@ public final class MediaOverviewPageFragment extends LoggingFragment
if (threadId == Long.MIN_VALUE) throw new AssertionError();
LoaderManager.getInstance(this).initLoader(0, null, this);
loadLimit = threadId == MediaTable.ALL_THREADS ? INITIAL_LOAD_LIMIT : 0;
allLoaded = loadLimit == 0;
}
@Override
@@ -125,6 +132,15 @@ public final class MediaOverviewPageFragment extends LoggingFragment
voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), false);
}
@Override
public void onResume() {
super.onResume();
if (pendingLoad) {
pendingLoad = false;
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
lifecycleDisposable = new LifecycleDisposable();
@@ -151,6 +167,22 @@ public final class MediaOverviewPageFragment extends LoggingFragment
this.recyclerView.setHasFixedSize(true);
this.recyclerView.addItemDecoration(new MediaGridDividerDecoration(spans, ViewUtil.dpToPx(4), adapter));
this.recyclerView.addItemDecoration(new BottomOffsetDecoration(ViewUtil.dpToPx(160)));
this.recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (allLoaded || loadingMore) return;
int scrollRange = rv.computeVerticalScrollRange();
int scrollOffset = rv.computeVerticalScrollOffset();
int scrollExtent = rv.computeVerticalScrollExtent();
if (scrollRange > 0 && scrollOffset + scrollExtent * 2 >= scrollRange) {
loadingMore = true;
loadLimit *= 2;
LoaderManager.getInstance(MediaOverviewPageFragment.this).restartLoader(0, null, MediaOverviewPageFragment.this);
}
}
});
MediaOverviewViewModel viewModel = MediaOverviewViewModel.getMediaOverviewViewModel(requireActivity());
@@ -158,8 +190,14 @@ public final class MediaOverviewPageFragment extends LoggingFragment
.observe(getViewLifecycleOwner(), sorting -> {
if (sorting != null) {
this.sorting = sorting;
this.loadLimit = threadId == MediaTable.ALL_THREADS ? INITIAL_LOAD_LIMIT : 0;
this.allLoaded = loadLimit == 0;
adapter.setShowFileSizes(sorting.isRelatedToFileSize());
LoaderManager.getInstance(this).restartLoader(0, null, this);
if (isResumed()) {
LoaderManager.getInstance(this).restartLoader(0, null, this);
} else {
pendingLoad = true;
}
updateMultiSelect();
}
});
@@ -196,7 +234,7 @@ public final class MediaOverviewPageFragment extends LoggingFragment
@Override
public @NonNull Loader<GroupedThreadMediaLoader.GroupedThreadMedia> onCreateLoader(int i, Bundle bundle) {
return new GroupedThreadMediaLoader(requireContext(), threadId, mediaType, sorting);
return new GroupedThreadMediaLoader(requireContext(), threadId, mediaType, sorting, loadLimit);
}
@Override
@@ -204,6 +242,15 @@ public final class MediaOverviewPageFragment extends LoggingFragment
((MediaGalleryAllAdapter) recyclerView.getAdapter()).setMedia(groupedThreadMedia);
((MediaGalleryAllAdapter) recyclerView.getAdapter()).notifyAllSectionsDataSetChanged();
if (loadLimit > 0) {
int totalMediaItems = 0;
for (int i = 0; i < groupedThreadMedia.getSectionCount(); i++) {
totalMediaItems += groupedThreadMedia.getSectionItemCount(i);
}
allLoaded = totalMediaItems < loadLimit;
}
loadingMore = false;
noMedia.setVisibility(recyclerView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE);
getActivity().invalidateOptionsMenu();
}