Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
Get rid of EmailProvider
Using an internal `ContentProvider` and `CursorLoader` was a failed experiment that lived in production for far too long. Let's get rid of it.


Discussion hints:
nan

Pull request: Remove `EmailProvider` :tada: 
Finally getting rid of `EmailProvider` should make it easier to implement a fix for: 
- #6246.

Closes #3248

Test command: ./gradlew :app:core:testDebugUnitTest --tests "com.fsck.k9.mailstore.K9BackendFolderTest" --tests "com.fsck.k9.mailstore.K9BackendStorageTest" --tests "com.fsck.k9.mailstore.MessageListCacheTest" --tests "com.fsck.k9.cache.EmailProviderCacheTest" --tests "com.fsck.k9.mailstore.MessageListRepositoryTest" :app:storage:testDebugUnitTest --tests "com.fsck.k9.storage.messages.RetrieveFolderOperationsTest" --tests "com.fsck.k9.storage.messages.RetrieveMessageListOperationsTest" :app:ui:legacy:testDebugUnitTest --tests "com.fsck.k9.fragment.MessageListAdapterTest"

Fix patch:
diff --git a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java b/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java
deleted file mode 100644
index cc12fde40c7..00000000000
--- a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package com.fsck.k9.cache;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import android.content.Context;
-import android.net.Uri;
-
-import com.fsck.k9.mailstore.LocalMessage;
-import com.fsck.k9.provider.EmailProvider;
-
-/**
- * Cache to bridge the time needed to write (user-initiated) changes to the database.
- */
-public class EmailProviderCache {
-    private static Context sContext;
-    private static Map<String, EmailProviderCache> sInstances =
-            new HashMap<>();
-
-    public static synchronized EmailProviderCache getCache(String accountUuid, Context context) {
-
-        if (sContext == null) {
-            sContext = context.getApplicationContext();
-        }
-
-        EmailProviderCache instance = sInstances.get(accountUuid);
-        if (instance == null) {
-            instance = new EmailProviderCache(accountUuid);
-            sInstances.put(accountUuid, instance);
-        }
-
-        return instance;
-    }
-
-
-    private String mAccountUuid;
-    private final Map<Long, Map<String, String>> mMessageCache = new HashMap<>();
-    private final Map<Long, Map<String, String>> mThreadCache = new HashMap<>();
-    private final Map<Long, Long> mHiddenMessageCache = new HashMap<>();
-
-
-    private EmailProviderCache(String accountUuid) {
-        mAccountUuid = accountUuid;
-    }
-
-    public String getValueForMessage(Long messageId, String columnName) {
-        synchronized (mMessageCache) {
-            Map<String, String> map = mMessageCache.get(messageId);
-            return (map == null) ? null : map.get(columnName);
-        }
-    }
-
-    public String getValueForThread(Long threadRootId, String columnName) {
-        synchronized (mThreadCache) {
-            Map<String, String> map = mThreadCache.get(threadRootId);
-            return (map == null) ? null : map.get(columnName);
-        }
-    }
-
-    public void setValueForMessages(List<Long> messageIds, String columnName, String value) {
-        synchronized (mMessageCache) {
-            for (Long messageId : messageIds) {
-                Map<String, String> map = mMessageCache.get(messageId);
-                if (map == null) {
-                    map = new HashMap<>();
-                    mMessageCache.put(messageId, map);
-                }
-                map.put(columnName, value);
-            }
-        }
-
-        notifyChange();
-    }
-
-    public void setValueForThreads(List<Long> threadRootIds, String columnName, String value) {
-        synchronized (mThreadCache) {
-            for (Long threadRootId : threadRootIds) {
-                Map<String, String> map = mThreadCache.get(threadRootId);
-                if (map == null) {
-                    map = new HashMap<>();
-                    mThreadCache.put(threadRootId, map);
-                }
-                map.put(columnName, value);
-            }
-        }
-
-        notifyChange();
-    }
-
-    public void removeValueForMessages(List<Long> messageIds, String columnName) {
-        synchronized (mMessageCache) {
-            for (Long messageId : messageIds) {
-                Map<String, String> map = mMessageCache.get(messageId);
-                if (map != null) {
-                    map.remove(columnName);
-                    if (map.isEmpty()) {
-                        mMessageCache.remove(messageId);
-                    }
-                }
-            }
-        }
-    }
-
-    public void removeValueForThreads(List<Long> threadRootIds, String columnName) {
-        synchronized (mThreadCache) {
-            for (Long threadRootId : threadRootIds) {
-                Map<String, String> map = mThreadCache.get(threadRootId);
-                if (map != null) {
-                    map.remove(columnName);
-                    if (map.isEmpty()) {
-                        mThreadCache.remove(threadRootId);
-                    }
-                }
-            }
-        }
-    }
-
-    public void hideMessages(List<LocalMessage> messages) {
-        synchronized (mHiddenMessageCache) {
-            for (LocalMessage message : messages) {
-                long messageId = message.getDatabaseId();
-                mHiddenMessageCache.put(messageId, message.getFolder().getDatabaseId());
-            }
-        }
-
-        notifyChange();
-    }
-
-    public boolean isMessageHidden(Long messageId, long folderId) {
-        synchronized (mHiddenMessageCache) {
-            Long hiddenInFolder = mHiddenMessageCache.get(messageId);
-            return (hiddenInFolder != null && hiddenInFolder == folderId);
-        }
-    }
-
-    public void unhideMessages(List<LocalMessage> messages) {
-        synchronized (mHiddenMessageCache) {
-            for (LocalMessage message : messages) {
-                long messageId = message.getDatabaseId();
-                long folderId = message.getFolder().getDatabaseId();
-                Long hiddenInFolder = mHiddenMessageCache.get(messageId);
-
-                if (hiddenInFolder != null && hiddenInFolder == folderId) {
-                    mHiddenMessageCache.remove(messageId);
-                }
-            }
-        }
-    }
-
-    private void notifyChange() {
-        Uri uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + mAccountUuid +
-                "/messages");
-        sContext.getContentResolver().notifyChange(uri, null);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java b/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java
deleted file mode 100644
index e695eea7fbb..00000000000
--- a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java
+++ /dev/null
@@ -1,144 +0,0 @@
-package com.fsck.k9.cache;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import com.fsck.k9.provider.EmailProvider.MessageColumns;
-import com.fsck.k9.provider.EmailProvider.ThreadColumns;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.database.CursorWrapper;
-
-/**
- * A {@link CursorWrapper} that utilizes {@link EmailProviderCache}.
- */
-public class EmailProviderCacheCursor extends CursorWrapper {
-    private EmailProviderCache mCache;
-    private List<Integer> mHiddenRows = new ArrayList<>();
-    private int mMessageIdColumn;
-    private int mFolderIdColumn;
-    private int mThreadRootColumn;
-
-    /**
-     * The cursor's current position.
-     *
-     * Note: This is only used when {@link #mHiddenRows} isn't empty.
-     */
-    private int mPosition;
-
-
-    public EmailProviderCacheCursor(String accountUuid, Cursor cursor, Context context) {
-        super(cursor);
-
-        mCache = EmailProviderCache.getCache(accountUuid, context);
-
-        mMessageIdColumn = cursor.getColumnIndex(MessageColumns.ID);
-        mFolderIdColumn = cursor.getColumnIndex(MessageColumns.FOLDER_ID);
-        mThreadRootColumn = cursor.getColumnIndex(ThreadColumns.ROOT);
-
-        if (mMessageIdColumn == -1 || mFolderIdColumn == -1 || mThreadRootColumn == -1) {
-            throw new IllegalArgumentException("The supplied cursor needs to contain the " +
-                    "following columns: " + MessageColumns.ID + ", " + MessageColumns.FOLDER_ID +
-                    ", " + ThreadColumns.ROOT);
-        }
-
-        while (cursor.moveToNext()) {
-            long messageId = cursor.getLong(mMessageIdColumn);
-            long folderId = cursor.getLong(mFolderIdColumn);
-            if (mCache.isMessageHidden(messageId, folderId)) {
-                mHiddenRows.add(cursor.getPosition());
-            }
-        }
-
-        // Reset the cursor position
-        cursor.moveToFirst();
-        cursor.moveToPrevious();
-    }
-
-    @Override
-    public int getInt(int columnIndex) {
-        long messageId = getLong(mMessageIdColumn);
-        long threadRootId = getLong(mThreadRootColumn);
-
-        String columnName = getColumnName(columnIndex);
-        String value = mCache.getValueForMessage(messageId, columnName);
-
-        if (value != null) {
-            return Integer.parseInt(value);
-        }
-
-        value = mCache.getValueForThread(threadRootId, columnName);
-        if (value != null) {
-            return Integer.parseInt(value);
-        }
-
-        return super.getInt(columnIndex);
-    }
-
-    @Override
-    public int getCount() {
-        return super.getCount() - mHiddenRows.size();
-    }
-
-    @Override
-    public boolean moveToFirst() {
-        return moveToPosition(0);
-    }
-
-    @Override
-    public boolean moveToLast() {
-        return moveToPosition(getCount());
-    }
-
-    @Override
-    public boolean moveToNext() {
-        return moveToPosition(getPosition() + 1);
-    }
-
-    @Override
-    public boolean moveToPrevious() {
-        return moveToPosition(getPosition() - 1);
-    }
-
-    @Override
-    public boolean move(int offset) {
-        return moveToPosition(getPosition() + offset);
-    }
-
-    @Override
-    public boolean moveToPosition(int position) {
-        if (mHiddenRows.isEmpty()) {
-            return super.moveToPosition(position);
-        }
-
-        mPosition = position;
-        int newPosition = position;
-        for (int hiddenRow : mHiddenRows) {
-            if (hiddenRow > newPosition) {
-                break;
-            }
-            newPosition++;
-        }
-
-        return super.moveToPosition(newPosition);
-    }
-
-    @Override
-    public int getPosition() {
-        if (mHiddenRows.isEmpty()) {
-            return super.getPosition();
-        }
-
-        return mPosition;
-    }
-
-    @Override
-    public boolean isLast() {
-        if (mHiddenRows.isEmpty()) {
-            return super.isLast();
-        }
-
-        return (mPosition == getCount() - 1);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
index 409dedc6dd8..2cf0e69e4f9 100644
--- a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
+++ b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
@@ -39,7 +39,6 @@
 import com.fsck.k9.backend.api.BuildConfig;
 import com.fsck.k9.backend.api.SyncConfig;
 import com.fsck.k9.backend.api.SyncListener;
-import com.fsck.k9.cache.EmailProviderCache;
 import com.fsck.k9.controller.ControllerExtension.ControllerInternals;
 import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend;
 import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand;
@@ -71,6 +70,7 @@
 import com.fsck.k9.mailstore.LocalMessage;
 import com.fsck.k9.mailstore.LocalStore;
 import com.fsck.k9.mailstore.LocalStoreProvider;
+import com.fsck.k9.mailstore.MessageListCache;
 import com.fsck.k9.mailstore.MessageStore;
 import com.fsck.k9.mailstore.MessageStoreManager;
 import com.fsck.k9.mailstore.OutboxState;
@@ -322,12 +322,12 @@ public Set<MessagingListener> getListeners(MessagingListener listener) {
 
 
     private void suppressMessages(Account account, List<LocalMessage> messages) {
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
         cache.hideMessages(messages);
     }
 
     private void unsuppressMessages(Account account, List<LocalMessage> messages) {
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
         cache.unhideMessages(messages);
     }
 
@@ -335,42 +335,36 @@ public boolean isMessageSuppressed(LocalMessage message) {
         long messageId = message.getDatabaseId();
         long folderId = message.getFolder().getDatabaseId();
 
-        EmailProviderCache cache = EmailProviderCache.getCache(message.getFolder().getAccountUuid(), context);
+        MessageListCache cache = MessageListCache.getCache(message.getFolder().getAccountUuid());
         return cache.isMessageHidden(messageId, folderId);
     }
 
     private void setFlagInCache(final Account account, final List<Long> messageIds,
             final Flag flag, final boolean newState) {
 
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
-        String columnName = LocalStore.getColumnNameForFlag(flag);
-        String value = Integer.toString((newState) ? 1 : 0);
-        cache.setValueForMessages(messageIds, columnName, value);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
+        cache.setFlagForMessages(messageIds, flag, newState);
     }
 
     private void removeFlagFromCache(final Account account, final List<Long> messageIds,
             final Flag flag) {
 
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
-        String columnName = LocalStore.getColumnNameForFlag(flag);
-        cache.removeValueForMessages(messageIds, columnName);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
+        cache.removeFlagForMessages(messageIds, flag);
     }
 
     private void setFlagForThreadsInCache(final Account account, final List<Long> threadRootIds,
             final Flag flag, final boolean newState) {
 
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
-        String columnName = LocalStore.getColumnNameForFlag(flag);
-        String value = Integer.toString((newState) ? 1 : 0);
-        cache.setValueForThreads(threadRootIds, columnName, value);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
+        cache.setValueForThreads(threadRootIds, flag, newState);
     }
 
     private void removeFlagForThreadsFromCache(final Account account, final List<Long> messageIds,
             final Flag flag) {
 
-        EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context);
-        String columnName = LocalStore.getColumnNameForFlag(flag);
-        cache.removeValueForThreads(messageIds, columnName);
+        MessageListCache cache = MessageListCache.getCache(account.getUuid());
+        cache.removeFlagForThreads(messageIds, flag);
     }
 
     public void refreshFolderList(final Account account) {
diff --git a/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java b/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java
deleted file mode 100644
index f0424f9568f..00000000000
--- a/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java
+++ /dev/null
@@ -1,462 +0,0 @@
-/*
- * Copyright (C) 2012 The K-9 Dog Walkers
- * Copyright (C) 2006 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.fsck.k9.helper;
-
-import java.util.Comparator;
-
-import android.annotation.TargetApi;
-import android.content.ContentResolver;
-import android.database.CharArrayBuffer;
-import android.database.ContentObserver;
-import android.database.Cursor;
-import android.database.DataSetObserver;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-
-
-/**
- * This class can be used to combine multiple {@link Cursor}s into one.
- */
-public class MergeCursor implements Cursor {
-    /**
-     * List of the cursors combined in this object.
-     */
-    protected final Cursor[] mCursors;
-
-    /**
-     * The currently active cursor.
-     */
-    protected Cursor mActiveCursor;
-
-    /**
-     * The index of the currently active cursor in {@link #mCursors}.
-     *
-     * @see #mActiveCursor
-     */
-    protected int mActiveCursorIndex;
-
-    /**
-     * The cursor's current position.
-     */
-    protected int mPosition;
-
-    /**
-     * Used to cache the value of {@link #getCount()}.
-     */
-    private int mCount = -1;
-
-    /**
-     * The comparator that is used to decide how the individual cursors are merged.
-     */
-    private final Comparator<Cursor> mComparator;
-
-
-    /**
-     * Constructor
-     *
-     * @param cursors
-     *         The list of cursors this {@code MultiCursor} should combine.
-     * @param comparator
-     *         A comparator that is used to decide in what order the individual cursors are merged.
-     */
-    public MergeCursor(Cursor[] cursors, Comparator<Cursor> comparator) {
-        mCursors = cursors.clone();
-        mComparator = comparator;
-
-        resetCursors();
-    }
-
-    private void resetCursors() {
-        mActiveCursorIndex = -1;
-        mActiveCursor = null;
-        mPosition = -1;
-
-        for (int i = 0, len = mCursors.length; i < len; i++) {
-            Cursor cursor = mCursors[i];
-            if (cursor != null) {
-                cursor.moveToPosition(-1);
-
-                if (mActiveCursor == null) {
-                    mActiveCursorIndex = i;
-                    mActiveCursor = mCursors[mActiveCursorIndex];
-                }
-            }
-        }
-    }
-
-    @Override
-    public void close() {
-        for (Cursor cursor : mCursors) {
-            if (cursor != null) {
-                cursor.close();
-            }
-        }
-    }
-
-    @Override
-    public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
-        mActiveCursor.copyStringToBuffer(columnIndex, buffer);
-    }
-
-    @Override
-    public void deactivate() {
-        for (Cursor cursor : mCursors) {
-            if (cursor != null) {
-                cursor.deactivate();
-            }
-        }
-    }
-
-    @Override
-    public byte[] getBlob(int columnIndex) {
-        return mActiveCursor.getBlob(columnIndex);
-    }
-
-    @Override
-    public int getColumnCount() {
-        return mActiveCursor.getColumnCount();
-    }
-
-    @Override
-    public int getColumnIndex(String columnName) {
-        return mActiveCursor.getColumnIndex(columnName);
-    }
-
-    @Override
-    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
-        return mActiveCursor.getColumnIndexOrThrow(columnName);
-    }
-
-    @Override
-    public String getColumnName(int columnIndex) {
-        return mActiveCursor.getColumnName(columnIndex);
-    }
-
-    @Override
-    public String[] getColumnNames() {
-        return mActiveCursor.getColumnNames();
-    }
-
-    @Override
-    public int getCount() {
-        // CursorLoaders seem to call getCount() a lot. So we're caching the aggregated count.
-        if (mCount == -1) {
-            int count = 0;
-            for (Cursor cursor : mCursors) {
-                if (cursor != null) {
-                    count += cursor.getCount();
-                }
-            }
-
-            mCount = count;
-        }
-
-        return mCount;
-    }
-
-    @Override
-    public double getDouble(int columnIndex) {
-        return mActiveCursor.getDouble(columnIndex);
-    }
-
-    @Override
-    public float getFloat(int columnIndex) {
-        return mActiveCursor.getFloat(columnIndex);
-    }
-
-    @Override
-    public int getInt(int columnIndex) {
-        return mActiveCursor.getInt(columnIndex);
-    }
-
-    @Override
-    public long getLong(int columnIndex) {
-        return mActiveCursor.getLong(columnIndex);
-    }
-
-    @Override
-    public int getPosition() {
-        return mPosition;
-    }
-
-    @Override
-    public short getShort(int columnIndex) {
-        return mActiveCursor.getShort(columnIndex);
-    }
-
-    @Override
-    public String getString(int columnIndex) {
-        return mActiveCursor.getString(columnIndex);
-    }
-
-    @Override
-    public int getType(int columnIndex) {
-        return mActiveCursor.getType(columnIndex);
-    }
-
-    @Override
-    public boolean getWantsAllOnMoveCalls() {
-        return mActiveCursor.getWantsAllOnMoveCalls();
-    }
-
-    @TargetApi(Build.VERSION_CODES.M)
-    @Override
-    public void setExtras(Bundle extras) {
-        mActiveCursor.setExtras(extras);
-    }
-
-    @Override
-    public boolean isAfterLast() {
-        int count = getCount();
-        return count == 0 || mPosition == count;
-
-    }
-
-    @Override
-    public boolean isBeforeFirst() {
-        return getCount() == 0 || mPosition == -1;
-
-    }
-
-    @Override
-    public boolean isClosed() {
-        return mActiveCursor.isClosed();
-    }
-
-    @Override
-    public boolean isFirst() {
-        return getCount() != 0 && mPosition == 0;
-
-    }
-
-    @Override
-    public boolean isLast() {
-        int count = getCount();
-        return count != 0 && mPosition == count - 1;
-
-    }
-
-    @Override
-    public boolean isNull(int columnIndex) {
-        return mActiveCursor.isNull(columnIndex);
-    }
-
-    @Override
-    public boolean move(int offset) {
-        return moveToPosition(mPosition + offset);
-    }
-
-    @Override
-    public boolean moveToFirst() {
-        return moveToPosition(0);
-    }
-
-    @Override
-    public boolean moveToLast() {
-        return moveToPosition(getCount() - 1);
-    }
-
-    @Override
-    public boolean moveToNext() {
-        int count = getCount();
-        if (mPosition == count) {
-            return false;
-        }
-
-        if (mPosition == count - 1) {
-            mActiveCursor.moveToNext();
-            mPosition++;
-            return false;
-        }
-
-        int smallest = -1;
-        for (int i = 0, len = mCursors.length; i < len; i++) {
-            if (mCursors[i] == null || mCursors[i].getCount() == 0 || mCursors[i].isLast()) {
-                continue;
-            }
-
-            if (smallest == -1) {
-                smallest = i;
-                mCursors[smallest].moveToNext();
-                continue;
-            }
-
-            Cursor left = mCursors[smallest];
-            Cursor right = mCursors[i];
-
-            right.moveToNext();
-
-            int result = mComparator.compare(left, right);
-            if (result > 0) {
-                smallest = i;
-                left.moveToPrevious();
-            } else {
-                right.moveToPrevious();
-            }
-        }
-
-        mPosition++;
-        if (smallest != -1) {
-            mActiveCursorIndex = smallest;
-            mActiveCursor = mCursors[mActiveCursorIndex];
-        }
-
-        return true;
-    }
-
-    @Override
-    public boolean moveToPosition(int position) {
-        // Make sure position isn't past the end of the cursor
-        final int count = getCount();
-        if (position >= count) {
-            mPosition = count;
-            return false;
-        }
-
-        // Make sure position isn't before the beginning of the cursor
-        if (position < 0) {
-            mPosition = -1;
-            return false;
-        }
-
-        // Check for no-op moves, and skip the rest of the work for them
-        if (position == mPosition) {
-            return true;
-        }
-
-        if (position > mPosition) {
-            for (int i = 0, end = position - mPosition; i < end; i++) {
-                if (!moveToNext()) {
-                    return false;
-                }
-            }
-        } else {
-            for (int i = 0, end = mPosition - position; i < end; i++) {
-                if (!moveToPrevious()) {
-                    return false;
-                }
-            }
-        }
-
-        return true;
-    }
-
-    @Override
-    public boolean moveToPrevious() {
-        if (mPosition < 0) {
-            return false;
-        }
-
-        mActiveCursor.moveToPrevious();
-
-        if (mPosition == 0) {
-            mPosition = -1;
-            return false;
-        }
-
-        int greatest = -1;
-        for (int i = 0, len = mCursors.length; i < len; i++) {
-            if (mCursors[i] == null || mCursors[i].isBeforeFirst()) {
-                continue;
-            }
-
-            if (greatest == -1) {
-                greatest = i;
-                continue;
-            }
-
-            Cursor left = mCursors[greatest];
-            Cursor right = mCursors[i];
-
-            int result = mComparator.compare(left, right);
-            if (result <= 0) {
-                greatest = i;
-            }
-        }
-
-        mPosition--;
-        if (greatest != -1) {
-            mActiveCursorIndex = greatest;
-            mActiveCursor = mCursors[mActiveCursorIndex];
-        }
-
-        return true;
-    }
-
-    @Override
-    public void registerContentObserver(ContentObserver observer) {
-        for (Cursor cursor : mCursors) {
-            cursor.registerContentObserver(observer);
-        }
-    }
-
-    @Override
-    public void registerDataSetObserver(DataSetObserver observer) {
-        for (Cursor cursor : mCursors) {
-            cursor.registerDataSetObserver(observer);
-        }
-    }
-
-    @Deprecated
-    @Override
-    public boolean requery() {
-        boolean success = true;
-        for (Cursor cursor : mCursors) {
-            success &= cursor.requery();
-        }
-
-        return success;
-    }
-
-    @Override
-    public void setNotificationUri(ContentResolver cr, Uri uri) {
-        for (Cursor cursor : mCursors) {
-            cursor.setNotificationUri(cr, uri);
-        }
-    }
-
-    @Override
-    public void unregisterContentObserver(ContentObserver observer) {
-        for (Cursor cursor : mCursors) {
-            cursor.unregisterContentObserver(observer);
-        }
-    }
-
-    @Override
-    public void unregisterDataSetObserver(DataSetObserver observer) {
-        for (Cursor cursor : mCursors) {
-            cursor.unregisterDataSetObserver(observer);
-        }
-    }
-
-    @Override
-    public Bundle getExtras() {
-        throw new RuntimeException("Not implemented");
-    }
-
-    @Override
-    public Bundle respond(Bundle extras) {
-        throw new RuntimeException("Not implemented");
-    }
-
-    @Override
-    public Uri getNotificationUri() {
-        return null;
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java b/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java
deleted file mode 100644
index 111d49fbbb1..00000000000
--- a/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.fsck.k9.helper;
-
-import java.util.Comparator;
-
-import android.database.Cursor;
-
-
-public class MergeCursorWithUniqueId extends MergeCursor {
-    private static final int SHIFT = 48;
-    private static final long MAX_ID = (1L << SHIFT) - 1;
-    private static final long MAX_CURSORS = 1L << (63 - SHIFT);
-
-    private int mColumnCount = -1;
-    private int mIdColumnIndex = -1;
-
-
-    public MergeCursorWithUniqueId(Cursor[] cursors, Comparator<Cursor> comparator) {
-        super(cursors, comparator);
-
-        if (cursors.length > MAX_CURSORS) {
-            throw new IllegalArgumentException("This class only supports up to " +
-                    MAX_CURSORS + " cursors");
-        }
-    }
-
-    @Override
-    public int getColumnCount() {
-        if (mColumnCount == -1) {
-            mColumnCount = super.getColumnCount();
-        }
-
-        return mColumnCount + 1;
-    }
-
-    @Override
-    public int getColumnIndex(String columnName) {
-        if ("_id".equals(columnName)) {
-            return getUniqueIdColumnIndex();
-        }
-
-        return super.getColumnIndexOrThrow(columnName);
-    }
-
-    @Override
-    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
-        if ("_id".equals(columnName)) {
-            return getUniqueIdColumnIndex();
-        }
-
-        return super.getColumnIndexOrThrow(columnName);
-    }
-
-    @Override
-    public long getLong(int columnIndex) {
-        if (columnIndex == getUniqueIdColumnIndex()) {
-            long id = getPerCursorId();
-            if (id > MAX_ID) {
-                throw new RuntimeException("Sorry, " + this.getClass().getName() +
-                        " can only handle '_id' values up to " + SHIFT + " bits.");
-            }
-
-            return (((long) mActiveCursorIndex) << SHIFT) + id;
-        }
-
-        return super.getLong(columnIndex);
-    }
-
-    protected int getUniqueIdColumnIndex() {
-        if (mColumnCount == -1) {
-            mColumnCount = super.getColumnCount();
-        }
-
-        return mColumnCount;
-    }
-
-    protected long getPerCursorId() {
-        if (mIdColumnIndex == -1) {
-            mIdColumnIndex = super.getColumnIndexOrThrow("_id");
-        }
-
-        return super.getLong(mIdColumnIndex);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java
index cb9e865dad8..a01c25477c1 100644
--- a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java
+++ b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java
@@ -1,6 +1,7 @@
 package com.fsck.k9.helper;
 
 
+import java.util.List;
 import java.util.regex.Pattern;
 
 import android.content.Context;
@@ -68,7 +69,7 @@ public CharSequence getRecipientDisplayNames(Address[] addresses) {
         return new SpannableStringBuilder(resourceProvider.contactDisplayNamePrefix()).append(recipients);
     }
 
-    public boolean toMe(Account account, Address[] toAddrs) {
+    public boolean toMe(Account account, List<Address> toAddrs) {
         for (Address address : toAddrs) {
             if (account.isAnIdentity(address)) {
                 return true;
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt b/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt
new file mode 100644
index 00000000000..775effd710f
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt
@@ -0,0 +1,53 @@
+package com.fsck.k9.mailstore
+
+import com.fsck.k9.mail.Flag
+
+internal class CacheAwareMessageMapper<T>(
+    private val cache: MessageListCache,
+    private val messageMapper: MessageMapper<T>
+) : MessageMapper<T?> {
+    override fun map(message: MessageDetailsAccessor): T? {
+        val messageId = message.id
+        val folderId = message.folderId
+
+        if (cache.isMessageHidden(messageId, folderId)) {
+            return null
+        }
+
+        val cachedMessage = CacheAwareMessageDetailsAccessor(cache, message)
+        return messageMapper.map(cachedMessage)
+    }
+}
+
+private class CacheAwareMessageDetailsAccessor(
+    private val cache: MessageListCache,
+    private val message: MessageDetailsAccessor
+) : MessageDetailsAccessor by message {
+    override val isRead: Boolean
+        get() {
+            return cache.getFlagForMessage(message.id, Flag.SEEN)
+                ?: cache.getFlagForThread(message.threadRoot, Flag.SEEN)
+                ?: message.isRead
+        }
+
+    override val isStarred: Boolean
+        get() {
+            return cache.getFlagForMessage(message.id, Flag.FLAGGED)
+                ?: cache.getFlagForThread(message.threadRoot, Flag.FLAGGED)
+                ?: message.isStarred
+        }
+
+    override val isAnswered: Boolean
+        get() {
+            return cache.getFlagForMessage(message.id, Flag.ANSWERED)
+                ?: cache.getFlagForThread(message.threadRoot, Flag.ANSWERED)
+                ?: message.isAnswered
+        }
+
+    override val isForwarded: Boolean
+        get() {
+            return cache.getFlagForMessage(message.id, Flag.FORWARDED)
+                ?: cache.getFlagForThread(message.threadRoot, Flag.FORWARDED)
+                ?: message.isForwarded
+        }
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java b/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java
index 40a262caaee..e0627bfb8b3 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java
@@ -2,6 +2,7 @@
 
 
 import com.fsck.k9.message.extractors.PreviewResult.PreviewType;
+import org.jetbrains.annotations.NotNull;
 
 
 public enum DatabasePreviewType {
@@ -20,6 +21,7 @@ public enum DatabasePreviewType {
         this.previewType = previewType;
     }
 
+    @NotNull
     public static DatabasePreviewType fromDatabaseValue(String databaseValue) {
         for (DatabasePreviewType databasePreviewType : values()) {
             if (databasePreviewType.getDatabaseValue().equals(databaseValue)) {
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt b/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt
new file mode 100644
index 00000000000..34dcf1ca00a
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt
@@ -0,0 +1,3 @@
+package com.fsck.k9.mailstore
+
+class FolderNotFoundException(val folderId: Long) : RuntimeException("Folder not found: $folderId")
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt
index 5f657ecaa41..8575c3bf55f 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt
@@ -34,4 +34,5 @@ val mailStoreModule = module {
             attachmentCounter = get()
         )
     }
+    single { MessageListRepository(messageStoreManager = get()) }
 }
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java
index 4e33ebfea2a..3f846a6c6c8 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java
@@ -18,12 +18,10 @@
 import java.util.Map;
 import java.util.Stack;
 
-import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
 import androidx.annotation.Nullable;
 import android.text.TextUtils;
 
@@ -51,8 +49,6 @@
 import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition;
 import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider;
 import com.fsck.k9.message.extractors.AttachmentInfoExtractor;
-import com.fsck.k9.provider.EmailProvider;
-import com.fsck.k9.provider.EmailProvider.MessageColumns;
 import com.fsck.k9.search.LocalSearch;
 import com.fsck.k9.search.SearchSpecification.Attribute;
 import com.fsck.k9.search.SearchSpecification.SearchField;
@@ -166,7 +162,6 @@ public class LocalStore {
     private static final int THREAD_FLAG_UPDATE_BATCH_SIZE = 500;
 
     private final Context context;
-    private final ContentResolver contentResolver;
     private final PendingCommandSerializer pendingCommandSerializer;
     private final AttachmentInfoExtractor attachmentInfoExtractor;
 
@@ -184,7 +179,6 @@ static LocalStore createInstance(Account account, Context context) throws Messag
      */
     private LocalStore(final Account account, final Context context) throws MessagingException {
         this.context = context;
-        this.contentResolver = context.getContentResolver();
 
         pendingCommandSerializer = PendingCommandSerializer.getInstance();
         attachmentInfoExtractor = DI.get(AttachmentInfoExtractor.class);
@@ -726,8 +720,8 @@ AttachmentInfoExtractor getAttachmentInfoExtractor() {
     }
 
     public void notifyChange() {
-        Uri uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + account.getUuid() + "/messages");
-        contentResolver.notifyChange(uri, null);
+        MessageListRepository messageListRepository = DI.get(MessageListRepository.class);
+        messageListRepository.notifyMessageListChanged(account.getUuid());
     }
 
     /**
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt
new file mode 100644
index 00000000000..a9af58a25aa
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt
@@ -0,0 +1,24 @@
+package com.fsck.k9.mailstore
+
+object MessageColumns {
+    const val ID = "id"
+    const val UID = "uid"
+    const val INTERNAL_DATE = "internal_date"
+    const val SUBJECT = "subject"
+    const val DATE = "date"
+    const val MESSAGE_ID = "message_id"
+    const val SENDER_LIST = "sender_list"
+    const val TO_LIST = "to_list"
+    const val CC_LIST = "cc_list"
+    const val BCC_LIST = "bcc_list"
+    const val REPLY_TO_LIST = "reply_to_list"
+    const val FLAGS = "flags"
+    const val ATTACHMENT_COUNT = "attachment_count"
+    const val FOLDER_ID = "folder_id"
+    const val PREVIEW_TYPE = "preview_type"
+    const val PREVIEW = "preview"
+    const val READ = "read"
+    const val FLAGGED = "flagged"
+    const val ANSWERED = "answered"
+    const val FORWARDED = "forwarded"
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt
new file mode 100644
index 00000000000..ed4034b8e02
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt
@@ -0,0 +1,131 @@
+package com.fsck.k9.mailstore
+
+import com.fsck.k9.DI
+import com.fsck.k9.mail.Flag
+import kotlin.collections.set
+
+typealias MessageId = Long
+typealias ThreadId = Long
+typealias FolderId = Long
+typealias FlagValue = Boolean
+typealias AccountUuid = String
+
+/**
+ * Cache to bridge the time needed to write (user-initiated) changes to the database.
+ */
+class MessageListCache private constructor(private val accountUuid: String) {
+    private val messageCache = mutableMapOf<MessageId, MutableMap<Flag, FlagValue>>()
+    private val threadCache = mutableMapOf<ThreadId, MutableMap<Flag, FlagValue>>()
+    private val hiddenMessageCache = mutableMapOf<MessageId, FolderId>()
+
+    fun getFlagForMessage(messageId: Long, flag: Flag): Boolean? {
+        synchronized(messageCache) {
+            val columnMap = messageCache[messageId]
+            return columnMap?.get(flag)
+        }
+    }
+
+    fun getFlagForThread(threadRootId: Long, flag: Flag): Boolean? {
+        synchronized(threadCache) {
+            val columnMap = threadCache[threadRootId]
+            return columnMap?.get(flag)
+        }
+    }
+
+    fun setFlagForMessages(messageIds: List<Long>, flag: Flag, value: Boolean) {
+        synchronized(messageCache) {
+            for (messageId in messageIds) {
+                val columnMap = messageCache.getOrPut(messageId) { mutableMapOf() }
+                columnMap[flag] = value
+            }
+        }
+
+        notifyChange()
+    }
+
+    fun setValueForThreads(threadRootIds: List<Long>, flag: Flag, value: Boolean) {
+        synchronized(threadCache) {
+            for (threadRootId in threadRootIds) {
+                val columnMap = threadCache.getOrPut(threadRootId) { mutableMapOf() }
+                columnMap[flag] = value
+            }
+        }
+
+        notifyChange()
+    }
+
+    fun removeFlagForMessages(messageIds: List<Long>, flag: Flag) {
+        synchronized(messageCache) {
+            for (messageId in messageIds) {
+                val columnMap = messageCache[messageId]
+                if (columnMap != null) {
+                    columnMap.remove(flag)
+                    if (columnMap.isEmpty()) {
+                        messageCache.remove(messageId)
+                    }
+                }
+            }
+        }
+    }
+
+    fun removeFlagForThreads(threadRootIds: List<Long>, flag: Flag) {
+        synchronized(threadCache) {
+            for (threadRootId in threadRootIds) {
+                val columnMap = threadCache[threadRootId]
+                if (columnMap != null) {
+                    columnMap.remove(flag)
+                    if (columnMap.isEmpty()) {
+                        threadCache.remove(threadRootId)
+                    }
+                }
+            }
+        }
+    }
+
+    fun hideMessages(messages: List<LocalMessage>) {
+        synchronized(hiddenMessageCache) {
+            for (message in messages) {
+                val messageId = message.databaseId
+                val folderId = message.folder.databaseId
+                hiddenMessageCache[messageId] = folderId
+            }
+        }
+
+        notifyChange()
+    }
+
+    fun isMessageHidden(messageId: Long, folderId: Long): Boolean {
+        synchronized(hiddenMessageCache) {
+            val hiddenInFolder = hiddenMessageCache[messageId]
+            return hiddenInFolder == folderId
+        }
+    }
+
+    fun unhideMessages(messages: List<LocalMessage>) {
+        synchronized(hiddenMessageCache) {
+            for (message in messages) {
+                val messageId = message.databaseId
+                val folderId = message.folder.databaseId
+                val hiddenInFolder = hiddenMessageCache[messageId]
+                if (hiddenInFolder == folderId) {
+                    hiddenMessageCache.remove(messageId)
+                }
+            }
+        }
+    }
+
+    private fun notifyChange() {
+        val messageListRepository = DI.get<MessageListRepository>()
+        messageListRepository.notifyMessageListChanged(accountUuid)
+    }
+
+    companion object {
+        private val instances = mutableMapOf<AccountUuid, MessageListCache>()
+
+        @JvmStatic
+        @Synchronized
+        fun getCache(accountUuid: String): MessageListCache {
+            return instances.getOrPut(accountUuid) { MessageListCache(accountUuid) }
+        }
+    }
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt
new file mode 100644
index 00000000000..cfbe04f75ef
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt
@@ -0,0 +1,80 @@
+package com.fsck.k9.mailstore
+
+import java.util.concurrent.CopyOnWriteArraySet
+
+class MessageListRepository(
+    private val messageStoreManager: MessageStoreManager
+) {
+    private val listeners = CopyOnWriteArraySet<Pair<String, MessageListChangedListener>>()
+
+    fun addListener(accountUuid: String, listener: MessageListChangedListener) {
+        listeners.add(accountUuid to listener)
+    }
+
+    fun removeListener(listener: MessageListChangedListener) {
+        val entries = listeners.filter { it.second == listener }.toSet()
+        listeners.removeAll(entries)
+    }
+
+    fun notifyMessageListChanged(accountUuid: String) {
+        for (listener in listeners) {
+            if (listener.first == accountUuid) {
+                listener.second.onMessageListChanged()
+            }
+        }
+    }
+
+    /**
+     * Retrieve list of messages from [MessageStore] but override values with data from [MessageListCache].
+     */
+    fun <T> getMessages(
+        accountUuid: String,
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T>
+    ): List<T> {
+        val messageStore = messageStoreManager.getMessageStore(accountUuid)
+        val cache = MessageListCache.getCache(accountUuid)
+
+        val mapper = CacheAwareMessageMapper(cache, messageMapper)
+        return messageStore.getMessages(selection, selectionArgs, sortOrder, mapper)
+    }
+
+    /**
+     * Retrieve threaded list of messages from [MessageStore] but override values with data from [MessageListCache].
+     */
+    fun <T> getThreadedMessages(
+        accountUuid: String,
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T>
+    ): List<T> {
+        val messageStore = messageStoreManager.getMessageStore(accountUuid)
+        val cache = MessageListCache.getCache(accountUuid)
+
+        val mapper = CacheAwareMessageMapper(cache, messageMapper)
+        return messageStore.getThreadedMessages(selection, selectionArgs, sortOrder, mapper)
+    }
+
+    /**
+     * Retrieve list of messages in a thread from [MessageStore] but override values with data from [MessageListCache].
+     */
+    fun <T> getThread(
+        accountUuid: String,
+        threadId: Long,
+        sortOrder: String,
+        messageMapper: MessageMapper<T>
+    ): List<T> {
+        val messageStore = messageStoreManager.getMessageStore(accountUuid)
+        val cache = MessageListCache.getCache(accountUuid)
+
+        val mapper = CacheAwareMessageMapper(cache, messageMapper)
+        return messageStore.getThread(threadId, sortOrder, mapper)
+    }
+}
+
+fun interface MessageListChangedListener {
+    fun onMessageListChanged()
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt
new file mode 100644
index 00000000000..a8c15d96c3a
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt
@@ -0,0 +1,28 @@
+package com.fsck.k9.mailstore
+
+import com.fsck.k9.mail.Address
+import com.fsck.k9.message.extractors.PreviewResult
+
+fun interface MessageMapper<T> {
+    fun map(message: MessageDetailsAccessor): T
+}
+
+interface MessageDetailsAccessor {
+    val id: Long
+    val messageServerId: String
+    val folderId: Long
+    val fromAddresses: List<Address>
+    val toAddresses: List<Address>
+    val ccAddresses: List<Address>
+    val messageDate: Long
+    val internalDate: Long
+    val subject: String?
+    val preview: PreviewResult
+    val isRead: Boolean
+    val isStarred: Boolean
+    val isAnswered: Boolean
+    val isForwarded: Boolean
+    val hasAttachments: Boolean
+    val threadRoot: Long
+    val threadCount: Int
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
index 9294ac736f5..7f85c5638c7 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
@@ -120,6 +120,31 @@ interface MessageStore {
      */
     fun getAllMessagesAndEffectiveDates(folderId: Long): Map<String, Long?>
 
+    /**
+     * Retrieve list of messages.
+     */
+    fun <T> getMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T?>
+    ): List<T>
+
+    /**
+     * Retrieve threaded list of messages.
+     */
+    fun <T> getThreadedMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T?>
+    ): List<T>
+
+    /**
+     * Retrieve list of messages in a thread.
+     */
+    fun <T> getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper<T?>): List<T>
+
     /**
      * Retrieve the date of the oldest message in the given folder.
      */
@@ -231,6 +256,11 @@ interface MessageStore {
      */
     fun setNotificationClass(folderId: Long, folderClass: FolderClass)
 
+    /**
+     * Get the 'more messages' state of a folder.
+     */
+    fun hasMoreMessages(folderId: Long): MoreMessages?
+
     /**
      * Update the 'more messages' state of a folder.
      */
diff --git a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java b/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java
deleted file mode 100644
index da70da78273..00000000000
--- a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java
+++ /dev/null
@@ -1,720 +0,0 @@
-package com.fsck.k9.provider;
-
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import android.content.ContentProvider;
-import android.content.ContentResolver;
-import android.content.ContentValues;
-import android.content.Context;
-import android.content.UriMatcher;
-import android.database.Cursor;
-import android.database.CursorWrapper;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-import android.text.TextUtils;
-
-import com.fsck.k9.Account;
-import com.fsck.k9.DI;
-import com.fsck.k9.Preferences;
-import com.fsck.k9.cache.EmailProviderCacheCursor;
-import com.fsck.k9.helper.Utility;
-import com.fsck.k9.mail.MessagingException;
-import com.fsck.k9.mailstore.LocalStore;
-import com.fsck.k9.mailstore.LocalStoreProvider;
-import com.fsck.k9.mailstore.LockableDatabase;
-import com.fsck.k9.mailstore.LockableDatabase.DbCallback;
-import com.fsck.k9.search.SqlQueryBuilder;
-
-
-/**
- * Content Provider used to display the message list etc.
- *
- * <p>
- * For now this content provider is for internal use only. In the future we may allow third-party
- * apps to access K-9 Mail content using this content provider.
- * </p>
- */
-/*
- * TODO:
- * - add support for account list and folder list
- */
-public class EmailProvider extends ContentProvider {
-    public static String AUTHORITY;
-    public static Uri CONTENT_URI;
-
-    public static Uri getNotificationUri(String accountUuid) {
-        return Uri.withAppendedPath(CONTENT_URI, "account/" + accountUuid + "/messages");
-    }
-
-    private UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
-
-
-    /*
-     * Constants that are used for the URI matching.
-     */
-    private static final int MESSAGE_BASE = 0;
-    private static final int MESSAGES = MESSAGE_BASE;
-    private static final int MESSAGES_THREADED = MESSAGE_BASE + 1;
-    private static final int MESSAGES_THREAD = MESSAGE_BASE + 2;
-
-
-    private static final String MESSAGES_TABLE = "messages";
-
-    private static final Map<String, String> THREAD_AGGREGATION_FUNCS = new HashMap<>();
-    static {
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.DATE, "MAX");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.INTERNAL_DATE, "MAX");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.ATTACHMENT_COUNT, "SUM");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.READ, "MIN");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.FLAGGED, "MAX");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.ANSWERED, "MIN");
-        THREAD_AGGREGATION_FUNCS.put(MessageColumns.FORWARDED, "MIN");
-    }
-
-    private static final String[] FIXUP_MESSAGES_COLUMNS = {
-            MessageColumns.ID
-    };
-
-    private static final String[] FIXUP_AGGREGATED_MESSAGES_COLUMNS = {
-            MessageColumns.DATE,
-            MessageColumns.INTERNAL_DATE,
-            MessageColumns.ATTACHMENT_COUNT,
-            MessageColumns.READ,
-            MessageColumns.FLAGGED,
-            MessageColumns.ANSWERED,
-            MessageColumns.FORWARDED
-    };
-
-    private static final String FOLDERS_TABLE = "folders";
-
-    private static final String[] FOLDERS_COLUMNS = {
-            FolderColumns.ID,
-            FolderColumns.NAME,
-            FolderColumns.LAST_UPDATED,
-            FolderColumns.UNREAD_COUNT,
-            FolderColumns.VISIBLE_LIMIT,
-            FolderColumns.STATUS,
-            FolderColumns.PUSH_STATE,
-            FolderColumns.LAST_PUSHED,
-            FolderColumns.FLAGGED_COUNT,
-            FolderColumns.INTEGRATE,
-            FolderColumns.TOP_GROUP,
-            FolderColumns.POLL_CLASS,
-            FolderColumns.PUSH_CLASS,
-            FolderColumns.DISPLAY_CLASS,
-            FolderColumns.SERVER_ID
-    };
-
-    private static final String THREADS_TABLE = "threads";
-
-    public interface SpecialColumns {
-        String ACCOUNT_UUID = "account_uuid";
-
-        String THREAD_COUNT = "thread_count";
-
-        String FOLDER_SERVER_ID = "server_id";
-        String INTEGRATE = "integrate";
-    }
-
-    public interface MessageColumns {
-        String ID = "id";
-        String UID = "uid";
-        String INTERNAL_DATE = "internal_date";
-        String SUBJECT = "subject";
-        String DATE = "date";
-        String MESSAGE_ID = "message_id";
-        String SENDER_LIST = "sender_list";
-        String TO_LIST = "to_list";
-        String CC_LIST = "cc_list";
-        String BCC_LIST = "bcc_list";
-        String REPLY_TO_LIST = "reply_to_list";
-        String FLAGS = "flags";
-        String ATTACHMENT_COUNT = "attachment_count";
-        String FOLDER_ID = "folder_id";
-        String PREVIEW_TYPE = "preview_type";
-        String PREVIEW = "preview";
-        String READ = "read";
-        String FLAGGED = "flagged";
-        String ANSWERED = "answered";
-        String FORWARDED = "forwarded";
-    }
-
-    private interface InternalMessageColumns extends MessageColumns {
-        String DELETED = "deleted";
-        String EMPTY = "empty";
-        String MIME_TYPE = "mime_type";
-    }
-
-    public interface FolderColumns {
-        String ID = "id";
-        String NAME = "name";
-        String LAST_UPDATED = "last_updated";
-        String UNREAD_COUNT = "unread_count";
-        String VISIBLE_LIMIT = "visible_limit";
-        String STATUS = "status";
-        String PUSH_STATE = "push_state";
-        String LAST_PUSHED = "last_pushed";
-        String FLAGGED_COUNT = "flagged_count";
-        String INTEGRATE = "integrate";
-        String TOP_GROUP = "top_group";
-        String POLL_CLASS = "poll_class";
-        String PUSH_CLASS = "push_class";
-        String DISPLAY_CLASS = "display_class";
-        String SERVER_ID = "server_id";
-    }
-
-    public interface ThreadColumns {
-        String ID = "id";
-        String MESSAGE_ID = "message_id";
-        String ROOT = "root";
-        String PARENT = "parent";
-    }
-
-    public interface StatsColumns {
-        String UNREAD_COUNT = "unread_count";
-        String FLAGGED_COUNT = "flagged_count";
-    }
-
-
-    private Preferences mPreferences;
-
-
-    @Override
-    public boolean onCreate() {
-        String packageName = getContext().getPackageName();
-        AUTHORITY = packageName + ".provider.email";
-        CONTENT_URI = Uri.parse("content://" + AUTHORITY);
-
-        uriMatcher.addURI(AUTHORITY, "account/*/messages", MESSAGES);
-        uriMatcher.addURI(AUTHORITY, "account/*/messages/threaded", MESSAGES_THREADED);
-        uriMatcher.addURI(AUTHORITY, "account/*/thread/#", MESSAGES_THREAD);
-
-        return true;
-    }
-
-    @Override
-    public String getType(Uri uri) {
-        throw new RuntimeException("not implemented yet");
-    }
-
-    @Override
-    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
-        int match = uriMatcher.match(uri);
-        if (match < 0) {
-            throw new IllegalArgumentException("Unknown URI: " + uri);
-        }
-
-        ContentResolver contentResolver = getContext().getContentResolver();
-        Cursor cursor = null;
-        switch (match) {
-            case MESSAGES:
-            case MESSAGES_THREADED:
-            case MESSAGES_THREAD: {
-                List<String> segments = uri.getPathSegments();
-                String accountUuid = segments.get(1);
-
-                List<String> dbColumnNames = new ArrayList<>(projection.length);
-                Map<String, String> specialColumns = new HashMap<>();
-                for (String columnName : projection) {
-                    if (SpecialColumns.ACCOUNT_UUID.equals(columnName)) {
-                        specialColumns.put(SpecialColumns.ACCOUNT_UUID, accountUuid);
-                    } else {
-                        dbColumnNames.add(columnName);
-                    }
-                }
-
-                String[] dbProjection = dbColumnNames.toArray(new String[0]);
-
-                if (match == MESSAGES) {
-                    cursor = getMessages(accountUuid, dbProjection, selection, selectionArgs, sortOrder);
-                } else if (match == MESSAGES_THREADED) {
-                    cursor = getThreadedMessages(accountUuid, dbProjection, selection, selectionArgs, sortOrder);
-                } else if (match == MESSAGES_THREAD) {
-                    String threadId = segments.get(3);
-                    cursor = getThread(accountUuid, dbProjection, threadId, sortOrder);
-                } else {
-                    throw new RuntimeException("Not implemented");
-                }
-
-                cursor.setNotificationUri(contentResolver, getNotificationUri(accountUuid));
-
-                cursor = new SpecialColumnsCursor(new IdTrickeryCursor(cursor), projection, specialColumns);
-                cursor = new EmailProviderCacheCursor(accountUuid, cursor, getContext());
-                break;
-            }
-        }
-
-        return cursor;
-    }
-
-    @Override
-    public int delete(Uri uri, String selection, String[] selectionArgs) {
-        throw new RuntimeException("not implemented yet");
-    }
-
-    @Override
-    public Uri insert(Uri uri, ContentValues values) {
-        throw new RuntimeException("not implemented yet");
-    }
-
-    @Override
-    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
-        throw new RuntimeException("not implemented yet");
-    }
-
-    protected Cursor getMessages(String accountUuid, final String[] projection, final String selection,
-            final String[] selectionArgs, final String sortOrder) {
-
-        Account account = getAccount(accountUuid);
-        LockableDatabase database = getDatabase(account);
-
-        try {
-            return database.execute(false, new DbCallback<Cursor>() {
-                @Override
-                public Cursor doDbWork(SQLiteDatabase db) {
-
-                    String where;
-                    if (TextUtils.isEmpty(selection)) {
-                        where = InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0";
-                    } else {
-                        where = "(" + selection + ") AND " +
-                                InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0";
-                    }
-
-                    final Cursor cursor;
-                    if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) {
-                        StringBuilder query = new StringBuilder();
-                        query.append("SELECT ");
-                        boolean first = true;
-                        for (String columnName : projection) {
-                            if (!first) {
-                                query.append(",");
-                            } else {
-                                first = false;
-                            }
-
-                            if (MessageColumns.ID.equals(columnName)) {
-                                query.append("m.");
-                                query.append(MessageColumns.ID);
-                                query.append(" AS ");
-                                query.append(MessageColumns.ID);
-                            } else {
-                                query.append(columnName);
-                            }
-                        }
-
-                        query.append(" FROM messages m " +
-                                "JOIN threads t ON (t.message_id = m.id) " +
-                                "LEFT JOIN folders f ON (m.folder_id = f.id) " +
-                                "WHERE ");
-                        query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", where));
-                        query.append(" ORDER BY ");
-                        query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", sortOrder));
-
-                        cursor = db.rawQuery(query.toString(), selectionArgs);
-                    } else {
-                        cursor = db.query(MESSAGES_TABLE, projection, where, selectionArgs, null, null, sortOrder);
-                    }
-
-                    return cursor;
-                }
-            });
-        } catch (MessagingException e) {
-            throw new RuntimeException("messaging exception", e);
-        }
-    }
-
-    protected Cursor getThreadedMessages(String accountUuid, final String[] projection, final String selection,
-            final String[] selectionArgs, final String sortOrder) {
-
-        Account account = getAccount(accountUuid);
-        LockableDatabase database = getDatabase(account);
-
-        try {
-            return database.execute(false, new DbCallback<Cursor>() {
-                @Override
-                public Cursor doDbWork(SQLiteDatabase db) {
-
-                    StringBuilder query = new StringBuilder();
-
-                    query.append("SELECT ");
-                    boolean first = true;
-                    for (String columnName : projection) {
-                        if (!first) {
-                            query.append(",");
-                        } else {
-                            first = false;
-                        }
-
-                        final String aggregationFunc = THREAD_AGGREGATION_FUNCS.get(columnName);
-
-                        if (MessageColumns.ID.equals(columnName)) {
-                            query.append("m." + MessageColumns.ID + " AS " + MessageColumns.ID);
-                        } else if (aggregationFunc != null) {
-                            query.append("a.");
-                            query.append(columnName);
-                            query.append(" AS ");
-                            query.append(columnName);
-                        } else {
-                            query.append(columnName);
-                        }
-                    }
-
-                    query.append(" FROM (");
-
-                    createThreadedSubQuery(projection, selection, query);
-
-                    query.append(") a ");
-
-                    query.append("JOIN " + THREADS_TABLE + " t " +
-                            "ON (t." + ThreadColumns.ROOT + " = a.thread_root) " +
-                            "JOIN " + MESSAGES_TABLE + " m " +
-                            "ON (m." + MessageColumns.ID + " = t." + ThreadColumns.MESSAGE_ID + " AND " +
-                            "m." + InternalMessageColumns.EMPTY + "=0 AND " +
-                            "m." + InternalMessageColumns.DELETED + "=0 AND " +
-                            "m." + MessageColumns.DATE + " = a." + MessageColumns.DATE +
-                            ") ");
-
-                    if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) {
-                        query.append("JOIN " + FOLDERS_TABLE + " f " +
-                                "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ") ");
-                    }
-
-                    query.append(" GROUP BY " + ThreadColumns.ROOT);
-
-                    if (!TextUtils.isEmpty(sortOrder)) {
-                        query.append(" ORDER BY ");
-                        query.append(SqlQueryBuilder.addPrefixToSelection(
-                                FIXUP_AGGREGATED_MESSAGES_COLUMNS, "a.", sortOrder));
-                    }
-
-                    return db.rawQuery(query.toString(), selectionArgs);
-                }
-            });
-        } catch (MessagingException e) {
-            throw new RuntimeException("messaging exception", e);
-        }
-    }
-
-    private void createThreadedSubQuery(String[] projection, String selection, StringBuilder query) {
-        query.append("SELECT t." + ThreadColumns.ROOT + " AS thread_root");
-        for (String columnName : projection) {
-            String aggregationFunc = THREAD_AGGREGATION_FUNCS.get(columnName);
-
-            if (SpecialColumns.THREAD_COUNT.equals(columnName)) {
-                query.append(",COUNT(t." + ThreadColumns.ROOT + ") AS " + SpecialColumns.THREAD_COUNT);
-            } else if (aggregationFunc != null) {
-                query.append(",");
-                query.append(aggregationFunc);
-                query.append("(");
-                query.append(columnName);
-                query.append(") AS ");
-                query.append(columnName);
-            } else {
-                // Skip
-            }
-        }
-
-        query.append(
-                " FROM " + MESSAGES_TABLE + " m " +
-                "JOIN " + THREADS_TABLE + " t " +
-                "ON (t." + ThreadColumns.MESSAGE_ID + " = m." + MessageColumns.ID + ")");
-
-        if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) {
-            query.append(" JOIN " + FOLDERS_TABLE + " f " +
-                    "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ")");
-        }
-
-        query.append(" WHERE (t." + ThreadColumns.ROOT + " IN (" +
-                "SELECT " + ThreadColumns.ROOT + " " +
-                "FROM " + MESSAGES_TABLE + " m " +
-                "JOIN " + THREADS_TABLE + " t " +
-                "ON (t." + ThreadColumns.MESSAGE_ID + " = m." + MessageColumns.ID + ") " +
-                "WHERE " +
-                "m." + InternalMessageColumns.EMPTY + " = 0 AND " +
-                "m." + InternalMessageColumns.DELETED + " = 0)");
-
-
-        if (!TextUtils.isEmpty(selection)) {
-            query.append(" AND (");
-            query.append(selection);
-            query.append(")");
-        }
-
-        query.append(
-                ") AND " +
-                InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0");
-
-        query.append(" GROUP BY t." + ThreadColumns.ROOT);
-    }
-
-    protected Cursor getThread(String accountUuid, final String[] projection, final String threadId,
-            final String sortOrder) {
-
-        Account account = getAccount(accountUuid);
-        LockableDatabase database = getDatabase(account);
-
-        try {
-            return database.execute(false, new DbCallback<Cursor>() {
-                @Override
-                public Cursor doDbWork(SQLiteDatabase db) {
-
-                    StringBuilder query = new StringBuilder();
-                    query.append("SELECT ");
-                    boolean first = true;
-                    for (String columnName : projection) {
-                        if (!first) {
-                            query.append(",");
-                        } else {
-                            first = false;
-                        }
-
-                        if (MessageColumns.ID.equals(columnName)) {
-                            query.append("m." + MessageColumns.ID + " AS " + MessageColumns.ID);
-                        } else {
-                            query.append(columnName);
-                        }
-                    }
-
-                    query.append(" FROM " + THREADS_TABLE + " t JOIN " + MESSAGES_TABLE + " m " +
-                            "ON (m." + MessageColumns.ID + " = t." + ThreadColumns.MESSAGE_ID + ") ");
-
-                    if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) {
-                        query.append("LEFT JOIN " + FOLDERS_TABLE + " f " +
-                                "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ") ");
-                    }
-
-                    query.append("WHERE " +
-                            ThreadColumns.ROOT + " = ? AND " +
-                            InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0");
-
-                    query.append(" ORDER BY ");
-                    query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", sortOrder));
-
-                    return db.rawQuery(query.toString(), new String[] { threadId });
-                }
-            });
-        } catch (MessagingException e) {
-            throw new RuntimeException("messaging exception", e);
-        }
-    }
-
-    private Account getAccount(String accountUuid) {
-        if (mPreferences == null) {
-            mPreferences = Preferences.getPreferences();
-        }
-
-        Account account = mPreferences.getAccount(accountUuid);
-
-        if (account == null) {
-            throw new IllegalArgumentException("Unknown account: " + accountUuid);
-        }
-
-        return account;
-    }
-
-    private LockableDatabase getDatabase(Account account) {
-        LocalStore localStore;
-        try {
-            localStore = DI.get(LocalStoreProvider.class).getInstance(account);
-        } catch (MessagingException e) {
-            throw new RuntimeException("Couldn't get LocalStore", e);
-        }
-
-        return localStore.getDatabase();
-    }
-
-    /**
-     * This class is needed to make {@link androidx.cursoradapter.widget.CursorAdapter} work with our database schema.
-     *
-     * <p>
-     * {@code CursorAdapter} requires a column named {@code "_id"} containing a stable id. We use
-     * the column name {@code "id"} as primary key in all our tables. So this {@link CursorWrapper}
-     * maps all queries for {@code "_id"} to {@code "id"}.
-     * </p><p>
-     * Please note that this only works for the returned {@code Cursor}. When querying the content
-     * provider you still need to use {@link MessageColumns#ID}.
-     * </p>
-     */
-    static class IdTrickeryCursor extends CursorWrapper {
-        public IdTrickeryCursor(Cursor cursor) {
-            super(cursor);
-        }
-
-        @Override
-        public int getColumnIndex(String columnName) {
-            if ("_id".equals(columnName)) {
-                return super.getColumnIndex("id");
-            }
-
-            return super.getColumnIndex(columnName);
-        }
-
-        @Override
-        public int getColumnIndexOrThrow(String columnName) {
-            if ("_id".equals(columnName)) {
-                return super.getColumnIndexOrThrow("id");
-            }
-
-            return super.getColumnIndexOrThrow(columnName);
-        }
-    }
-
-    static class SpecialColumnsCursor extends CursorWrapper {
-        private int[] mColumnMapping;
-        private String[] mSpecialColumnValues;
-        private String[] mColumnNames;
-
-        public SpecialColumnsCursor(Cursor cursor, String[] allColumnNames, Map<String, String> specialColumns) {
-            super(cursor);
-
-            mColumnNames = allColumnNames;
-            mColumnMapping = new int[allColumnNames.length];
-            mSpecialColumnValues = new String[specialColumns.size()];
-            for (int i = 0, columnIndex = 0, specialColumnCount = 0, len = allColumnNames.length; i < len; i++) {
-                String columnName = allColumnNames[i];
-
-                if (specialColumns.containsKey(columnName)) {
-                    // This is a special column name, so save the value in mSpecialColumnValues
-                    mSpecialColumnValues[specialColumnCount] = specialColumns.get(columnName);
-
-                    // Write the index into mSpecialColumnValues negated into mColumnMapping
-                    mColumnMapping[i] = -(specialColumnCount + 1);
-                    specialColumnCount++;
-                } else {
-                    mColumnMapping[i] = columnIndex++;
-                }
-            }
-        }
-
-        @Override
-        public byte[] getBlob(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getBlob(realColumnIndex);
-        }
-
-        @Override
-        public int getColumnCount() {
-            return mColumnMapping.length;
-        }
-
-        @Override
-        public int getColumnIndex(String columnName) {
-            for (int i = 0, len = mColumnNames.length; i < len; i++) {
-                if (mColumnNames[i].equals(columnName)) {
-                    return i;
-                }
-            }
-
-            return super.getColumnIndex(columnName);
-        }
-
-        @Override
-        public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
-            int index = getColumnIndex(columnName);
-            if (index == -1) {
-                throw new IllegalArgumentException("Unknown column name");
-            }
-
-            return index;
-        }
-
-        @Override
-        public String getColumnName(int columnIndex) {
-            return mColumnNames[columnIndex];
-        }
-
-        @Override
-        public String[] getColumnNames() {
-            return mColumnNames.clone();
-        }
-
-        @Override
-        public double getDouble(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getDouble(realColumnIndex);
-        }
-
-        @Override
-        public float getFloat(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getFloat(realColumnIndex);
-        }
-
-        @Override
-        public int getInt(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getInt(realColumnIndex);
-        }
-
-        @Override
-        public long getLong(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getLong(realColumnIndex);
-        }
-
-        @Override
-        public short getShort(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                throw new RuntimeException("Special column can only be retrieved as string.");
-            }
-
-            return super.getShort(realColumnIndex);
-        }
-
-        @Override
-        public String getString(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                return mSpecialColumnValues[-realColumnIndex - 1];
-            }
-
-            return super.getString(realColumnIndex);
-        }
-
-        @Override
-        public int getType(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                return FIELD_TYPE_STRING;
-            }
-
-            return super.getType(realColumnIndex);
-        }
-
-        @Override
-        public boolean isNull(int columnIndex) {
-            int realColumnIndex = mColumnMapping[columnIndex];
-            if (realColumnIndex < 0) {
-                return (mSpecialColumnValues[-realColumnIndex - 1] == null);
-            }
-
-            return super.isNull(realColumnIndex);
-        }
-    }
-}
diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml
index 3460da1c0d5..c2f32660fdb 100644
--- a/app/k9mail/src/main/AndroidManifest.xml
+++ b/app/k9mail/src/main/AndroidManifest.xml
@@ -401,11 +401,6 @@
             android:authorities="${applicationId}.messageprovider"
             android:exported="false" />
 
-        <provider
-            android:name=".provider.EmailProvider"
-            android:authorities="${applicationId}.provider.email"
-            android:exported="false"/>
-
         <provider
             android:name=".provider.DecryptedFileProvider"
             android:authorities="${applicationId}.decryptedfileprovider"
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
index 2f617fa593b..60b4f0ee763 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
@@ -9,6 +9,7 @@ import com.fsck.k9.mailstore.CreateFolderInfo
 import com.fsck.k9.mailstore.FolderDetails
 import com.fsck.k9.mailstore.FolderMapper
 import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MessageMapper
 import com.fsck.k9.mailstore.MessageStore
 import com.fsck.k9.mailstore.MoreMessages
 import com.fsck.k9.mailstore.SaveMessageData
@@ -35,6 +36,7 @@ class K9MessageStore(
     private val flagMessageOperations = FlagMessageOperations(database)
     private val updateMessageOperations = UpdateMessageOperations(database)
     private val retrieveMessageOperations = RetrieveMessageOperations(database)
+    private val retrieveMessageListOperations = RetrieveMessageListOperations(database)
     private val deleteMessageOperations = DeleteMessageOperations(database, attachmentFileManager)
     private val createFolderOperations = CreateFolderOperations(database)
     private val retrieveFolderOperations = RetrieveFolderOperations(database)
@@ -100,6 +102,28 @@ class K9MessageStore(
         return retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId)
     }
 
+    override fun <T> getMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T?>
+    ): List<T> {
+        return retrieveMessageListOperations.getMessages(selection, selectionArgs, sortOrder, messageMapper)
+    }
+
+    override fun <T> getThreadedMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        messageMapper: MessageMapper<T?>
+    ): List<T> {
+        return retrieveMessageListOperations.getThreadedMessages(selection, selectionArgs, sortOrder, messageMapper)
+    }
+
+    override fun <T> getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper<T?>): List<T> {
+        return retrieveMessageListOperations.getThread(threadId, sortOrder, messageMapper)
+    }
+
     override fun getOldestMessageDate(folderId: Long): Date? {
         return retrieveMessageOperations.getOldestMessageDate(folderId)
     }
@@ -184,6 +208,10 @@ class K9MessageStore(
         updateFolderOperations.setNotificationClass(folderId, folderClass)
     }
 
+    override fun hasMoreMessages(folderId: Long): MoreMessages {
+        return retrieveFolderOperations.hasMoreMessages(folderId)
+    }
+
     override fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
         updateFolderOperations.setMoreMessages(folderId, moreMessages)
     }
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt
index 7a68605927b..44bbba649c9 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt
@@ -8,6 +8,7 @@ import com.fsck.k9.mail.FolderClass
 import com.fsck.k9.mail.FolderType
 import com.fsck.k9.mailstore.FolderDetailsAccessor
 import com.fsck.k9.mailstore.FolderMapper
+import com.fsck.k9.mailstore.FolderNotFoundException
 import com.fsck.k9.mailstore.LockableDatabase
 import com.fsck.k9.mailstore.MoreMessages
 import com.fsck.k9.mailstore.toFolderType
@@ -157,6 +158,10 @@ internal class RetrieveFolderOperations(private val lockableDatabase: LockableDa
             }
         }
     }
+
+    fun hasMoreMessages(folderId: Long): MoreMessages {
+        return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId)
+    }
 }
 
 private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor {
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt
new file mode 100644
index 00000000000..3dff6cc5966
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt
@@ -0,0 +1,243 @@
+package com.fsck.k9.storage.messages
+
+import android.database.Cursor
+import com.fsck.k9.helper.map
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mailstore.DatabasePreviewType
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MessageDetailsAccessor
+import com.fsck.k9.mailstore.MessageMapper
+import com.fsck.k9.message.extractors.PreviewResult
+import com.fsck.k9.search.SqlQueryBuilder
+
+internal class RetrieveMessageListOperations(private val lockableDatabase: LockableDatabase) {
+
+    fun <T> getMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        mapper: MessageMapper<T?>
+    ): List<T> {
+        return lockableDatabase.execute(false) { database ->
+            database.rawQuery(
+                """
+                SELECT 
+                    messages.id AS id, 
+                    uid, 
+                    folder_id, 
+                    sender_list, 
+                    to_list, 
+                    cc_list, 
+                    date, 
+                    internal_date, 
+                    subject, 
+                    preview_type,
+                    preview, 
+                    read, 
+                    flagged, 
+                    answered, 
+                    forwarded, 
+                    attachment_count, 
+                    root
+                FROM messages
+                JOIN threads ON (threads.message_id = messages.id)
+                LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
+                WHERE
+                    ($selection)
+                    AND empty = 0 AND deleted = 0
+                ORDER BY $sortOrder
+                """.trimIndent(),
+                selectionArgs,
+            ).use { cursor ->
+                val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
+                buildList {
+                    while (cursor.moveToNext()) {
+                        val value = mapper.map(cursorMessageAccessor)
+                        if (value != null) {
+                            add(value)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    fun <T> getThreadedMessages(
+        selection: String,
+        selectionArgs: Array<String>,
+        sortOrder: String,
+        mapper: MessageMapper<T?>
+    ): List<T> {
+        val orderBy = SqlQueryBuilder.addPrefixToSelection(AGGREGATED_MESSAGES_COLUMNS, "aggregated.", sortOrder)
+
+        return lockableDatabase.execute(false) { database ->
+            database.rawQuery(
+                """
+                SELECT 
+                    messages.id AS id, 
+                    uid, 
+                    folder_id, 
+                    sender_list, 
+                    to_list, 
+                    cc_list, 
+                    aggregated.date AS date, 
+                    aggregated.internal_date AS internal_date, 
+                    subject, 
+                    preview_type,
+                    preview, 
+                    aggregated.read AS read, 
+                    aggregated.flagged AS flagged, 
+                    aggregated.answered AS answered, 
+                    aggregated.forwarded AS forwarded, 
+                    aggregated.attachment_count AS attachment_count, 
+                    root, 
+                    aggregated.thread_count AS thread_count
+                FROM (
+                    SELECT 
+                        threads.root AS thread_root,
+                        MAX(date) AS date,
+                        MAX(internal_date) AS internal_date,
+                        MIN(read) AS read,
+                        MAX(flagged) AS flagged,
+                        MIN(answered) AS answered,
+                        MIN(forwarded) AS forwarded,
+                        SUM(attachment_count) AS attachment_count,
+                        COUNT(threads.root) AS thread_count                        
+                    FROM messages
+                    JOIN threads ON (threads.message_id = messages.id)
+                    JOIN folders ON (folders.id = messages.folder_id)
+                    WHERE
+                        threads.root IN (
+                            SELECT threads.root 
+                            FROM messages
+                            JOIN threads ON (threads.message_id = messages.id)
+                            WHERE messages.empty = 0 AND messages.deleted = 0
+                        )
+                        AND ($selection)
+                        AND messages.empty = 0 AND messages.deleted = 0
+                    GROUP BY threads.root
+                ) aggregated
+                JOIN threads ON (threads.root = aggregated.thread_root)
+                JOIN messages ON (
+                    messages.id = threads.message_id
+                    AND messages.empty = 0 AND messages.deleted = 0
+                    AND messages.date = aggregated.date
+                )
+                JOIN folders ON (folders.id = messages.folder_id)
+                GROUP BY threads.root
+                ORDER BY $orderBy
+                """.trimIndent(),
+                selectionArgs,
+            ).use { cursor ->
+                val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = true)
+                buildList {
+                    while (cursor.moveToNext()) {
+                        val value = mapper.map(cursorMessageAccessor)
+                        if (value != null) {
+                            add(value)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    fun <T> getThread(threadId: Long, sortOrder: String, mapper: MessageMapper<T?>): List<T> {
+        return lockableDatabase.execute(false) { database ->
+            database.rawQuery(
+                """
+                SELECT 
+                    messages.id AS id, 
+                    uid, 
+                    folder_id, 
+                    sender_list, 
+                    to_list, 
+                    cc_list, 
+                    date, 
+                    internal_date, 
+                    subject, 
+                    preview_type,
+                    preview, 
+                    read, 
+                    flagged, 
+                    answered, 
+                    forwarded, 
+                    attachment_count, 
+                    root
+                FROM threads 
+                JOIN messages ON (messages.id = threads.message_id)
+                LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
+                WHERE
+                    root = ?
+                    AND empty = 0 AND deleted = 0
+                ORDER BY $sortOrder
+                """.trimIndent(),
+                arrayOf(threadId.toString()),
+            ).use { cursor ->
+                val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
+                buildList {
+                    while (cursor.moveToNext()) {
+                        val value = mapper.map(cursorMessageAccessor)
+                        if (value != null) {
+                            add(value)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+private class CursorMessageAccessor(val cursor: Cursor, val includesThreadCount: Boolean) : MessageDetailsAccessor {
+    override val id: Long
+        get() = cursor.getLong(0)
+    override val messageServerId: String
+        get() = cursor.getString(1)
+    override val folderId: Long
+        get() = cursor.getLong(2)
+    override val fromAddresses: List<Address>
+        get() = Address.unpack(cursor.getString(3)).toList()
+    override val toAddresses: List<Address>
+        get() = Address.unpack(cursor.getString(4)).toList()
+    override val ccAddresses: List<Address>
+        get() = Address.unpack(cursor.getString(5)).toList()
+    override val messageDate: Long
+        get() = cursor.getLong(6)
+    override val internalDate: Long
+        get() = cursor.getLong(7)
+    override val subject: String?
+        get() = cursor.getString(8)
+    override val preview: PreviewResult
+        get() {
+            return when (DatabasePreviewType.fromDatabaseValue(cursor.getString(9))) {
+                DatabasePreviewType.NONE -> PreviewResult.none()
+                DatabasePreviewType.TEXT -> PreviewResult.text(cursor.getString(10))
+                DatabasePreviewType.ENCRYPTED -> PreviewResult.encrypted()
+                DatabasePreviewType.ERROR -> PreviewResult.error()
+            }
+        }
+    override val isRead: Boolean
+        get() = cursor.getInt(11) == 1
+    override val isStarred: Boolean
+        get() = cursor.getInt(12) == 1
+    override val isAnswered: Boolean
+        get() = cursor.getInt(13) == 1
+    override val isForwarded: Boolean
+        get() = cursor.getInt(14) == 1
+    override val hasAttachments: Boolean
+        get() = cursor.getInt(15) > 0
+    override val threadRoot: Long
+        get() = cursor.getLong(16)
+    override val threadCount: Int
+        get() = if (includesThreadCount) cursor.getInt(17) else 0
+}
+
+private val AGGREGATED_MESSAGES_COLUMNS = arrayOf(
+    "date",
+    "internal_date",
+    "attachment_count",
+    "read",
+    "flagged",
+    "answered",
+    "forwarded"
+)
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java
deleted file mode 100644
index f38fa2d61eb..00000000000
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.fsck.k9.fragment;
-
-
-import java.util.Arrays;
-
-import com.fsck.k9.provider.EmailProvider.MessageColumns;
-import com.fsck.k9.provider.EmailProvider.SpecialColumns;
-import com.fsck.k9.provider.EmailProvider.ThreadColumns;
-
-
-public final class MLFProjectionInfo {
-
-    public static final String[] THREADED_PROJECTION = {
-            MessageColumns.ID,
-            MessageColumns.UID,
-            MessageColumns.INTERNAL_DATE,
-            MessageColumns.SUBJECT,
-            MessageColumns.DATE,
-            MessageColumns.SENDER_LIST,
-            MessageColumns.TO_LIST,
-            MessageColumns.CC_LIST,
-            MessageColumns.READ,
-            MessageColumns.FLAGGED,
-            MessageColumns.ANSWERED,
-            MessageColumns.FORWARDED,
-            MessageColumns.ATTACHMENT_COUNT,
-            MessageColumns.FOLDER_ID,
-            MessageColumns.PREVIEW_TYPE,
-            MessageColumns.PREVIEW,
-            ThreadColumns.ROOT,
-            SpecialColumns.ACCOUNT_UUID,
-            SpecialColumns.FOLDER_SERVER_ID,
-
-            SpecialColumns.THREAD_COUNT,
-    };
-
-    public static final int ID_COLUMN = 0;
-    public static final int UID_COLUMN = 1;
-    public static final int INTERNAL_DATE_COLUMN = 2;
-    public static final int SUBJECT_COLUMN = 3;
-    public static final int DATE_COLUMN = 4;
-    public static final int SENDER_LIST_COLUMN = 5;
-    public static final int TO_LIST_COLUMN = 6;
-    public static final int CC_LIST_COLUMN = 7;
-    public static final int READ_COLUMN = 8;
-    public static final int FLAGGED_COLUMN = 9;
-    public static final int ANSWERED_COLUMN = 10;
-    public static final int FORWARDED_COLUMN = 11;
-    public static final int ATTACHMENT_COUNT_COLUMN = 12;
-    public static final int FOLDER_ID_COLUMN = 13;
-    public static final int PREVIEW_TYPE_COLUMN = 14;
-    public static final int PREVIEW_COLUMN = 15;
-    public static final int THREAD_ROOT_COLUMN = 16;
-    public static final int ACCOUNT_UUID_COLUMN = 17;
-    public static final int FOLDER_SERVER_ID_COLUMN = 18;
-    public static final int THREAD_COUNT_COLUMN = 19;
-
-    public static final String[] PROJECTION = Arrays.copyOf(THREADED_PROJECTION,
-            THREAD_COUNT_COLUMN);
-}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt
index 468ac24c52a..e06af5b0ec1 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt
@@ -55,9 +55,12 @@ class MessageListAdapter internal constructor(
     var messages: List<MessageListItem> = emptyList()
         set(value) {
             field = value
+            messagesMap = value.associateBy { it.uniqueId }
             notifyDataSetChanged()
         }
 
+    private var messagesMap = emptyMap<Long, MessageListItem>()
+
     var activeMessage: MessageReference? = null
 
     var selected: Set<Long> = emptySet()
@@ -71,14 +74,14 @@ class MessageListAdapter internal constructor(
 
     private val flagClickListener = OnClickListener { view: View ->
         val messageViewHolder = view.tag as MessageViewHolder
-        val messageListItem = getItem(messageViewHolder.position)
+        val messageListItem = getItemById(messageViewHolder.uniqueId)
         listItemListener.onToggleMessageFlag(messageListItem)
     }
 
     private val contactPictureClickListener = OnClickListener { view: View ->
         val parentView = view.parent.parent as View
         val messageViewHolder = parentView.tag as MessageViewHolder
-        val messageListItem = getItem(messageViewHolder.position)
+        val messageListItem = getItemById(messageViewHolder.uniqueId)
         listItemListener.onToggleMessageSelection(messageListItem)
     }
 
@@ -96,6 +99,10 @@ class MessageListAdapter internal constructor(
 
     override fun getItem(position: Int): MessageListItem = messages[position]
 
+    private fun getItemById(uniqueId: Long): MessageListItem {
+        return messagesMap[uniqueId]!!
+    }
+
     override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
         val message = getItem(position)
         val view: View = convertView ?: newView(parent)
@@ -163,7 +170,7 @@ class MessageListAdapter internal constructor(
             if (appearance.stars) {
                 holder.flagged.isChecked = isStarred
             }
-            holder.position = position
+            holder.uniqueId = uniqueId
             if (appearance.showContactPicture && holder.contactPicture.isVisible) {
                 setContactPicture(holder.contactPicture, displayAddress)
             }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java
deleted file mode 100644
index 61d67806e41..00000000000
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java
+++ /dev/null
@@ -1,163 +0,0 @@
-package com.fsck.k9.fragment;
-
-import android.database.Cursor;
-
-import java.util.Comparator;
-import java.util.List;
-
-/**
- * A set of {@link Comparator} classes used for {@link Cursor} data comparison.
- */
-public class MessageListFragmentComparators {
-    /**
-     * Reverses the result of a {@link Comparator}.
-     *
-     * @param <T>
-     */
-    public static class ReverseComparator<T> implements Comparator<T> {
-        private Comparator<T> mDelegate;
-
-        /**
-         * @param delegate
-         *         Never {@code null}.
-         */
-        public ReverseComparator(final Comparator<T> delegate) {
-            mDelegate = delegate;
-        }
-
-        @Override
-        public int compare(final T object1, final T object2) {
-            // arg1 & 2 are mixed up, this is done on purpose
-            return mDelegate.compare(object2, object1);
-        }
-    }
-
-    /**
-     * Chains comparator to find a non-0 result.
-     *
-     * @param <T>
-     */
-    public static class ComparatorChain<T> implements Comparator<T> {
-        private List<Comparator<T>> mChain;
-
-        /**
-         * @param chain
-         *         Comparator chain. Never {@code null}.
-         */
-        public ComparatorChain(final List<Comparator<T>> chain) {
-            mChain = chain;
-        }
-
-        @Override
-        public int compare(T object1, T object2) {
-            int result = 0;
-            for (final Comparator<T> comparator : mChain) {
-                result = comparator.compare(object1, object2);
-                if (result != 0) {
-                    break;
-                }
-            }
-            return result;
-        }
-    }
-
-    public static class ReverseIdComparator implements Comparator<Cursor> {
-        private int mIdColumn = -1;
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            if (mIdColumn == -1) {
-                mIdColumn = cursor1.getColumnIndex("_id");
-            }
-            long o1Id = cursor1.getLong(mIdColumn);
-            long o2Id = cursor2.getLong(mIdColumn);
-            return (o1Id > o2Id) ? -1 : 1;
-        }
-    }
-
-    public static class AttachmentComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            int o1HasAttachment = (cursor1.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0) ? 0 : 1;
-            int o2HasAttachment = (cursor2.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0) ? 0 : 1;
-            return o1HasAttachment - o2HasAttachment;
-        }
-    }
-
-    public static class FlaggedComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            int o1IsFlagged = (cursor1.getInt(MLFProjectionInfo.FLAGGED_COLUMN) == 1) ? 0 : 1;
-            int o2IsFlagged = (cursor2.getInt(MLFProjectionInfo.FLAGGED_COLUMN) == 1) ? 0 : 1;
-            return o1IsFlagged - o2IsFlagged;
-        }
-    }
-
-    public static class UnreadComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            int o1IsUnread = cursor1.getInt(MLFProjectionInfo.READ_COLUMN);
-            int o2IsUnread = cursor2.getInt(MLFProjectionInfo.READ_COLUMN);
-            return o1IsUnread - o2IsUnread;
-        }
-    }
-
-    public static class DateComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            long o1Date = cursor1.getLong(MLFProjectionInfo.DATE_COLUMN);
-            long o2Date = cursor2.getLong(MLFProjectionInfo.DATE_COLUMN);
-            return Long.compare(o1Date, o2Date);
-        }
-    }
-
-    public static class ArrivalComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            long o1Date = cursor1.getLong(MLFProjectionInfo.INTERNAL_DATE_COLUMN);
-            long o2Date = cursor2.getLong(MLFProjectionInfo.INTERNAL_DATE_COLUMN);
-            return Long.compare(o1Date, o2Date);
-        }
-    }
-
-    public static class SubjectComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            String subject1 = cursor1.getString(MLFProjectionInfo.SUBJECT_COLUMN);
-            String subject2 = cursor2.getString(MLFProjectionInfo.SUBJECT_COLUMN);
-
-            if (subject1 == null) {
-                return (subject2 == null) ? 0 : -1;
-            } else if (subject2 == null) {
-                return 1;
-            }
-
-            return subject1.compareToIgnoreCase(subject2);
-        }
-    }
-
-    public static class SenderComparator implements Comparator<Cursor> {
-
-        @Override
-        public int compare(Cursor cursor1, Cursor cursor2) {
-            String sender1 = MlfUtils.getSenderAddressFromCursor(cursor1);
-            String sender2 = MlfUtils.getSenderAddressFromCursor(cursor2);
-
-            if (sender1 == null && sender2 == null) {
-                return 0;
-            } else if (sender1 == null) {
-                return 1;
-            } else if (sender2 == null) {
-                return -1;
-            } else {
-                return sender1.compareToIgnoreCase(sender2);
-            }
-        }
-    }
-}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt
index 9afd334390f..d6487ab9b55 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt
@@ -7,7 +7,7 @@ import android.widget.TextView
 import com.fsck.k9.ui.R
 
 class MessageViewHolder(view: View) {
-    var position = -1
+    var uniqueId: Long = -1L
 
     val selected: View = view.findViewById(R.id.selected)
     val contactPicture: ImageView = view.findViewById(R.id.contact_picture)
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java
index af429d8774d..eb35a43e3bf 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java
@@ -3,7 +3,6 @@
 
 import java.util.List;
 
-import android.database.Cursor;
 import android.text.TextUtils;
 
 import com.fsck.k9.Account;
@@ -11,14 +10,11 @@
 import com.fsck.k9.Preferences;
 import com.fsck.k9.controller.MessageReference;
 import com.fsck.k9.helper.Utility;
-import com.fsck.k9.mail.Address;
 import com.fsck.k9.mail.MessagingException;
 import com.fsck.k9.mailstore.LocalFolder;
 import com.fsck.k9.mailstore.LocalStore;
 import com.fsck.k9.mailstore.LocalStoreProvider;
 
-import static com.fsck.k9.fragment.MLFProjectionInfo.SENDER_LIST_COLUMN;
-
 
 public class MlfUtils {
 
@@ -35,12 +31,6 @@ static void setLastSelectedFolder(Preferences preferences, List<MessageReference
         account.setLastSelectedFolderId(folderId);
     }
 
-    static String getSenderAddressFromCursor(Cursor cursor) {
-        String fromList = cursor.getString(SENDER_LIST_COLUMN);
-        Address[] fromAddrs = Address.unpack(fromList);
-        return (fromAddrs.length > 0) ? fromAddrs[0].getAddress() : null;
-    }
-
     static String buildSubject(String subjectFromCursor, String emptySubject, int threadCount) {
         if (TextUtils.isEmpty(subjectFromCursor)) {
             return emptySubject;
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt
index cb25a0dc8c6..488e5c90f61 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt
@@ -1,17 +1,14 @@
 package com.fsck.k9.ui.account
 
-import android.content.ContentResolver
-import android.database.ContentObserver
-import android.os.Handler
-import android.os.Looper
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.asLiveData
 import com.fsck.k9.Account
 import com.fsck.k9.controller.MessageCounts
 import com.fsck.k9.controller.MessageCountsProvider
+import com.fsck.k9.mailstore.MessageListChangedListener
+import com.fsck.k9.mailstore.MessageListRepository
 import com.fsck.k9.preferences.AccountManager
-import com.fsck.k9.provider.EmailProvider
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.awaitClose
@@ -26,7 +23,7 @@ import kotlinx.coroutines.launch
 class AccountsViewModel(
     accountManager: AccountManager,
     private val messageCountsProvider: MessageCountsProvider,
-    private val contentResolver: ContentResolver
+    private val messageListRepository: MessageListRepository
 ) : ViewModel() {
     private val displayAccountFlow: Flow<List<DisplayAccount>> = accountManager.getAccountsFlow()
         .flatMapLatest { accounts ->
@@ -47,21 +44,17 @@ class AccountsViewModel(
 
     private fun getMessageCountsFlow(account: Account): Flow<MessageCounts> {
         return callbackFlow {
-            val notificationUri = EmailProvider.getNotificationUri(account.uuid)
-
             send(messageCountsProvider.getMessageCounts(account))
 
-            val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
-                override fun onChange(selfChange: Boolean) {
-                    launch {
-                        send(messageCountsProvider.getMessageCounts(account))
-                    }
+            val listener = MessageListChangedListener {
+                launch {
+                    send(messageCountsProvider.getMessageCounts(account))
                 }
             }
-            contentResolver.registerContentObserver(notificationUri, false, contentObserver)
+            messageListRepository.addListener(account.uuid, listener)
 
             awaitClose {
-                contentResolver.unregisterContentObserver(contentObserver)
+                messageListRepository.removeListener(listener)
             }
         }.flowOn(Dispatchers.IO)
     }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt
index f72738ab320..426350b5e32 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt
@@ -4,7 +4,9 @@ import org.koin.androidx.viewmodel.dsl.viewModel
 import org.koin.dsl.module
 
 val accountUiModule = module {
-    viewModel { AccountsViewModel(accountManager = get(), messageCountsProvider = get(), contentResolver = get()) }
+    viewModel {
+        AccountsViewModel(accountManager = get(), messageCountsProvider = get(), messageListRepository = get())
+    }
     factory { AccountImageLoader(accountFallbackImageProvider = get()) }
     factory { AccountFallbackImageProvider(context = get()) }
     factory { AccountImageModelLoaderFactory(contactPhotoLoader = get(), accountFallbackImageProvider = get()) }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
index c24478518c3..16be0694ae9 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
@@ -6,7 +6,15 @@ import org.koin.dsl.module
 val messageListUiModule = module {
     viewModel { MessageListViewModel(get()) }
     factory { DefaultFolderProvider() }
-    factory { MessageListExtractor(get(), get()) }
-    factory { MessageListLoader(get(), get(), get(), get()) }
-    factory { MessageListLiveDataFactory(get(), get(), get()) }
+    factory {
+        MessageListLoader(
+            preferences = get(),
+            localStoreProvider = get(),
+            messageListRepository = get(),
+            messageHelper = get()
+        )
+    }
+    factory {
+        MessageListLiveDataFactory(messageListLoader = get(), preferences = get(), messageListRepository = get())
+    }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt
deleted file mode 100644
index 0f241475af8..00000000000
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.fsck.k9.ui.messagelist
-
-import android.database.Cursor
-import com.fsck.k9.Preferences
-import com.fsck.k9.fragment.MLFProjectionInfo
-import com.fsck.k9.helper.MessageHelper
-import com.fsck.k9.helper.map
-import com.fsck.k9.mail.Address
-import com.fsck.k9.mailstore.DatabasePreviewType
-import com.fsck.k9.ui.helper.DisplayAddressHelper
-
-class MessageListExtractor(
-    private val preferences: Preferences,
-    private val messageHelper: MessageHelper
-) {
-    fun extractMessageList(cursor: Cursor, uniqueIdColumn: Int, threadCountIncluded: Boolean): List<MessageListItem> {
-        return cursor.map { extractMessageListItem(it, uniqueIdColumn, threadCountIncluded) }
-    }
-
-    private fun extractMessageListItem(
-        cursor: Cursor,
-        uniqueIdColumn: Int,
-        threadCountIncluded: Boolean
-    ): MessageListItem {
-        val position = cursor.position
-        val accountUuid = cursor.getString(MLFProjectionInfo.ACCOUNT_UUID_COLUMN)
-        val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found")
-
-        val fromList = cursor.getString(MLFProjectionInfo.SENDER_LIST_COLUMN)
-        val toList = cursor.getString(MLFProjectionInfo.TO_LIST_COLUMN)
-        val ccList = cursor.getString(MLFProjectionInfo.CC_LIST_COLUMN)
-        val fromAddresses = Address.unpack(fromList)
-        val toAddresses = Address.unpack(toList)
-        val ccAddresses = Address.unpack(ccList)
-        val toMe = messageHelper.toMe(account, toAddresses)
-        val ccMe = messageHelper.toMe(account, ccAddresses)
-        val messageDate = cursor.getLong(MLFProjectionInfo.DATE_COLUMN)
-        val threadCount = if (threadCountIncluded) cursor.getInt(MLFProjectionInfo.THREAD_COUNT_COLUMN) else 0
-        val subject = cursor.getString(MLFProjectionInfo.SUBJECT_COLUMN)
-        val isRead = cursor.getBoolean(MLFProjectionInfo.READ_COLUMN)
-        val isStarred = cursor.getBoolean(MLFProjectionInfo.FLAGGED_COLUMN)
-        val isAnswered = cursor.getBoolean(MLFProjectionInfo.ANSWERED_COLUMN)
-        val isForwarded = cursor.getBoolean(MLFProjectionInfo.FORWARDED_COLUMN)
-        val hasAttachments = cursor.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0
-        val previewTypeString = cursor.getString(MLFProjectionInfo.PREVIEW_TYPE_COLUMN)
-        val previewType = DatabasePreviewType.fromDatabaseValue(previewTypeString)
-        val isMessageEncrypted = previewType == DatabasePreviewType.ENCRYPTED
-        val previewText = getPreviewText(previewType, cursor)
-        val uniqueId = cursor.getLong(uniqueIdColumn)
-        val folderId = cursor.getLong(MLFProjectionInfo.FOLDER_ID_COLUMN)
-        val messageUid = cursor.getString(MLFProjectionInfo.UID_COLUMN)
-        val databaseId = cursor.getLong(MLFProjectionInfo.ID_COLUMN)
-        val threadRoot = cursor.getLong(MLFProjectionInfo.THREAD_ROOT_COLUMN)
-        val showRecipients = DisplayAddressHelper.shouldShowRecipients(account, folderId)
-        val displayAddress = if (showRecipients) toAddresses.firstOrNull() else fromAddresses.firstOrNull()
-        val displayName = if (showRecipients) {
-            messageHelper.getRecipientDisplayNames(toAddresses)
-        } else {
-            messageHelper.getSenderDisplayName(displayAddress)
-        }
-
-        return MessageListItem(
-            position,
-            account,
-            subject,
-            threadCount,
-            messageDate,
-            displayName,
-            displayAddress,
-            toMe,
-            ccMe,
-            previewText,
-            isMessageEncrypted,
-            isRead,
-            isStarred,
-            isAnswered,
-            isForwarded,
-            hasAttachments,
-            uniqueId,
-            folderId,
-            messageUid,
-            databaseId,
-            threadRoot
-        )
-    }
-
-    private fun getPreviewText(previewType: DatabasePreviewType?, cursor: Cursor): String {
-        return if (previewType == DatabasePreviewType.TEXT) {
-            cursor.getString(MLFProjectionInfo.PREVIEW_COLUMN) ?: ""
-        } else {
-            ""
-        }
-    }
-
-    private fun Cursor.getBoolean(columnIndex: Int): Boolean = getInt(columnIndex) == 1
-}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt
index 3797bea8a11..706225021d4 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt
@@ -4,11 +4,11 @@ import com.fsck.k9.Account
 import com.fsck.k9.mail.Address
 
 data class MessageListItem(
-    val position: Int,
     val account: Account,
     val subject: String?,
     val threadCount: Int,
     val messageDate: Long,
+    val internalDate: Long,
     val displayName: CharSequence,
     val displayAddress: Address?,
     val toMe: Boolean,
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt
new file mode 100644
index 00000000000..5840804cdd0
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt
@@ -0,0 +1,60 @@
+package com.fsck.k9.ui.messagelist
+
+import com.fsck.k9.Account
+import com.fsck.k9.helper.MessageHelper
+import com.fsck.k9.mailstore.MessageDetailsAccessor
+import com.fsck.k9.mailstore.MessageMapper
+import com.fsck.k9.message.extractors.PreviewResult.PreviewType
+import com.fsck.k9.ui.helper.DisplayAddressHelper
+
+class MessageListItemMapper(
+    private val messageHelper: MessageHelper,
+    private val account: Account
+) : MessageMapper<MessageListItem> {
+
+    override fun map(message: MessageDetailsAccessor): MessageListItem {
+        val fromAddresses = message.fromAddresses
+        val toAddresses = message.toAddresses
+        val toMe = messageHelper.toMe(account, toAddresses)
+        val ccMe = messageHelper.toMe(account, message.ccAddresses)
+        val previewResult = message.preview
+        val isMessageEncrypted = previewResult.previewType == PreviewType.ENCRYPTED
+        val previewText = if (previewResult.isPreviewTextAvailable) previewResult.previewText else ""
+        val uniqueId = createUniqueId(account, message.id)
+        val showRecipients = DisplayAddressHelper.shouldShowRecipients(account, message.folderId)
+        val displayAddress = if (showRecipients) toAddresses.firstOrNull() else fromAddresses.firstOrNull()
+        val displayName = if (showRecipients) {
+            messageHelper.getRecipientDisplayNames(toAddresses.toTypedArray())
+        } else {
+            messageHelper.getSenderDisplayName(displayAddress)
+        }
+
+        return MessageListItem(
+            account,
+            message.subject,
+            message.threadCount,
+            message.messageDate,
+            message.internalDate,
+            displayName,
+            displayAddress,
+            toMe,
+            ccMe,
+            previewText,
+            isMessageEncrypted,
+            message.isRead,
+            message.isStarred,
+            message.isAnswered,
+            message.isForwarded,
+            message.hasAttachments,
+            uniqueId,
+            message.folderId,
+            message.messageServerId,
+            message.id,
+            message.threadRoot
+        )
+    }
+
+    private fun createUniqueId(account: Account, messageId: Long): Long {
+        return ((account.accountNumber + 1).toLong() shl 52) + messageId
+    }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt
index aaf36a1b08d..bf757521d24 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt
@@ -1,12 +1,9 @@
 package com.fsck.k9.ui.messagelist
 
-import android.content.ContentResolver
-import android.database.ContentObserver
-import android.net.Uri
-import android.os.Handler
 import androidx.lifecycle.LiveData
 import com.fsck.k9.Preferences
-import com.fsck.k9.provider.EmailProvider
+import com.fsck.k9.mailstore.MessageListChangedListener
+import com.fsck.k9.mailstore.MessageListRepository
 import com.fsck.k9.search.getAccountUuids
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -16,53 +13,43 @@ import kotlinx.coroutines.withContext
 class MessageListLiveData(
     private val messageListLoader: MessageListLoader,
     private val preferences: Preferences,
-    private val contentResolver: ContentResolver,
+    private val messageListRepository: MessageListRepository,
     private val coroutineScope: CoroutineScope,
     val config: MessageListConfig
 ) : LiveData<MessageListInfo>() {
 
-    private val contentObserver = object : ContentObserver(Handler()) {
-        override fun onChange(selfChange: Boolean) {
-            loadMessageListAsync()
-        }
+    private val messageListChangedListener = MessageListChangedListener {
+        loadMessageListAsync()
     }
 
     private fun loadMessageListAsync() {
         coroutineScope.launch(Dispatchers.Main) {
-            value = withContext(Dispatchers.IO) {
+            val messageList = withContext(Dispatchers.IO) {
                 messageListLoader.getMessageList(config)
             }
+            value = messageList
         }
     }
 
     override fun onActive() {
         super.onActive()
 
-        registerContentObserverAsync()
+        registerMessageListChangedListenerAsync()
         loadMessageListAsync()
     }
 
     override fun onInactive() {
         super.onInactive()
-        contentResolver.unregisterContentObserver(contentObserver)
+        messageListRepository.removeListener(messageListChangedListener)
     }
 
-    private fun registerContentObserverAsync() {
-        coroutineScope.launch(Dispatchers.Main) {
-            val notificationUris = withContext(Dispatchers.IO) {
-                getNotificationUris()
-            }
+    private fun registerMessageListChangedListenerAsync() {
+        coroutineScope.launch(Dispatchers.IO) {
+            val accountUuids = config.search.getAccountUuids(preferences)
 
-            for (notificationUri in notificationUris) {
-                contentResolver.registerContentObserver(notificationUri, false, contentObserver)
+            for (accountUuid in accountUuids) {
+                messageListRepository.addListener(accountUuid, messageListChangedListener)
             }
         }
     }
-
-    private fun getNotificationUris(): List<Uri> {
-        val accountUuids = config.search.getAccountUuids(preferences)
-        return accountUuids.map { accountUuid ->
-            EmailProvider.getNotificationUri(accountUuid)
-        }
-    }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt
index 315a0199c4a..af995910f10 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt
@@ -1,15 +1,15 @@
 package com.fsck.k9.ui.messagelist
 
-import android.content.ContentResolver
 import com.fsck.k9.Preferences
+import com.fsck.k9.mailstore.MessageListRepository
 import kotlinx.coroutines.CoroutineScope
 
 class MessageListLiveDataFactory(
     private val messageListLoader: MessageListLoader,
     private val preferences: Preferences,
-    private val contentResolver: ContentResolver
+    private val messageListRepository: MessageListRepository
 ) {
     fun create(coroutineScope: CoroutineScope, config: MessageListConfig): MessageListLiveData {
-        return MessageListLiveData(messageListLoader, preferences, contentResolver, coroutineScope, config)
+        return MessageListLiveData(messageListLoader, preferences, messageListRepository, coroutineScope, config)
     }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt
index 7a5b6806b63..59dcdd2b3ca 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt
@@ -1,42 +1,23 @@
 package com.fsck.k9.ui.messagelist
 
-import android.annotation.SuppressLint
-import android.content.ContentResolver
-import android.database.Cursor
-import android.database.MatrixCursor
-import android.database.sqlite.SQLiteException
-import android.net.Uri
 import com.fsck.k9.Account
 import com.fsck.k9.Account.SortType
-import com.fsck.k9.K9
 import com.fsck.k9.Preferences
-import com.fsck.k9.fragment.MLFProjectionInfo
-import com.fsck.k9.fragment.MessageListFragmentComparators.ArrivalComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.AttachmentComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.ComparatorChain
-import com.fsck.k9.fragment.MessageListFragmentComparators.DateComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.FlaggedComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.ReverseComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.ReverseIdComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.SenderComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.SubjectComparator
-import com.fsck.k9.fragment.MessageListFragmentComparators.UnreadComparator
-import com.fsck.k9.helper.MergeCursorWithUniqueId
+import com.fsck.k9.helper.MessageHelper
 import com.fsck.k9.mailstore.LocalStoreProvider
-import com.fsck.k9.provider.EmailProvider
+import com.fsck.k9.mailstore.MessageColumns
+import com.fsck.k9.mailstore.MessageListRepository
 import com.fsck.k9.search.LocalSearch
 import com.fsck.k9.search.SearchSpecification.SearchField
 import com.fsck.k9.search.SqlQueryBuilder
 import com.fsck.k9.search.getAccounts
-import java.util.ArrayList
-import java.util.Comparator
 import timber.log.Timber
 
 class MessageListLoader(
     private val preferences: Preferences,
-    private val contentResolver: ContentResolver,
     private val localStoreProvider: LocalStoreProvider,
-    private val messageListExtractor: MessageListExtractor
+    private val messageListRepository: MessageListRepository,
+    private val messageHelper: MessageHelper
 ) {
 
     fun getMessageList(config: MessageListConfig): MessageListInfo {
@@ -52,113 +33,76 @@ class MessageListLoader(
 
     private fun getMessageListInfo(config: MessageListConfig): MessageListInfo {
         val accounts = config.search.getAccounts(preferences)
-        val cursors = accounts
-            .mapNotNull { loadMessageListForAccount(it, config) }
-            .toTypedArray()
-
-        if (cursors.isEmpty()) {
-            Timber.w("Couldn't get message list")
-            return MessageListInfo(messageListItems = emptyList(), hasMoreMessages = false)
-        }
-
-        val cursor: Cursor
-        val uniqueIdColumn: Int
-        if (cursors.size == 1) {
-            cursor = cursors[0]
-            uniqueIdColumn = MLFProjectionInfo.ID_COLUMN
-        } else {
-            cursor = MergeCursorWithUniqueId(cursors, getComparator(config))
-            uniqueIdColumn = cursor.getColumnIndex("_id")
-        }
+        val messageListItems = accounts
+            .flatMap { account ->
+                loadMessageListForAccount(account, config)
+            }
+            .sortedWith(config)
 
-        val messageListItems = cursor.use {
-            messageListExtractor.extractMessageList(
-                cursor,
-                uniqueIdColumn,
-                threadCountIncluded = config.showingThreadedList
-            )
-        }
         val hasMoreMessages = loadHasMoreMessages(accounts, config.search.folderIds)
 
         return MessageListInfo(messageListItems, hasMoreMessages)
     }
 
-    @SuppressLint("Recycle")
-    private fun loadMessageListForAccount(account: Account, config: MessageListConfig): Cursor? {
+    private fun loadMessageListForAccount(account: Account, config: MessageListConfig): List<MessageListItem> {
         val accountUuid = account.uuid
-        val threadId: String? = getThreadId(config.search)
+        val threadId = getThreadId(config.search)
+        val sortOrder = buildSortOrder(config)
+        val mapper = MessageListItemMapper(messageHelper, account)
 
-        val uri: Uri
-        val projection: Array<String>
-        val needConditions: Boolean
-        when {
+        return when {
             threadId != null -> {
-                uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/thread/$threadId")
-                projection = MLFProjectionInfo.PROJECTION
-                needConditions = false
+                messageListRepository.getThread(accountUuid, threadId, sortOrder, mapper)
             }
             config.showingThreadedList -> {
-                uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/messages/threaded")
-                projection = MLFProjectionInfo.THREADED_PROJECTION
-                needConditions = true
+                val (selection, selectionArgs) = buildSelection(account, config)
+                messageListRepository.getThreadedMessages(accountUuid, selection, selectionArgs, sortOrder, mapper)
             }
             else -> {
-                uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/messages")
-                projection = MLFProjectionInfo.PROJECTION
-                needConditions = true
+                val (selection, selectionArgs) = buildSelection(account, config)
+                messageListRepository.getMessages(accountUuid, selection, selectionArgs, sortOrder, mapper)
             }
         }
+    }
 
+    private fun buildSelection(account: Account, config: MessageListConfig): Pair<String, Array<String>> {
         val query = StringBuilder()
-        val queryArgs: MutableList<String> = ArrayList()
-        if (needConditions) {
-            val activeMessage = config.activeMessage
-            val selectActive = activeMessage != null && activeMessage.accountUuid == accountUuid
-            if (selectActive && activeMessage != null) {
-                query.append("(${EmailProvider.MessageColumns.UID} = ? AND ${EmailProvider.MessageColumns.FOLDER_ID} = ?) OR (")
-                queryArgs.add(activeMessage.uid)
-                queryArgs.add(activeMessage.folderId.toString())
-            }
+        val queryArgs = mutableListOf<String>()
+
+        val activeMessage = config.activeMessage
+        val selectActive = activeMessage != null && activeMessage.accountUuid == account.uuid
+        if (selectActive && activeMessage != null) {
+            query.append("(${MessageColumns.UID} = ? AND ${MessageColumns.FOLDER_ID} = ?) OR (")
+            queryArgs.add(activeMessage.uid)
+            queryArgs.add(activeMessage.folderId.toString())
+        }
 
-            SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs)
+        SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs)
 
-            if (selectActive) {
-                query.append(')')
-            }
+        if (selectActive) {
+            query.append(')')
         }
 
         val selection = query.toString()
         val selectionArgs = queryArgs.toTypedArray()
 
-        val sortOrder: String = buildSortOrder(config)
-
-        return try {
-            contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)
-        } catch (e: SQLiteException) {
-            Timber.e(e, "Error querying EmailProvider")
-
-            if (K9.DEVELOPER_MODE && e.message?.contains("malformed MATCH expression") == false) {
-                throw e
-            }
-
-            return MatrixCursor(projection)
-        }
+        return selection to selectionArgs
     }
 
-    private fun getThreadId(search: LocalSearch): String? {
-        return search.leafSet.firstOrNull { it.condition.field == SearchField.THREAD_ID }?.condition?.value
+    private fun getThreadId(search: LocalSearch): Long? {
+        return search.leafSet.firstOrNull { it.condition.field == SearchField.THREAD_ID }?.condition?.value?.toLong()
     }
 
     private fun buildSortOrder(config: MessageListConfig): String {
         val sortColumn = when (config.sortType) {
-            SortType.SORT_ARRIVAL -> EmailProvider.MessageColumns.INTERNAL_DATE
-            SortType.SORT_ATTACHMENT -> "(${EmailProvider.MessageColumns.ATTACHMENT_COUNT} < 1)"
-            SortType.SORT_FLAGGED -> "(${EmailProvider.MessageColumns.FLAGGED} != 1)"
-            SortType.SORT_SENDER -> EmailProvider.MessageColumns.SENDER_LIST // FIXME
-            SortType.SORT_SUBJECT -> "${EmailProvider.MessageColumns.SUBJECT} COLLATE NOCASE"
-            SortType.SORT_UNREAD -> EmailProvider.MessageColumns.READ
-            SortType.SORT_DATE -> EmailProvider.MessageColumns.DATE
-            else -> EmailProvider.MessageColumns.DATE
+            SortType.SORT_ARRIVAL -> MessageColumns.INTERNAL_DATE
+            SortType.SORT_ATTACHMENT -> "(${MessageColumns.ATTACHMENT_COUNT} < 1)"
+            SortType.SORT_FLAGGED -> "(${MessageColumns.FLAGGED} != 1)"
+            SortType.SORT_SENDER -> MessageColumns.SENDER_LIST // FIXME
+            SortType.SORT_SUBJECT -> "${MessageColumns.SUBJECT} COLLATE NOCASE"
+            SortType.SORT_UNREAD -> MessageColumns.READ
+            SortType.SORT_DATE -> MessageColumns.DATE
+            else -> MessageColumns.DATE
         }
 
         val sortDirection = if (config.sortAscending) " ASC" else " DESC"
@@ -166,41 +110,46 @@ class MessageListLoader(
             ""
         } else {
             if (config.sortDateAscending) {
-                "${EmailProvider.MessageColumns.DATE} ASC, "
+                "${MessageColumns.DATE} ASC, "
             } else {
-                "${EmailProvider.MessageColumns.DATE} DESC, "
+                "${MessageColumns.DATE} DESC, "
             }
         }
 
-        return "$sortColumn$sortDirection, $secondarySort${EmailProvider.MessageColumns.ID} DESC"
+        return "$sortColumn$sortDirection, $secondarySort${MessageColumns.ID} DESC"
     }
 
-    private fun getComparator(config: MessageListConfig): Comparator<Cursor>? {
-        val chain: MutableList<Comparator<Cursor>> = ArrayList(3 /* we add 3 comparators at most */)
-
-        // Add the specified comparator
-        val comparator = SORT_COMPARATORS.getValue(config.sortType)
-        if (config.sortAscending) {
-            chain.add(comparator)
-        } else {
-            chain.add(ReverseComparator(comparator))
-        }
-
-        // Add the date comparator if not already specified
-        if (config.sortType != SortType.SORT_DATE && config.sortType != SortType.SORT_ARRIVAL) {
-            val dateComparator = SORT_COMPARATORS.getValue(SortType.SORT_DATE)
-            if (config.sortDateAscending) {
-                chain.add(dateComparator)
-            } else {
-                chain.add(ReverseComparator(dateComparator))
+    private fun List<MessageListItem>.sortedWith(config: MessageListConfig): List<MessageListItem> {
+        val comparator = when (config.sortType) {
+            SortType.SORT_DATE -> {
+                compareBy(config.sortAscending) { it.messageDate }
             }
-        }
-
-        // Add the id comparator
-        chain.add(ReverseIdComparator())
+            SortType.SORT_ARRIVAL -> {
+                compareBy(config.sortAscending) { it.internalDate }
+            }
+            SortType.SORT_SUBJECT -> {
+                compareStringBy<MessageListItem>(config.sortAscending) { it.subject.orEmpty() }
+                    .thenByDate(config)
+            }
+            SortType.SORT_SENDER -> {
+                compareStringBy<MessageListItem>(config.sortAscending) { it.displayName.toString() }
+                    .thenByDate(config)
+            }
+            SortType.SORT_UNREAD -> {
+                compareBy<MessageListItem>(config.sortAscending) { it.isRead }
+                    .thenByDate(config)
+            }
+            SortType.SORT_FLAGGED -> {
+                compareBy<MessageListItem>(!config.sortAscending) { it.isStarred }
+                    .thenByDate(config)
+            }
+            SortType.SORT_ATTACHMENT -> {
+                compareBy<MessageListItem>(!config.sortAscending) { it.hasAttachments }
+                    .thenByDate(config)
+            }
+        }.thenByDescending { it.databaseId }
 
-        // Build the comparator chain
-        return ComparatorChain(chain)
+        return this.sortedWith(comparator)
     }
 
     private fun loadHasMoreMessages(accounts: List<Account>, folderIds: List<Long>): Boolean {
@@ -215,17 +164,29 @@ class MessageListLoader(
             false
         }
     }
+}
+
+private inline fun <T> compareBy(sortAscending: Boolean, crossinline selector: (T) -> Comparable<*>?): Comparator<T> {
+    return if (sortAscending) {
+        compareBy(selector)
+    } else {
+        compareByDescending(selector)
+    }
+}
+
+private inline fun <T> compareStringBy(sortAscending: Boolean, crossinline selector: (T) -> String): Comparator<T> {
+    return if (sortAscending) {
+        compareBy(String.CASE_INSENSITIVE_ORDER, selector)
+    } else {
+        compareByDescending(String.CASE_INSENSITIVE_ORDER, selector)
+    }
+}
 
-    companion object {
-        private val SORT_COMPARATORS = mapOf(
-            SortType.SORT_ATTACHMENT to AttachmentComparator(),
-            SortType.SORT_DATE to DateComparator(),
-            SortType.SORT_ARRIVAL to ArrivalComparator(),
-            SortType.SORT_FLAGGED to FlaggedComparator(),
-            SortType.SORT_SUBJECT to SubjectComparator(),
-            SortType.SORT_SENDER to SenderComparator(),
-            SortType.SORT_UNREAD to UnreadComparator()
-        )
+private fun Comparator<MessageListItem>.thenByDate(config: MessageListConfig): Comparator<MessageListItem> {
+    return if (config.sortDateAscending) {
+        thenBy { it.messageDate }
+    } else {
+        thenByDescending { it.messageDate }
     }
 }
 


Test patch:
diff --git a/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java b/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java
deleted file mode 100644
index e673c332b31..00000000000
--- a/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java
+++ /dev/null
@@ -1,145 +0,0 @@
-package com.fsck.k9.cache;
-
-
-import java.util.Collections;
-import java.util.UUID;
-
-import android.content.Context;
-import android.net.Uri;
-
-import com.fsck.k9.RobolectricTest;
-import com.fsck.k9.mailstore.LocalFolder;
-import com.fsck.k9.mailstore.LocalMessage;
-import com.fsck.k9.provider.EmailProvider;
-import org.junit.Before;
-import org.junit.Test;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-import org.robolectric.RuntimeEnvironment;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.when;
-
-
-public class EmailProviderCacheTest extends RobolectricTest {
-    private final Context context = RuntimeEnvironment.getApplication();
-
-    private EmailProviderCache cache;
-    @Mock
-    private LocalMessage mockLocalMessage;
-    @Mock
-    private LocalFolder mockLocalMessageFolder;
-    private Long localMessageId = 1L;
-    private Long localMessageFolderId = 2L;
-
-    @Before
-    public void before() {
-        MockitoAnnotations.initMocks(this);
-        EmailProvider.CONTENT_URI = Uri.parse("content://test.provider.email");
-
-        cache = EmailProviderCache.getCache(UUID.randomUUID().toString(), context);
-        when(mockLocalMessage.getDatabaseId()).thenReturn(localMessageId);
-        when(mockLocalMessage.getFolder()).thenReturn(mockLocalMessageFolder);
-        when(mockLocalMessageFolder.getDatabaseId()).thenReturn(localMessageFolderId);
-    }
-
-    @Test
-    public void getCache_returnsDifferentCacheForEachUUID() {
-        EmailProviderCache cache = EmailProviderCache.getCache("u001", context);
-        EmailProviderCache cache2 = EmailProviderCache.getCache("u002", context);
-
-        assertNotEquals(cache, cache2);
-    }
-
-    @Test
-    public void getCache_returnsSameCacheForAUUID() {
-        EmailProviderCache cache = EmailProviderCache.getCache("u001", context);
-        EmailProviderCache cache2 = EmailProviderCache.getCache("u001", context);
-
-        assertSame(cache, cache2);
-    }
-
-    @Test
-    public void getValueForMessage_returnsValueSetForMessage() {
-        cache.setValueForMessages(Collections.singletonList(1L), "subject", "Subject");
-
-        String result = cache.getValueForMessage(1L, "subject");
-
-        assertEquals("Subject", result);
-    }
-
-    @Test
-    public void getValueForUnknownMessage_returnsNull() {
-        String result = cache.getValueForMessage(1L, "subject");
-
-        assertNull(result);
-    }
-
-    @Test
-    public void getValueForUnknownMessage_returnsNullWhenRemoved() {
-        cache.setValueForMessages(Collections.singletonList(1L), "subject", "Subject");
-        cache.removeValueForMessages(Collections.singletonList(1L), "subject");
-
-        String result = cache.getValueForMessage(1L, "subject");
-
-        assertNull(result);
-    }
-
-    @Test
-    public void getValueForThread_returnsValueSetForThread() {
-        cache.setValueForThreads(Collections.singletonList(1L), "subject", "Subject");
-
-        String result = cache.getValueForThread(1L, "subject");
-
-        assertEquals("Subject", result);
-    }
-
-    @Test
-    public void getValueForUnknownThread_returnsNull() {
-        String result = cache.getValueForThread(1L, "subject");
-
-        assertNull(result);
-    }
-
-    @Test
-    public void getValueForUnknownThread_returnsNullWhenRemoved() {
-        cache.setValueForThreads(Collections.singletonList(1L), "subject", "Subject");
-        cache.removeValueForThreads(Collections.singletonList(1L), "subject");
-
-        String result = cache.getValueForThread(1L, "subject");
-
-        assertNull(result);
-    }
-
-    @Test
-    public void isMessageHidden_returnsTrueForHiddenMessage() {
-        cache.hideMessages(Collections.singletonList(mockLocalMessage));
-
-        boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId);
-
-        assertTrue(result);
-    }
-
-    @Test
-    public void isMessageHidden_returnsFalseForUnknownMessage() {
-        boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId);
-
-        assertFalse(result);
-    }
-
-    @Test
-    public void isMessageHidden_returnsFalseForUnhidenMessage() {
-        cache.hideMessages(Collections.singletonList(mockLocalMessage));
-        cache.unhideMessages(Collections.singletonList(mockLocalMessage));
-
-        boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId);
-
-        assertFalse(result);
-    }
-
-}
diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt
index 49bc6fc81dc..ba1488fb017 100644
--- a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt
+++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt
@@ -1,7 +1,6 @@
 package com.fsck.k9.mailstore
 
 import android.database.sqlite.SQLiteDatabase
-import android.net.Uri
 import androidx.core.content.contentValuesOf
 import com.fsck.k9.Account
 import com.fsck.k9.K9RobolectricTest
@@ -17,12 +16,10 @@ import com.fsck.k9.mail.MessageDownloadState
 import com.fsck.k9.mail.internet.MimeMessage
 import com.fsck.k9.mail.internet.MimeMessageHelper
 import com.fsck.k9.mail.internet.TextBody
-import com.fsck.k9.provider.EmailProvider
 import org.junit.After
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
-import org.junit.Before
 import org.junit.Test
 import org.koin.core.component.inject
 
@@ -36,12 +33,6 @@ class K9BackendFolderTest : K9RobolectricTest() {
     val backendFolder = createBackendFolder()
     val database: LockableDatabase = localStoreProvider.getInstance(account).database
 
-    @Before
-    fun setUp() {
-        // Set EmailProvider.CONTENT_URI so LocalStore.notifyChange() won't crash
-        EmailProvider.CONTENT_URI = Uri.parse("content://dummy")
-    }
-
     @After
     fun tearDown() {
         preferences.deleteAccount(account)
diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt
index 780ceb9cf87..ed0009a78eb 100644
--- a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt
+++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt
@@ -1,15 +1,12 @@
 package com.fsck.k9.mailstore
 
-import android.net.Uri
 import com.fsck.k9.Account
 import com.fsck.k9.K9RobolectricTest
 import com.fsck.k9.Preferences
 import com.fsck.k9.backend.api.BackendStorage
 import com.fsck.k9.mail.FolderClass
-import com.fsck.k9.provider.EmailProvider
 import org.junit.After
 import org.junit.Assert.assertEquals
-import org.junit.Before
 import org.junit.Test
 import org.koin.core.component.inject
 import org.mockito.kotlin.any
@@ -26,12 +23,6 @@ class K9BackendStorageTest : K9RobolectricTest() {
     val database: LockableDatabase = localStoreProvider.getInstance(account).database
     val backendStorage = createBackendStorage()
 
-    @Before
-    fun setUp() {
-        // Set EmailProvider.CONTENT_URI so LocalStore.notifyChange() won't crash
-        EmailProvider.CONTENT_URI = Uri.parse("content://dummy")
-    }
-
     @After
     fun tearDown() {
         preferences.deleteAccount(account)
diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt
new file mode 100644
index 00000000000..b23c4ff7e6d
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt
@@ -0,0 +1,141 @@
+package com.fsck.k9.mailstore
+
+import com.fsck.k9.mail.Flag
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+private const val MESSAGE_ID = 1L
+private const val FOLDER_ID = 2L
+
+class MessageListCacheTest {
+    private val localFolder = mock<LocalFolder> {
+        on { databaseId } doReturn FOLDER_ID
+    }
+
+    private val localMessage = mock<LocalMessage> {
+        on { databaseId } doReturn MESSAGE_ID
+        on { folder } doReturn localFolder
+    }
+
+    private val cache = MessageListCache.getCache(UUID.randomUUID().toString())
+
+    @Before
+    fun setUp() {
+        startKoin {
+            modules(
+                module {
+                    single { mock<MessageListRepository>() }
+                }
+            )
+        }
+    }
+
+    @After
+    fun tearDown() {
+        stopKoin()
+    }
+
+    @Test
+    fun `getCache() returns different cache for each UUID`() {
+        val cache = MessageListCache.getCache("u001")
+
+        val cache2 = MessageListCache.getCache("u002")
+
+        assertThat(cache2).isNotSameInstanceAs(cache)
+    }
+
+    @Test
+    fun `getCache() returns same cache for the same UUID`() {
+        val cache = MessageListCache.getCache("u001")
+
+        val cache2 = MessageListCache.getCache("u001")
+
+        assertThat(cache2).isSameInstanceAs(cache)
+    }
+
+    @Test
+    fun `getFlagForMessage() returns value set for message`() {
+        cache.setFlagForMessages(listOf(1L), Flag.SEEN, true)
+
+        val result = cache.getFlagForMessage(1L, Flag.SEEN)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun `getFlagForMessage() with unknown message ID returns null`() {
+        val result = cache.getFlagForMessage(1L, Flag.SEEN)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `getFlagForMessage() returns null when removed`() {
+        cache.setFlagForMessages(listOf(1L), Flag.FLAGGED, false)
+        cache.removeFlagForMessages(listOf(1L), Flag.FLAGGED)
+
+        val result = cache.getFlagForMessage(1L, Flag.FLAGGED)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `getFlagForThread() returns value set for thread`() {
+        cache.setValueForThreads(listOf(1L), Flag.SEEN, false)
+
+        val result = cache.getFlagForThread(1L, Flag.SEEN)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun `getFlagForThread() with unknown message ID returns null`() {
+        val result = cache.getFlagForThread(1L, Flag.ANSWERED)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `getFlagForThread() returns null when removed`() {
+        cache.setValueForThreads(listOf(1L), Flag.SEEN, true)
+        cache.removeFlagForThreads(listOf(1L), Flag.SEEN)
+
+        val result = cache.getFlagForThread(1L, Flag.SEEN)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `isMessageHidden() returns true for hidden message`() {
+        cache.hideMessages(listOf(localMessage))
+
+        val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun `isMessageHidden() returns false for unknown message`() {
+        val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun `isMessageHidden() returns false for unhidden message`() {
+        cache.hideMessages(listOf(localMessage))
+        cache.unhideMessages(listOf(localMessage))
+
+        val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID)
+
+        assertThat(result).isFalse()
+    }
+}
diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt
new file mode 100644
index 00000000000..300356f22f4
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt
@@ -0,0 +1,433 @@
+package com.fsck.k9.mailstore
+
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.message.extractors.PreviewResult
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+
+private const val MESSAGE_ID = 1L
+private const val MESSAGE_ID_2 = 2L
+private const val MESSAGE_ID_3 = 3L
+private const val FOLDER_ID = 20L
+private const val FOLDER_ID_2 = 21L
+private const val THREAD_ROOT = 30L
+private const val THREAD_ROOT_2 = 31L
+
+private const val SELECTION = "irrelevant"
+private val SELECTION_ARGS = arrayOf("irrelevant")
+private const val SORT_ORDER = "irrelevant"
+
+class MessageListRepositoryTest {
+    private val accountUuid = UUID.randomUUID().toString()
+
+    private val messageStore = mock<ListenableMessageStore>()
+    private val messageStoreManager = mock<MessageStoreManager> {
+        on { getMessageStore(accountUuid) } doReturn messageStore
+    }
+
+    private val messageListRepository = MessageListRepository(messageStoreManager)
+
+    @Before
+    fun setUp() {
+        startKoin {
+            modules(
+                module {
+                    single { messageListRepository }
+                }
+            )
+        }
+    }
+
+    @After
+    fun tearDown() {
+        stopKoin()
+    }
+
+    @Test
+    fun `adding and removing listener`() {
+        var messageListChanged = 0
+        val listener = MessageListChangedListener {
+            messageListChanged++
+        }
+        messageListRepository.addListener(accountUuid, listener)
+
+        messageListRepository.notifyMessageListChanged(accountUuid)
+
+        assertThat(messageListChanged).isEqualTo(1)
+
+        messageListRepository.removeListener(listener)
+
+        messageListRepository.notifyMessageListChanged(accountUuid)
+
+        assertThat(messageListChanged).isEqualTo(1)
+    }
+
+    @Test
+    fun `only notify listener when account UUID matches`() {
+        var messageListChanged = 0
+        val listener = MessageListChangedListener {
+            messageListChanged++
+        }
+        messageListRepository.addListener(accountUuid, listener)
+
+        messageListRepository.notifyMessageListChanged("otherAccountUuid")
+
+        assertThat(messageListChanged).isEqualTo(0)
+    }
+
+    @Test
+    fun `notifyMessageListChanged() without any listeners should not throw`() {
+        messageListRepository.notifyMessageListChanged(accountUuid)
+    }
+
+    @Test
+    fun `getMessages() should use flag values from the cache`() {
+        addMessages(
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = false,
+                isStarred = true,
+                isAnswered = false,
+                isForwarded = true
+            )
+        )
+        MessageListCache.getCache(accountUuid).apply {
+            setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true)
+            setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false)
+        }
+
+        val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message ->
+            MessageData(
+                messageId = message.id,
+                folderId = message.folderId,
+                threadRoot = message.threadRoot,
+                isRead = message.isRead,
+                isStarred = message.isStarred,
+                isAnswered = message.isAnswered,
+                isForwarded = message.isForwarded
+            )
+        }
+
+        assertThat(result).containsExactly(
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = true,
+                isStarred = false,
+                isAnswered = false,
+                isForwarded = true
+            )
+        )
+    }
+
+    @Test
+    fun `getMessages() should skip messages marked as hidden in the cache`() {
+        addMessages(
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID)
+
+        val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message ->
+            message.id
+        }
+
+        assertThat(result).containsExactly(MESSAGE_ID_2)
+    }
+
+    @Test
+    fun `getMessages() should not skip message when marked as hidden in a different folder`() {
+        addMessages(
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID_2)
+
+        val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message ->
+            message.id
+        }
+
+        assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2)
+    }
+
+    @Test
+    fun `getThreadedMessages() should use flag values from the cache`() {
+        addThreadedMessages(
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = false,
+                isStarred = true,
+                isAnswered = false,
+                isForwarded = true
+            )
+        )
+        MessageListCache.getCache(accountUuid).apply {
+            setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true)
+            setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false)
+        }
+
+        val result = messageListRepository.getThreadedMessages(
+            accountUuid,
+            SELECTION,
+            SELECTION_ARGS,
+            SORT_ORDER
+        ) { message ->
+            MessageData(
+                messageId = message.id,
+                folderId = message.folderId,
+                threadRoot = message.threadRoot,
+                isRead = message.isRead,
+                isStarred = message.isStarred,
+                isAnswered = message.isAnswered,
+                isForwarded = message.isForwarded
+            )
+        }
+
+        assertThat(result).containsExactly(
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = true,
+                isStarred = false,
+                isAnswered = false,
+                isForwarded = true
+            )
+        )
+    }
+
+    @Test
+    fun `getThreadedMessages() should skip messages marked as hidden in the cache`() {
+        addThreadedMessages(
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID)
+
+        val result = messageListRepository.getThreadedMessages(
+            accountUuid,
+            SELECTION,
+            SELECTION_ARGS,
+            SORT_ORDER
+        ) { message ->
+            message.id
+        }
+
+        assertThat(result).containsExactly(MESSAGE_ID_2)
+    }
+
+    @Test
+    fun `getThreadedMessages() should not skip message when marked as hidden in a different folder`() {
+        addThreadedMessages(
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID_2)
+
+        val result = messageListRepository.getThreadedMessages(
+            accountUuid,
+            SELECTION,
+            SELECTION_ARGS,
+            SORT_ORDER
+        ) { message ->
+            message.id
+        }
+
+        assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2)
+    }
+
+    @Test
+    fun `getThread() should use flag values from the cache`() {
+        addMessagesToThread(
+            THREAD_ROOT,
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = false,
+                isStarred = true,
+                isAnswered = false,
+                isForwarded = true
+            ),
+            MessageData(
+                messageId = MESSAGE_ID_2,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = false,
+                isStarred = true,
+                isAnswered = true,
+                isForwarded = false
+            )
+        )
+        MessageListCache.getCache(accountUuid).apply {
+            setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true)
+            setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false)
+        }
+
+        val result = messageListRepository.getThread(
+            accountUuid,
+            THREAD_ROOT,
+            SORT_ORDER
+        ) { message ->
+            MessageData(
+                messageId = message.id,
+                folderId = message.folderId,
+                threadRoot = message.threadRoot,
+                isRead = message.isRead,
+                isStarred = message.isStarred,
+                isAnswered = message.isAnswered,
+                isForwarded = message.isForwarded
+            )
+        }
+
+        assertThat(result).containsExactly(
+            MessageData(
+                messageId = MESSAGE_ID,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = true,
+                isStarred = false,
+                isAnswered = false,
+                isForwarded = true
+            ),
+            MessageData(
+                messageId = MESSAGE_ID_2,
+                folderId = FOLDER_ID,
+                threadRoot = THREAD_ROOT,
+                isRead = false,
+                isStarred = false,
+                isAnswered = true,
+                isForwarded = false
+            )
+        )
+    }
+
+    @Test
+    fun `getThread() should skip messages marked as hidden in the cache`() {
+        addMessagesToThread(
+            THREAD_ROOT,
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID)
+
+        val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id }
+
+        assertThat(result).containsExactly(MESSAGE_ID_2, MESSAGE_ID_3)
+    }
+
+    @Test
+    fun `getThread() should not skip message when marked as hidden in a different folder`() {
+        addMessagesToThread(
+            THREAD_ROOT,
+            MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT),
+            MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT)
+        )
+        hideMessage(MESSAGE_ID, FOLDER_ID_2)
+
+        val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id }
+
+        assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2, MESSAGE_ID_3)
+    }
+
+    private fun addMessages(vararg messages: MessageData) {
+        messageStore.stub {
+            on { getMessages<Any>(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer {
+                val mapper: MessageMapper<Any?> = it.getArgument(3)
+
+                runMessageMapper(messages, mapper)
+            }
+        }
+    }
+
+    private fun addThreadedMessages(vararg messages: MessageData) {
+        messageStore.stub {
+            on { getThreadedMessages<Any>(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer {
+                val mapper: MessageMapper<Any?> = it.getArgument(3)
+
+                runMessageMapper(messages, mapper)
+            }
+        }
+    }
+
+    @Suppress("SameParameterValue")
+    private fun addMessagesToThread(threadRoot: Long, vararg messages: MessageData) {
+        messageStore.stub {
+            on { getThread<Any>(eq(threadRoot), eq(SORT_ORDER), any()) } doAnswer {
+                val mapper: MessageMapper<Any?> = it.getArgument(2)
+
+                runMessageMapper(messages, mapper)
+            }
+        }
+    }
+
+    private fun runMessageMapper(messages: Array<out MessageData>, mapper: MessageMapper<Any?>): List<Any> {
+        return messages.mapNotNull { message ->
+            mapper.map(object : MessageDetailsAccessor {
+                override val id = message.messageId
+                override val messageServerId = "irrelevant"
+                override val folderId = message.folderId
+                override val fromAddresses = emptyList<Address>()
+                override val toAddresses = emptyList<Address>()
+                override val ccAddresses = emptyList<Address>()
+                override val messageDate = 0L
+                override val internalDate = 0L
+                override val subject = "irrelevant"
+                override val preview = PreviewResult.error()
+                override val isRead = message.isRead
+                override val isStarred = message.isStarred
+                override val isAnswered = message.isAnswered
+                override val isForwarded = message.isForwarded
+                override val hasAttachments = false
+                override val threadRoot = message.threadRoot
+                override val threadCount = 0
+            })
+        }
+    }
+
+    @Suppress("SameParameterValue")
+    private fun hideMessage(messageId: Long, folderId: Long) {
+        val cache = MessageListCache.getCache(accountUuid)
+
+        val localFolder = mock<LocalFolder> {
+            on { databaseId } doReturn folderId
+        }
+
+        val localMessage = mock<LocalMessage> {
+            on { databaseId } doReturn messageId
+            on { folder } doReturn localFolder
+        }
+
+        cache.hideMessages(listOf(localMessage))
+    }
+}
+
+private data class MessageData(
+    val messageId: Long,
+    val folderId: Long,
+    val threadRoot: Long,
+    val isRead: Boolean = false,
+    val isStarred: Boolean = false,
+    val isAnswered: Boolean = false,
+    val isForwarded: Boolean = false,
+)
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt
index bd71c627916..0c9bbf74a31 100644
--- a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt
@@ -38,8 +38,8 @@ fun createDatabase(): SQLiteDatabase {
 }
 
 fun SQLiteDatabase.createMessage(
-    deleted: Boolean = false,
     folderId: Long,
+    deleted: Boolean = false,
     uid: String? = null,
     subject: String = "",
     date: Long = 0L,
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt
index 69fc55cbb58..234dc350ca5 100644
--- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt
@@ -3,9 +3,12 @@ package com.fsck.k9.storage.messages
 import com.fsck.k9.Account.FolderMode
 import com.fsck.k9.mail.FolderClass
 import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mailstore.FolderNotFoundException
+import com.fsck.k9.mailstore.MoreMessages
 import com.fsck.k9.mailstore.toDatabaseFolderType
 import com.fsck.k9.storage.RobolectricTest
 import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.fail
 import org.junit.Test
 
 class RetrieveFolderOperationsTest : RobolectricTest() {
@@ -347,4 +350,41 @@ class RetrieveFolderOperationsTest : RobolectricTest() {
 
         assertThat(result).isEqualTo(2)
     }
+
+    @Test
+    fun `get 'more messages' value from non-existent folder`() {
+        try {
+            retrieveFolderOperations.hasMoreMessages(23)
+            fail("Expected exception")
+        } catch (e: FolderNotFoundException) {
+            assertThat(e.folderId).isEqualTo(23)
+        }
+    }
+
+    @Test
+    fun `get 'more messages' value from folder with value 'unknown'`() {
+        val folderId = sqliteDatabase.createFolder(moreMessages = "unknown")
+
+        val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+        assertThat(result).isEqualTo(MoreMessages.UNKNOWN)
+    }
+
+    @Test
+    fun `get 'more messages' value from folder with value 'false'`() {
+        val folderId = sqliteDatabase.createFolder(moreMessages = "false")
+
+        val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+        assertThat(result).isEqualTo(MoreMessages.FALSE)
+    }
+
+    @Test
+    fun `get 'more messages' value from folder with value 'true'`() {
+        val folderId = sqliteDatabase.createFolder(moreMessages = "true")
+
+        val result = retrieveFolderOperations.hasMoreMessages(folderId)
+
+        assertThat(result).isEqualTo(MoreMessages.TRUE)
+    }
 }
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt
new file mode 100644
index 00000000000..45b62c19d59
--- /dev/null
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt
@@ -0,0 +1,486 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mailstore.DatabasePreviewType
+import com.fsck.k9.mailstore.MessageMapper
+import com.fsck.k9.message.extractors.PreviewResult.PreviewType
+import com.fsck.k9.storage.RobolectricTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class RetrieveMessageListOperationsTest : RobolectricTest() {
+    private val sqliteDatabase = createDatabase()
+    private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
+    private val retrieveMessageListOperations = RetrieveMessageListOperations(lockableDatabase)
+
+    @Test
+    fun `getMessages() on empty folder`() {
+        val folderId = sqliteDatabase.createFolder()
+
+        val result = getMessagesFromFolder(folderId) { "unexpected" }
+
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `getMessages() with only a deleted message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true)
+        sqliteDatabase.createThread(messageId)
+
+        val result = getMessagesFromFolder(folderId) { "unexpected" }
+
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `getMessages() with single message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId = sqliteDatabase.createMessage(
+            folderId,
+            uid = "uid1",
+            subject = "subject",
+            date = 123L,
+            senderList = Address.pack(Address.parse("from@domain.example")),
+            toList = Address.pack(Address.parse("to@domain.example")),
+            ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")),
+            attachmentCount = 1,
+            internalDate = 456L,
+            previewType = DatabasePreviewType.TEXT,
+            preview = "preview",
+            read = true,
+            flagged = true,
+            answered = true,
+            forwarded = true
+        )
+        val threadId = sqliteDatabase.createThread(messageId)
+
+        val result = getMessagesFromFolder(folderId) { message ->
+            assertThat(message.id).isEqualTo(messageId)
+            assertThat(message.messageServerId).isEqualTo("uid1")
+            assertThat(message.folderId).isEqualTo(folderId)
+            assertThat(message.fromAddresses).containsExactly(Address("from@domain.example"))
+            assertThat(message.toAddresses).containsExactly(Address("to@domain.example"))
+            assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example"))
+            assertThat(message.messageDate).isEqualTo(123L)
+            assertThat(message.internalDate).isEqualTo(456L)
+            assertThat(message.subject).isEqualTo("subject")
+            assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT)
+            assertThat(message.preview.previewText).isEqualTo("preview")
+            assertThat(message.isRead).isTrue()
+            assertThat(message.isStarred).isTrue()
+            assertThat(message.isAnswered).isTrue()
+            assertThat(message.isForwarded).isTrue()
+            assertThat(message.hasAttachments).isTrue()
+            assertThat(message.threadRoot).isEqualTo(threadId)
+            assertThat(message.threadCount).isEqualTo(0)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getMessages() with folder containing an empty message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message -> message.id }
+
+        assertThat(result).containsExactly(messageId2)
+    }
+
+    @Test
+    fun `getMessages() with folder containing a deleted message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, deleted = true)
+        sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message -> message.id }
+
+        assertThat(result).containsExactly(messageId2)
+    }
+
+    @Test
+    fun `getMessages() selecting only unread messages`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = false)
+        sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true)
+        sqliteDatabase.createThread(messageId2)
+        val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3", read = false)
+        sqliteDatabase.createThread(messageId3)
+
+        val result = retrieveMessageListOperations.getThreadedMessages(
+            selection = "folder_id = ? AND read = 0",
+            selectionArgs = arrayOf(folderId.toString()),
+            sortOrder = "date DESC, id DESC"
+        ) { message ->
+            message.id
+        }
+
+        assertThat(result).containsExactly(messageId1, messageId3)
+    }
+
+    @Test
+    fun `getThreadedMessages() on empty folder`() {
+        val folderId = sqliteDatabase.createFolder()
+
+        val result = getThreadedMessagesFromFolder(folderId) { "unexpected" }
+
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `getThreadedMessages() with only a deleted message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true)
+        sqliteDatabase.createThread(messageId)
+
+        val result = getThreadedMessagesFromFolder(folderId) { "unexpected" }
+
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `getThreadedMessages() with single message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId = sqliteDatabase.createMessage(
+            folderId,
+            uid = "uid1",
+            subject = "subject",
+            date = 123L,
+            senderList = Address.pack(Address.parse("from@domain.example")),
+            toList = Address.pack(Address.parse("to@domain.example")),
+            ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")),
+            attachmentCount = 1,
+            internalDate = 456L,
+            previewType = DatabasePreviewType.TEXT,
+            preview = "preview",
+            read = true,
+            flagged = true,
+            answered = true,
+            forwarded = true
+        )
+        val threadId = sqliteDatabase.createThread(messageId)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.id).isEqualTo(messageId)
+            assertThat(message.messageServerId).isEqualTo("uid1")
+            assertThat(message.folderId).isEqualTo(folderId)
+            assertThat(message.fromAddresses).containsExactly(Address("from@domain.example"))
+            assertThat(message.toAddresses).containsExactly(Address("to@domain.example"))
+            assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example"))
+            assertThat(message.messageDate).isEqualTo(123L)
+            assertThat(message.internalDate).isEqualTo(456L)
+            assertThat(message.subject).isEqualTo("subject")
+            assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT)
+            assertThat(message.preview.previewText).isEqualTo("preview")
+            assertThat(message.isRead).isTrue()
+            assertThat(message.isStarred).isTrue()
+            assertThat(message.isAnswered).isTrue()
+            assertThat(message.isForwarded).isTrue()
+            assertThat(message.hasAttachments).isTrue()
+            assertThat(message.threadRoot).isEqualTo(threadId)
+            assertThat(message.threadCount).isEqualTo(1)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() with thread containing an empty message`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.id).isEqualTo(messageId2)
+            assertThat(message.messageServerId).isEqualTo("uid2")
+            assertThat(message.threadRoot).isEqualTo(threadId1)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return latest message in thread`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(
+            folderId,
+            uid = "uid1",
+            date = 1000L,
+            internalDate = 1001L
+        )
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(
+            folderId,
+            uid = "uid2",
+            date = 2000L,
+            internalDate = 2001L
+        )
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.id).isEqualTo(messageId2)
+            assertThat(message.messageDate).isEqualTo(2000L)
+            assertThat(message.internalDate).isEqualTo(2001L)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'unread' when at least one message in thread is marked as unread`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = false)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isRead).isFalse()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'read' when all messages in thread are marked as read`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isRead).isTrue()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'starred' when at least one message in thread is marked as starred`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = true)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isStarred).isTrue()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'not starred' when all messages in thread are not marked as starred`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = false)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isStarred).isFalse()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'not answered' when not all messages in thread are marked as answered`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = false)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isAnswered).isFalse()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'answered' when all messages in thread are marked as answered`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isAnswered).isTrue()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'not forwarded' when not all messages in thread are marked as forwarded`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = false)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isForwarded).isFalse()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'forwarded' when all messages in thread are marked as forwarded`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = true)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.isForwarded).isTrue()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'has attachment' when at least one message in thread contains an attachment`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 1)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.hasAttachments).isTrue()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should return 'has no attachment' when no message in thread contains an attachment`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 0)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0)
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.hasAttachments).isFalse()
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() with 3 messages in thread`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1")
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+        val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+        sqliteDatabase.createThread(messageId3, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.threadCount).isEqualTo(3)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThreadedMessages() should not include empty messages in thread count`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", empty = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+        val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+        sqliteDatabase.createThread(messageId3, root = threadId1)
+
+        val result = getThreadedMessagesFromFolder(folderId) { message ->
+            assertThat(message.threadCount).isEqualTo(2)
+            "OK"
+        }
+
+        assertThat(result).containsExactly("OK")
+    }
+
+    @Test
+    fun `getThread() with empty message as thread root`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, empty = true)
+        val threadId1 = sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        sqliteDatabase.createThread(messageId2, root = threadId1)
+        val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+        sqliteDatabase.createThread(messageId3, root = threadId1)
+
+        val result = retrieveMessageListOperations.getThread(threadId = threadId1, sortOrder = "date DESC") { it.id }
+
+        assertThat(result).containsExactly(messageId2, messageId3)
+    }
+
+    @Test
+    fun `getThread() should only return messages in thread`() {
+        val folderId = sqliteDatabase.createFolder()
+        val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1")
+        sqliteDatabase.createThread(messageId1)
+        val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2")
+        val threadId2 = sqliteDatabase.createThread(messageId2)
+        val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3")
+        sqliteDatabase.createThread(messageId3, root = threadId2)
+
+        val result = retrieveMessageListOperations.getThread(threadId = threadId2, sortOrder = "date DESC") { it.id }
+
+        assertThat(result).containsExactly(messageId2, messageId3)
+    }
+
+    private fun <T> getMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> {
+        return retrieveMessageListOperations.getMessages(
+            selection = "folder_id = ?",
+            selectionArgs = arrayOf(folderId.toString()),
+            sortOrder = "date DESC, id DESC",
+            mapper
+        )
+    }
+
+    private fun <T> getThreadedMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> {
+        return retrieveMessageListOperations.getThreadedMessages(
+            selection = "folder_id = ?",
+            selectionArgs = arrayOf(folderId.toString()),
+            sortOrder = "date DESC, id DESC",
+            mapper
+        )
+    }
+}
diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt
index 029b1421e2c..168f2bc6888 100644
--- a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt
+++ b/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt
@@ -469,11 +469,11 @@ class MessageListAdapterTest : RobolectricTest() {
     }
 
     fun createMessageListItem(
-        position: Int = 0,
         account: Account = Account(SOME_ACCOUNT_UUID),
         subject: String? = "irrelevant",
         threadCount: Int = 0,
         messageDate: Long = 0L,
+        internalDate: Long = 0L,
         displayName: CharSequence = "irrelevant",
         displayAddress: Address? = Address.parse("irrelevant@domain.example").first(),
         toMe: Boolean = false,
@@ -492,11 +492,11 @@ class MessageListAdapterTest : RobolectricTest() {
         threadRoot: Long = 0L
     ): MessageListItem {
         return MessageListItem(
-            position,
             account,
             subject,
             threadCount,
             messageDate,
+            internalDate,
             displayName,
             displayAddress,
             toMe,