summaryrefslogtreecommitdiff
path: root/app/storage/src/main/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /app/storage/src/main/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'app/storage/src/main/java')
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/K9StorageEditor.java114
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java251
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo10.kt37
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo11.kt26
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo12.kt65
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo13.kt18
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo14.kt35
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo15.kt36
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo16.kt18
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo17.kt54
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt36
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo19.kt54
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo2.java100
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo3.kt44
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo4.kt31
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo5.kt36
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt54
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo7.kt54
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo8.kt23
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt29
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationsHelper.kt10
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/ImapStoreUriDecoder.java144
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/Pop3StoreUriDecoder.java106
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/SmtpTransportUriDecoder.java108
-rw-r--r--app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/WebDavStoreUriDecoder.java120
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/K9SchemaDefinitionFactory.kt13
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt18
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java273
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/AttachmentFileManager.kt36
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt34
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperations.kt21
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/CopyMessageOperations.kt315
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/CreateFolderOperations.kt31
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/DataLocation.kt3
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt40
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteFolderOperations.kt44
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteMessageOperations.kt170
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt111
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt287
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStoreFactory.kt22
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/KeyValueStoreOperations.kt131
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/MoveMessageOperations.kt132
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt284
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt242
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt197
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt539
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt223
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt105
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt28
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingAppend.java6
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingCommand.java4
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingDelete.java24
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingExpunge.java5
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMarkAllAsRead.java5
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveAndMarkAsRead.java9
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveOrCopy.java10
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingSetFlag.java12
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo62.java15
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo64.kt34
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo65.kt22
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo66.kt12
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo67.kt26
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo68.kt33
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo69.kt39
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo70.kt93
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo71.kt24
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo72.kt10
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo73.kt169
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo74.kt124
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo75.kt36
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo76.kt131
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo78.kt12
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo79.kt21
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt12
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt22
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt27
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt42
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo84.kt59
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt34
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt72
-rw-r--r--app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt13
81 files changed, 6059 insertions, 0 deletions
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/K9StorageEditor.java b/app/storage/src/main/java/com/fsck/k9/preferences/K9StorageEditor.java
new file mode 100644
index 0000000..74a6266
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/K9StorageEditor.java
@@ -0,0 +1,114 @@
+package com.fsck.k9.preferences;
+
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import android.os.SystemClock;
+
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback;
+import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations;
+import timber.log.Timber;
+
+
+public class K9StorageEditor implements StorageEditor {
+ private StorageUpdater storageUpdater;
+ private K9StoragePersister storagePersister;
+
+ private Map<String, String> changes = new HashMap<>();
+ private List<String> removals = new ArrayList<>();
+
+
+ public K9StorageEditor(StorageUpdater storageUpdater, K9StoragePersister storagePersister) {
+ this.storageUpdater = storageUpdater;
+ this.storagePersister = storagePersister;
+ }
+
+ @Override
+ public boolean commit() {
+ try {
+ storageUpdater.updateStorage(this::commitChanges);
+ return true;
+ } catch (Exception e) {
+ Timber.e(e, "Failed to save preferences");
+ return false;
+ }
+ }
+
+ private Storage commitChanges(Storage storage) {
+ long startTime = SystemClock.elapsedRealtime();
+ Timber.i("Committing preference changes");
+
+ Map<String, String> newValues = new HashMap<>();
+ Map<String, String> oldValues = storage.getAll();
+ StoragePersistOperationCallback committer = new StoragePersistOperationCallback() {
+ @Override
+ public void beforePersistTransaction(Map<String, String> workingStorage) {
+ workingStorage.putAll(oldValues);
+ }
+
+ @Override
+ public void persist(StoragePersistOperations ops) {
+ for (String removeKey : removals) {
+ ops.remove(removeKey);
+ }
+ for (Entry<String, String> entry : changes.entrySet()) {
+ String key = entry.getKey();
+ String newValue = entry.getValue();
+ String oldValue = oldValues.get(key);
+ if (removals.contains(key) || !newValue.equals(oldValue)) {
+ ops.put(key, newValue);
+ }
+ }
+ }
+
+ @Override
+ public void onPersistTransactionSuccess(Map<String, String> workingStorage) {
+ newValues.putAll(workingStorage);
+ }
+ };
+ storagePersister.doInTransaction(committer);
+ long endTime = SystemClock.elapsedRealtime();
+ Timber.i("Preferences commit took %d ms", endTime - startTime);
+
+ return new Storage(newValues);
+ }
+
+ @Override
+ public StorageEditor putBoolean(String key,
+ boolean value) {
+ changes.put(key, "" + value);
+ return this;
+ }
+
+ @Override
+ public StorageEditor putInt(String key, int value) {
+ changes.put(key, "" + value);
+ return this;
+ }
+
+ @Override
+ public StorageEditor putLong(String key, long value) {
+ changes.put(key, "" + value);
+ return this;
+ }
+
+ @Override
+ public StorageEditor putString(String key, String value) {
+ if (value == null) {
+ remove(key);
+ } else {
+ changes.put(key, value);
+ }
+ return this;
+ }
+
+ @Override
+ public StorageEditor remove(String key) {
+ removals.add(key);
+ return this;
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
new file mode 100644
index 0000000..eb8b0ce
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java
@@ -0,0 +1,251 @@
+package com.fsck.k9.preferences;
+
+
+import java.util.HashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.os.SystemClock;
+
+import androidx.annotation.NonNull;
+import com.fsck.k9.helper.Utility;
+import com.fsck.k9.preferences.migrations.StorageMigrations;
+import com.fsck.k9.preferences.migrations.StorageMigrationsHelper;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import timber.log.Timber;
+
+
+public class K9StoragePersister implements StoragePersister {
+ private static final int DB_VERSION = 19;
+ private static final String DB_NAME = "preferences_storage";
+
+ private final Context context;
+
+ public K9StoragePersister(Context context) {
+ this.context = context;
+ }
+
+ private SQLiteDatabase openDB() {
+ SQLiteDatabase db = context.openOrCreateDatabase(DB_NAME, Context.MODE_PRIVATE, null);
+
+ db.beginTransaction();
+ try {
+ if (db.getVersion() > DB_VERSION) {
+ throw new AssertionError("Database downgrades are not supported. " +
+ "Please fix the database '" + DB_NAME + "' manually or clear app data.");
+ }
+
+ if (db.getVersion() < 1) {
+ createStorageDatabase(db);
+ } else {
+ StorageMigrations.upgradeDatabase(db, migrationsHelper);
+ }
+
+ db.setVersion(DB_VERSION);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (db.getVersion() != DB_VERSION) {
+ throw new RuntimeException("Storage database upgrade failed!");
+ }
+
+ return db;
+ }
+
+ private void createStorageDatabase(SQLiteDatabase db) {
+ Timber.i("Creating Storage database");
+
+ db.execSQL("DROP TABLE IF EXISTS preferences_storage");
+ db.execSQL("CREATE TABLE preferences_storage " +
+ "(primkey TEXT PRIMARY KEY ON CONFLICT REPLACE, value TEXT)");
+ db.setVersion(DB_VERSION);
+ }
+
+ void doInTransaction(StoragePersistOperationCallback operationCallback) {
+ HashMap<String, String> workingStorage = new HashMap<>();
+ SQLiteDatabase workingDb = openDB();
+
+ try {
+ operationCallback.beforePersistTransaction(workingStorage);
+
+ StoragePersistOperations storagePersistOperations = new StoragePersistOperations(workingStorage, workingDb);
+ workingDb.beginTransaction();
+ operationCallback.persist(storagePersistOperations);
+ storagePersistOperations.close();
+ workingDb.setTransactionSuccessful();
+
+ operationCallback.onPersistTransactionSuccess(workingStorage);
+ } finally {
+ workingDb.endTransaction();
+ workingDb.close();
+ }
+ }
+
+ @NonNull
+ @Override
+ public StorageEditor createStorageEditor(@NonNull StorageUpdater storageUpdater) {
+ return new K9StorageEditor(storageUpdater, this);
+ }
+
+ static class StoragePersistOperations {
+ private Map<String, String> workingStorage;
+ private final SQLiteStatement deleteStatement;
+ private final SQLiteStatement insertStatement;
+
+ private StoragePersistOperations(Map<String, String> workingStorage, SQLiteDatabase database) {
+ this.workingStorage = workingStorage;
+
+ insertStatement = database.compileStatement(
+ "INSERT INTO preferences_storage (primkey, value) VALUES (?, ?)");
+ deleteStatement = database.compileStatement(
+ "DELETE FROM preferences_storage WHERE primkey = ?");
+ }
+
+ void put(String key, String value) {
+ insertStatement.bindString(1, key);
+ insertStatement.bindString(2, value);
+ insertStatement.execute();
+ insertStatement.clearBindings();
+
+ workingStorage.put(key, value);
+ }
+
+ void remove(String key) {
+ deleteStatement.bindString(1, key);
+ deleteStatement.executeUpdateDelete();
+ deleteStatement.clearBindings();
+
+ workingStorage.remove(key);
+ }
+
+ private void close() {
+ insertStatement.close();
+ deleteStatement.close();
+ }
+ }
+
+ interface StoragePersistOperationCallback {
+ void beforePersistTransaction(Map<String, String> workingStorage);
+ void persist(StoragePersistOperations ops);
+ void onPersistTransactionSuccess(Map<String, String> workingStorage);
+ }
+
+ @NonNull
+ @Override
+ public Storage loadValues() {
+ long startTime = SystemClock.elapsedRealtime();
+ Timber.i("Loading preferences from DB into Storage");
+
+ try (SQLiteDatabase database = openDB()) {
+ return new Storage(readAllValues(database));
+ } finally {
+ long endTime = SystemClock.elapsedRealtime();
+ Timber.i("Preferences load took %d ms", endTime - startTime);
+ }
+ }
+
+ private Map<String, String> readAllValues(SQLiteDatabase database) {
+ HashMap<String, String> loadedValues = new HashMap<>();
+ Cursor cursor = null;
+ try {
+ cursor = database.rawQuery("SELECT primkey, value FROM preferences_storage", null);
+ while (cursor.moveToNext()) {
+ String key = cursor.getString(0);
+ String value = cursor.getString(1);
+ Timber.d("Loading key '%s', value = '%s'", key, value);
+ loadedValues.put(key, value);
+ }
+ } finally {
+ Utility.closeQuietly(cursor);
+ }
+
+ return loadedValues;
+ }
+
+ private String readValue(SQLiteDatabase mDb, String key) {
+ Cursor cursor = null;
+ String value = null;
+ try {
+ cursor = mDb.query(
+ "preferences_storage",
+ new String[] {"value"},
+ "primkey = ?",
+ new String[] {key},
+ null,
+ null,
+ null);
+
+ if (cursor.moveToNext()) {
+ value = cursor.getString(0);
+ Timber.d("Loading key '%s', value = '%s'", key, value);
+ }
+ } finally {
+ Utility.closeQuietly(cursor);
+ }
+
+ return value;
+ }
+
+ private void writeValue(SQLiteDatabase mDb, String key, String value) {
+ if (value == null) {
+ mDb.delete("preferences_storage", "primkey = ?", new String[] { key });
+ return;
+ }
+
+ ContentValues cv = new ContentValues();
+ cv.put("primkey", key);
+ cv.put("value", value);
+
+ long result = mDb.update("preferences_storage", cv, "primkey = ?", new String[] { key });
+
+ if (result == -1) {
+ Timber.e("Error writing key '%s', value = '%s'", key, value);
+ }
+ }
+
+ private void insertValue(SQLiteDatabase mDb, String key, String value) {
+ if (value == null) {
+ return;
+ }
+
+ ContentValues cv = new ContentValues();
+ cv.put("primkey", key);
+ cv.put("value", value);
+
+ long result = mDb.insert("preferences_storage", null, cv);
+
+ if (result == -1) {
+ Timber.e("Error writing key '%s', value = '%s'", key, value);
+ }
+ }
+
+ private StorageMigrationsHelper migrationsHelper = new StorageMigrationsHelper() {
+ @Override
+ public void writeValue(@NotNull SQLiteDatabase db, @NotNull String key, String value) {
+ K9StoragePersister.this.writeValue(db, key, value);
+ }
+
+ @NotNull
+ @Override
+ public Map<String, String> readAllValues(@NotNull SQLiteDatabase db) {
+ return K9StoragePersister.this.readAllValues(db);
+ }
+
+ @Override
+ public String readValue(@NotNull SQLiteDatabase db, @NotNull String key) {
+ return K9StoragePersister.this.readValue(db, key);
+ }
+
+ @Override
+ public void insertValue(@NotNull SQLiteDatabase db, @NotNull String key, @Nullable String value) {
+ K9StoragePersister.this.insertValue(db, key, value);
+ }
+ };
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo10.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo10.kt
new file mode 100644
index 0000000..aa7f134
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo10.kt
@@ -0,0 +1,37 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Remove saved folder settings
+ *
+ * Folder settings are now only written to 'Storage' when settings are imported. The saved settings will be used when
+ * folders are first created and then the saved settings are discarded.
+ * Since this wasn't the procedure in earlier app versions, we remove any saved folder settings in this migration.
+ */
+class StorageMigrationTo10(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ private val folderKeyPattern = Regex("[^.]+\\..+\\.([^.]+)")
+ private val folderSettingKeys = setOf(
+ "displayMode",
+ "notifyMode",
+ "syncMode",
+ "pushMode",
+ "inTopGroup",
+ "integrate"
+ )
+
+ fun removeSavedFolderSettings() {
+ val loadedValues = migrationsHelper.readAllValues(db)
+
+ for (key in loadedValues.keys) {
+ val matches = folderKeyPattern.matchEntire(key) ?: continue
+ val folderKey = matches.groupValues[1]
+ if (folderKey in folderSettingKeys) {
+ migrationsHelper.writeValue(db, key, null)
+ }
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo11.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo11.kt
new file mode 100644
index 0000000..94a232a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo11.kt
@@ -0,0 +1,26 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.preferences.GeneralSettingsDescriptions
+
+/**
+ * Convert old value for message view content font size to new format.
+ *
+ * This change in formats has been made a long time ago. But never in a migration. So it's possible there are still
+ * installations out there that have the old version in the database. And they would work just fine, because this
+ * conversion was done when loading font size values.
+ */
+class StorageMigrationTo11(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun upgradeMessageViewContentFontSize() {
+ val newFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContentPercent")
+ if (newFontSizeValue != null) return
+
+ val oldFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContent")?.toIntOrNull() ?: 3
+ val fontSizeValue = GeneralSettingsDescriptions.SettingsUpgraderV31.convertFromOldSize(oldFontSizeValue)
+ migrationsHelper.writeValue(db, "fontSizeMessageViewContentPercent", fontSizeValue.toString())
+ migrationsHelper.writeValue(db, "fontSizeMessageViewContent", null)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo12.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo12.kt
new file mode 100644
index 0000000..3b5d970
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo12.kt
@@ -0,0 +1,65 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.ServerSettingsSerializer
+import com.fsck.k9.mail.filter.Base64
+import com.fsck.k9.preferences.migrations.migration12.ImapStoreUriDecoder
+import com.fsck.k9.preferences.migrations.migration12.Pop3StoreUriDecoder
+import com.fsck.k9.preferences.migrations.migration12.SmtpTransportUriDecoder
+import com.fsck.k9.preferences.migrations.migration12.WebDavStoreUriDecoder
+
+/**
+ * Convert server settings from the old URI format to the new JSON format
+ */
+class StorageMigrationTo12(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ private val serverSettingsSerializer = ServerSettingsSerializer()
+
+ fun removeStoreAndTransportUri() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ convertStoreUri(accountUuid)
+ convertTransportUri(accountUuid)
+ }
+ }
+
+ private fun convertStoreUri(accountUuid: String) {
+ val storeUri = migrationsHelper.readValue(db, "$accountUuid.storeUri")?.base64Decode() ?: return
+
+ val serverSettings = when {
+ storeUri.startsWith("imap") -> ImapStoreUriDecoder.decode(storeUri)
+ storeUri.startsWith("pop3") -> Pop3StoreUriDecoder.decode(storeUri)
+ storeUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(storeUri)
+ else -> error("Unsupported account type")
+ }
+
+ val json = serverSettingsSerializer.serialize(serverSettings)
+
+ migrationsHelper.insertValue(db, "$accountUuid.incomingServerSettings", json)
+ migrationsHelper.writeValue(db, "$accountUuid.storeUri", null)
+ }
+
+ private fun convertTransportUri(accountUuid: String) {
+ val transportUri = migrationsHelper.readValue(db, "$accountUuid.transportUri")?.base64Decode() ?: return
+
+ val serverSettings = when {
+ transportUri.startsWith("smtp") -> SmtpTransportUriDecoder.decodeSmtpUri(transportUri)
+ transportUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(transportUri)
+ else -> error("Unsupported account type")
+ }
+
+ val json = serverSettingsSerializer.serialize(serverSettings)
+
+ migrationsHelper.insertValue(db, "$accountUuid.outgoingServerSettings", json)
+ migrationsHelper.writeValue(db, "$accountUuid.transportUri", null)
+ }
+
+ private fun String.base64Decode() = Base64.decode(this)
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo13.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo13.kt
new file mode 100644
index 0000000..01b61f6
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo13.kt
@@ -0,0 +1,18 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rename `hideSpecialAccounts` to `showUnifiedInbox` (and invert value).
+ */
+class StorageMigrationTo13(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun renameHideSpecialAccounts() {
+ val hideSpecialAccounts = migrationsHelper.readValue(db, "hideSpecialAccounts")?.toBoolean() ?: false
+ val showUnifiedInbox = !hideSpecialAccounts
+ migrationsHelper.insertValue(db, "showUnifiedInbox", showUnifiedInbox.toString())
+ migrationsHelper.writeValue(db, "hideSpecialAccounts", null)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo14.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo14.kt
new file mode 100644
index 0000000..c4f38c2
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo14.kt
@@ -0,0 +1,35 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.ServerSettingsSerializer
+import com.fsck.k9.preferences.Protocols
+
+/**
+ * Rewrite 'folderPushMode' value of non-IMAP accounts to 'NONE'.
+ */
+class StorageMigrationTo14(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ private val serverSettingsSerializer = ServerSettingsSerializer()
+
+ fun disablePushFoldersForNonImapAccounts() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ disablePushFolders(accountUuid)
+ }
+ }
+
+ private fun disablePushFolders(accountUuid: String) {
+ val json = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
+ val serverSettings = serverSettingsSerializer.deserialize(json)
+ if (serverSettings.type != Protocols.IMAP) {
+ migrationsHelper.writeValue(db, "$accountUuid.folderPushMode", "NONE")
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo15.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo15.kt
new file mode 100644
index 0000000..e3ab3f0
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo15.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+private const val DEFAULT_IDLE_REFRESH_MINUTES = 24
+private const val MINIMUM_IDLE_REFRESH_MINUTES = 2
+
+/**
+ * Rewrite 'idleRefreshMinutes' to make sure the minimum value is 2 minutes.
+ */
+class StorageMigrationTo15(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteIdleRefreshInterval() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ rewriteIdleRefreshInterval(accountUuid)
+ }
+ }
+
+ private fun rewriteIdleRefreshInterval(accountUuid: String) {
+ val idleRefreshMinutes = migrationsHelper.readValue(db, "$accountUuid.idleRefreshMinutes")?.toIntOrNull()
+ ?: DEFAULT_IDLE_REFRESH_MINUTES
+
+ val newIdleRefreshMinutes = idleRefreshMinutes.coerceAtLeast(MINIMUM_IDLE_REFRESH_MINUTES)
+ if (newIdleRefreshMinutes != idleRefreshMinutes) {
+ migrationsHelper.writeValue(db, "$accountUuid.idleRefreshMinutes", newIdleRefreshMinutes.toString())
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo16.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo16.kt
new file mode 100644
index 0000000..c6418ba
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo16.kt
@@ -0,0 +1,18 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Change default value of `registeredNameColor` to have enough contrast in both the light and dark theme.
+ */
+class StorageMigrationTo16(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun changeDefaultRegisteredNameColor() {
+ val registeredNameColorValue = migrationsHelper.readValue(db, "registeredNameColor")?.toInt()
+ if (registeredNameColorValue == 0xFF00008F.toInt()) {
+ migrationsHelper.writeValue(db, "registeredNameColor", 0xFF1093F5.toInt().toString())
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo17.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo17.kt
new file mode 100644
index 0000000..2f4dc7d
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo17.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite 'led' and 'ledColor' values to 'notificationLight'.
+ */
+class StorageMigrationTo17(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteNotificationLightSettings() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ rewriteNotificationLightSettings(accountUuid)
+ }
+ }
+
+ private fun rewriteNotificationLightSettings(accountUuid: String) {
+ val isLedEnabled = migrationsHelper.readValue(db, "$accountUuid.led").toBoolean()
+ val ledColor = migrationsHelper.readValue(db, "$accountUuid.ledColor")?.toInt() ?: 0
+ val accountColor = migrationsHelper.readValue(db, "$accountUuid.chipColor")?.toInt() ?: 0
+
+ val notificationLight = convertToNotificationLightValue(isLedEnabled, ledColor, accountColor)
+
+ migrationsHelper.writeValue(db, "$accountUuid.notificationLight", notificationLight)
+ migrationsHelper.writeValue(db, "$accountUuid.led", null)
+ migrationsHelper.writeValue(db, "$accountUuid.ledColor", null)
+ }
+
+ private fun convertToNotificationLightValue(isLedEnabled: Boolean, ledColor: Int, accountColor: Int): String {
+ if (!isLedEnabled) return "Disabled"
+
+ return when (ledColor.rgb) {
+ accountColor.rgb -> "AccountColor"
+ 0xFFFFFF -> "White"
+ 0xFF0000 -> "Red"
+ 0x00FF00 -> "Green"
+ 0x0000FF -> "Blue"
+ 0xFFFF00 -> "Yellow"
+ 0x00FFFF -> "Cyan"
+ 0xFF00FF -> "Magenta"
+ else -> "SystemDefaultColor"
+ }
+ }
+
+ private val Int.rgb
+ get() = this and 0x00FFFFFF
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt
new file mode 100644
index 0000000..15a98f9
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite the per-network type IMAP compression settings to a single setting.
+ */
+class StorageMigrationTo18(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteImapCompressionSettings() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ rewriteImapCompressionSetting(accountUuid)
+ }
+ }
+
+ private fun rewriteImapCompressionSetting(accountUuid: String) {
+ val useCompressionWifi = migrationsHelper.readValue(db, "$accountUuid.useCompression.WIFI").toBoolean()
+ val useCompressionMobile = migrationsHelper.readValue(db, "$accountUuid.useCompression.MOBILE").toBoolean()
+ val useCompressionOther = migrationsHelper.readValue(db, "$accountUuid.useCompression.OTHER").toBoolean()
+
+ val useCompression = useCompressionWifi && useCompressionMobile && useCompressionOther
+ migrationsHelper.writeValue(db, "$accountUuid.useCompression", useCompression.toString())
+
+ migrationsHelper.writeValue(db, "$accountUuid.useCompression.WIFI", null)
+ migrationsHelper.writeValue(db, "$accountUuid.useCompression.MOBILE", null)
+ migrationsHelper.writeValue(db, "$accountUuid.useCompression.OTHER", null)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo19.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo19.kt
new file mode 100644
index 0000000..5a35b83
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo19.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+
+/**
+ * Mark Gmail accounts.
+ *
+ * Gmail has stopped allowing clients to use the Google account password to authenticate via IMAP/POP3/SMTP. We want to
+ * automatically update the server settings to use OAuth 2.0 for users who can no longer access Gmail because of the
+ * change. However, we don't want to touch accounts that are using an app-specific password and still work fine. Since
+ * we can't distinguish an app-specific password from an account password, we only switch accounts to using OAuth after
+ * an authentication failure. We usually want to avoid automatically changing the user's settings like this. So we only
+ * do it for existing accounts, and only after the first authentication failure.
+ */
+class StorageMigrationTo19(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun markGmailAccounts() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ markIfGmailAccount(accountUuid)
+ }
+ }
+
+ private fun markIfGmailAccount(accountUuid: String) {
+ val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
+ val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
+
+ val moshi = Moshi.Builder().build()
+ val adapter = moshi.adapter<Map<String, Any?>>(
+ Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)
+ )
+
+ val incomingServerSettings = adapter.fromJson(incomingServerSettingsJson) ?: return
+ val outgoingServerSettings = adapter.fromJson(outgoingServerSettingsJson) ?: return
+
+ if (incomingServerSettings["type"] == "imap" &&
+ incomingServerSettings["host"] in setOf("imap.gmail.com", "imap.googlemail.com") &&
+ incomingServerSettings["authenticationType"] != "XOAUTH2" ||
+ outgoingServerSettings["host"] in setOf("smtp.gmail.com", "smtp.googlemail.com") &&
+ outgoingServerSettings["authenticationType"] != "XOAUTH2"
+ ) {
+ migrationsHelper.insertValue(db, "$accountUuid.migrateToOAuth", "true")
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo2.java b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo2.java
new file mode 100644
index 0000000..f6c98a4
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo2.java
@@ -0,0 +1,100 @@
+package com.fsck.k9.preferences.migrations;
+
+
+import java.net.URI;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import com.fsck.k9.helper.UrlEncodingHelper;
+import com.fsck.k9.mail.filter.Base64;
+import timber.log.Timber;
+
+
+public class StorageMigrationTo2 {
+ public static void urlEncodeUserNameAndPassword(SQLiteDatabase db, StorageMigrationsHelper migrationsHelper) {
+ Timber.i("Updating preferences to urlencoded username/password");
+
+ String accountUuids = migrationsHelper.readValue(db, "accountUuids");
+ if (accountUuids != null && accountUuids.length() != 0) {
+ String[] uuids = accountUuids.split(",");
+ for (String uuid : uuids) {
+ try {
+ String storeUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".storeUri"));
+ String transportUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".transportUri"));
+
+ URI uri = new URI(transportUriStr);
+ String newUserInfo = null;
+ if (transportUriStr != null) {
+ String[] userInfoParts = uri.getUserInfo().split(":");
+
+ String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
+ String passwordEnc = "";
+ String authType = "";
+ if (userInfoParts.length > 1) {
+ passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
+ }
+ if (userInfoParts.length > 2) {
+ authType = ":" + userInfoParts[2];
+ }
+
+ newUserInfo = usernameEnc + passwordEnc + authType;
+ }
+
+ if (newUserInfo != null) {
+ URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
+ uri.getQuery(), uri.getFragment());
+ String newTransportUriStr = Base64.encode(newUri.toString());
+ migrationsHelper.writeValue(db, uuid + ".transportUri", newTransportUriStr);
+ }
+
+ uri = new URI(storeUriStr);
+ newUserInfo = null;
+ if (storeUriStr.startsWith("imap")) {
+ String[] userInfoParts = uri.getUserInfo().split(":");
+ if (userInfoParts.length == 2) {
+ String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
+ String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
+
+ newUserInfo = usernameEnc + ":" + passwordEnc;
+ } else {
+ String authType = userInfoParts[0];
+ String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
+ String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[2]);
+
+ newUserInfo = authType + ":" + usernameEnc + ":" + passwordEnc;
+ }
+ } else if (storeUriStr.startsWith("pop3")) {
+ String[] userInfoParts = uri.getUserInfo().split(":", 2);
+ String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
+
+ String passwordEnc = "";
+ if (userInfoParts.length > 1) {
+ passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
+ }
+
+ newUserInfo = usernameEnc + passwordEnc;
+ } else if (storeUriStr.startsWith("webdav")) {
+ String[] userInfoParts = uri.getUserInfo().split(":", 2);
+ String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
+
+ String passwordEnc = "";
+ if (userInfoParts.length > 1) {
+ passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
+ }
+
+ newUserInfo = usernameEnc + passwordEnc;
+ }
+
+ if (newUserInfo != null) {
+ URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
+ uri.getQuery(), uri.getFragment());
+ String newStoreUriStr = Base64.encode(newUri.toString());
+ migrationsHelper.writeValue(db, uuid + ".storeUri", newStoreUriStr);
+ }
+ } catch (Exception e) {
+ Timber.e(e, "ooops");
+ }
+ }
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo3.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo3.kt
new file mode 100644
index 0000000..e0e3062
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo3.kt
@@ -0,0 +1,44 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite folder name values of "-NONE-" to `null`
+ */
+class StorageMigrationTo3(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteFolderNone() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ rewriteAccount(accountUuid)
+ }
+ }
+
+ private fun rewriteAccount(accountUuid: String) {
+ rewriteFolderValue("$accountUuid.archiveFolderName")
+ rewriteFolderValue("$accountUuid.autoExpandFolderName")
+ rewriteFolderValue("$accountUuid.draftsFolderName")
+ rewriteFolderValue("$accountUuid.sentFolderName")
+ rewriteFolderValue("$accountUuid.spamFolderName")
+ rewriteFolderValue("$accountUuid.trashFolderName")
+ }
+
+ private fun rewriteFolderValue(key: String) {
+ val folderValue = migrationsHelper.readValue(db, key)
+ if (folderValue == OLD_FOLDER_VALUE) {
+ migrationsHelper.writeValue(db, key, NEW_FOLDER_VALUE)
+ }
+ }
+
+ companion object {
+ private const val OLD_FOLDER_VALUE = "-NONE-"
+ private val NEW_FOLDER_VALUE = null
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo4.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo4.kt
new file mode 100644
index 0000000..066306b
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo4.kt
@@ -0,0 +1,31 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Add `*FolderSelection` values of "MANUAL" for existing accounts (default for new accounts is "AUTOMATIC").
+ */
+class StorageMigrationTo4(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun insertSpecialFolderSelectionValues() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ insertSpecialFolderSelectionValues(accountUuid)
+ }
+ }
+
+ private fun insertSpecialFolderSelectionValues(accountUuid: String) {
+ migrationsHelper.insertValue(db, "$accountUuid.archiveFolderSelection", "MANUAL")
+ migrationsHelper.insertValue(db, "$accountUuid.draftsFolderSelection", "MANUAL")
+ migrationsHelper.insertValue(db, "$accountUuid.sentFolderSelection", "MANUAL")
+ migrationsHelper.insertValue(db, "$accountUuid.spamFolderSelection", "MANUAL")
+ migrationsHelper.insertValue(db, "$accountUuid.trashFolderSelection", "MANUAL")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo5.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo5.kt
new file mode 100644
index 0000000..318c54a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo5.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite frequencies lower than LOWEST_FREQUENCY_SUPPORTED
+ */
+class StorageMigrationTo5(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun fixMailCheckFrequencies() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ fixFrequencyForAccount(accountUuid)
+ }
+ }
+
+ private fun fixFrequencyForAccount(accountUuid: String) {
+ val key = "$accountUuid.automaticCheckIntervalMinutes"
+ val frequencyValue = migrationsHelper.readValue(db, key)?.toIntOrNull()
+ if (frequencyValue != null && frequencyValue > -1 && frequencyValue < LOWEST_FREQUENCY_SUPPORTED) {
+ migrationsHelper.writeValue(db, key, LOWEST_FREQUENCY_SUPPORTED.toString())
+ }
+ }
+
+ companion object {
+ // see: https://github.com/evernote/android-job/wiki/FAQ#why-cant-an-interval-be-smaller-than-15-minutes-for-periodic-jobs
+ private const val LOWEST_FREQUENCY_SUPPORTED = 15
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt
new file mode 100644
index 0000000..cbab840
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Perform legacy conversions that previously lived in `K9`.
+ */
+class StorageMigrationTo6(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun performLegacyMigrations() {
+ rewriteTheme()
+ migrateOpenPgpGlobalToAccountSettings()
+ }
+
+ private fun rewriteTheme() {
+ val theme = migrationsHelper.readValue(db, "theme")?.toInt()
+
+ // We used to save the resource ID of the theme. So convert that to the new format if necessary.
+ val newTheme = if (theme == THEME_ORDINAL_DARK || theme == android.R.style.Theme) {
+ THEME_ORDINAL_DARK
+ } else {
+ THEME_ORDINAL_LIGHT
+ }
+
+ migrationsHelper.writeValue(db, "theme", newTheme.toString())
+ }
+
+ private fun migrateOpenPgpGlobalToAccountSettings() {
+ val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
+ if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
+ return
+ }
+
+ val openPgpProvider = migrationsHelper.readValue(db, "openPgpProvider") ?: ""
+ val openPgpSupportSignOnly = migrationsHelper.readValue(db, "openPgpSupportSignOnly")?.toBoolean() ?: false
+ val openPgpHideSignOnly = (!openPgpSupportSignOnly).toString()
+
+ val accountUuids = accountUuidsListValue.split(",")
+ for (accountUuid in accountUuids) {
+ migrationsHelper.writeValue(db, "$accountUuid.openPgpProvider", openPgpProvider)
+ migrationsHelper.writeValue(db, "$accountUuid.openPgpHideSignOnly", openPgpHideSignOnly)
+ }
+
+ migrationsHelper.writeValue(db, "openPgpProvider", null)
+ migrationsHelper.writeValue(db, "openPgpSupportSignOnly", null)
+ }
+
+ companion object {
+ private const val THEME_ORDINAL_LIGHT = 0
+ private const val THEME_ORDINAL_DARK = 1
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo7.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo7.kt
new file mode 100644
index 0000000..70c9675
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo7.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite settings to use enum names instead of ordinals.
+ */
+class StorageMigrationTo7(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteEnumOrdinalsToNames() {
+ rewriteTheme()
+ rewriteMessageViewTheme()
+ rewriteMessageComposeTheme()
+ }
+
+ private fun rewriteTheme() {
+ val theme = migrationsHelper.readValue(db, "theme")?.toInt()
+ val newTheme = if (theme == THEME_ORDINAL_DARK) {
+ THEME_DARK
+ } else {
+ THEME_LIGHT
+ }
+
+ migrationsHelper.writeValue(db, "theme", newTheme)
+ }
+
+ private fun rewriteMessageViewTheme() {
+ rewriteScreenTheme("messageViewTheme")
+ }
+
+ private fun rewriteMessageComposeTheme() {
+ rewriteScreenTheme("messageComposeTheme")
+ }
+
+ private fun rewriteScreenTheme(key: String) {
+ val newTheme = when (migrationsHelper.readValue(db, key)?.toInt()) {
+ THEME_ORDINAL_DARK -> THEME_DARK
+ THEME_ORDINAL_USE_GLOBAL -> THEME_USE_GLOBAL
+ else -> THEME_LIGHT
+ }
+
+ migrationsHelper.writeValue(db, key, newTheme)
+ }
+
+ companion object {
+ private const val THEME_ORDINAL_DARK = 1
+ private const val THEME_ORDINAL_USE_GLOBAL = 2
+ private const val THEME_LIGHT = "LIGHT"
+ private const val THEME_DARK = "DARK"
+ private const val THEME_USE_GLOBAL = "USE_GLOBAL"
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo8.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo8.kt
new file mode 100644
index 0000000..7ac8e8e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo8.kt
@@ -0,0 +1,23 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite theme setting to use `FOLLOW_SYSTEM` when it's currently set to `LIGHT`.
+ */
+class StorageMigrationTo8(
+ private val db: SQLiteDatabase,
+ private val migrationsHelper: StorageMigrationsHelper
+) {
+ fun rewriteTheme() {
+ val theme = migrationsHelper.readValue(db, "theme")
+ if (theme == THEME_LIGHT) {
+ migrationsHelper.writeValue(db, "theme", THEME_FOLLOW_SYSTEM)
+ }
+ }
+
+ companion object {
+ private const val THEME_LIGHT = "LIGHT"
+ private const val THEME_FOLLOW_SYSTEM = "FOLLOW_SYSTEM"
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt
new file mode 100644
index 0000000..41ded91
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt
@@ -0,0 +1,29 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal object StorageMigrations {
+ @JvmStatic
+ fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: StorageMigrationsHelper) {
+ val oldVersion = db.version
+
+ if (oldVersion < 2) StorageMigrationTo2.urlEncodeUserNameAndPassword(db, migrationsHelper)
+ if (oldVersion < 3) StorageMigrationTo3(db, migrationsHelper).rewriteFolderNone()
+ if (oldVersion < 4) StorageMigrationTo4(db, migrationsHelper).insertSpecialFolderSelectionValues()
+ if (oldVersion < 5) StorageMigrationTo5(db, migrationsHelper).fixMailCheckFrequencies()
+ if (oldVersion < 6) StorageMigrationTo6(db, migrationsHelper).performLegacyMigrations()
+ if (oldVersion < 7) StorageMigrationTo7(db, migrationsHelper).rewriteEnumOrdinalsToNames()
+ if (oldVersion < 8) StorageMigrationTo8(db, migrationsHelper).rewriteTheme()
+ // 9: "Temporarily disable Push" is no longer necessary
+ if (oldVersion < 10) StorageMigrationTo10(db, migrationsHelper).removeSavedFolderSettings()
+ if (oldVersion < 11) StorageMigrationTo11(db, migrationsHelper).upgradeMessageViewContentFontSize()
+ if (oldVersion < 12) StorageMigrationTo12(db, migrationsHelper).removeStoreAndTransportUri()
+ if (oldVersion < 13) StorageMigrationTo13(db, migrationsHelper).renameHideSpecialAccounts()
+ if (oldVersion < 14) StorageMigrationTo14(db, migrationsHelper).disablePushFoldersForNonImapAccounts()
+ if (oldVersion < 15) StorageMigrationTo15(db, migrationsHelper).rewriteIdleRefreshInterval()
+ if (oldVersion < 16) StorageMigrationTo16(db, migrationsHelper).changeDefaultRegisteredNameColor()
+ if (oldVersion < 17) StorageMigrationTo17(db, migrationsHelper).rewriteNotificationLightSettings()
+ if (oldVersion < 18) StorageMigrationTo18(db, migrationsHelper).rewriteImapCompressionSettings()
+ if (oldVersion < 19) StorageMigrationTo19(db, migrationsHelper).markGmailAccounts()
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationsHelper.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationsHelper.kt
new file mode 100644
index 0000000..9365516
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationsHelper.kt
@@ -0,0 +1,10 @@
+package com.fsck.k9.preferences.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+interface StorageMigrationsHelper {
+ fun readAllValues(db: SQLiteDatabase): Map<String, String>
+ fun readValue(db: SQLiteDatabase, key: String): String?
+ fun writeValue(db: SQLiteDatabase, key: String, value: String?)
+ fun insertValue(db: SQLiteDatabase, key: String, value: String?)
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/ImapStoreUriDecoder.java b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/ImapStoreUriDecoder.java
new file mode 100644
index 0000000..38f3250
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/ImapStoreUriDecoder.java
@@ -0,0 +1,144 @@
+package com.fsck.k9.preferences.migrations.migration12;
+
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fsck.k9.mail.AuthType;
+import com.fsck.k9.mail.ConnectionSecurity;
+import com.fsck.k9.mail.ServerSettings;
+
+import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
+
+
+public class ImapStoreUriDecoder {
+ private static final int DEFAULT_PORT = 143;
+ private static final int DEFAULT_TLS_PORT = 993;
+
+ /**
+ * Decodes an ImapStore URI.
+ *
+ * <p>Possible forms:</p>
+ * <pre>
+ * imap://auth:user:password@server:port ConnectionSecurity.NONE
+ * imap+tls+://auth:user:password@server:port ConnectionSecurity.STARTTLS_REQUIRED
+ * imap+ssl+://auth:user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
+ * </pre>
+ *
+ * NOTE: this method expects the userinfo part of the URI to be encoded twice, due to a bug in the URI creation
+ * code.
+ *
+ * @param uri the store uri.
+ */
+ public static ServerSettings decode(String uri) {
+ String host;
+ int port;
+ ConnectionSecurity connectionSecurity;
+ AuthType authenticationType = AuthType.PLAIN;
+ String username = "";
+ String password = null;
+ String clientCertificateAlias = null;
+ String pathPrefix = null;
+ boolean autoDetectNamespace = true;
+
+ URI imapUri;
+ try {
+ imapUri = new URI(uri);
+ } catch (URISyntaxException use) {
+ throw new IllegalArgumentException("Invalid ImapStore URI", use);
+ }
+
+ String scheme = imapUri.getScheme();
+ /*
+ * Currently available schemes are:
+ * imap
+ * imap+tls+
+ * imap+ssl+
+ *
+ * The following are obsolete schemes that may be found in pre-existing
+ * settings from earlier versions or that may be found when imported. We
+ * continue to recognize them and re-map them appropriately:
+ * imap+tls
+ * imap+ssl
+ */
+ if (scheme.equals("imap")) {
+ connectionSecurity = ConnectionSecurity.NONE;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("imap+tls")) {
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("imap+ssl")) {
+ connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
+ port = DEFAULT_TLS_PORT;
+ } else {
+ throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
+ }
+
+ host = imapUri.getHost();
+
+ if (imapUri.getPort() != -1) {
+ port = imapUri.getPort();
+ }
+
+ if (imapUri.getUserInfo() != null) {
+ String userinfo = imapUri.getUserInfo();
+ String[] userInfoParts = userinfo.split(":");
+
+ if (userinfo.endsWith(":")) {
+ // Last field (password/certAlias) is empty.
+ // For imports e.g.: PLAIN:username: or username:
+ // Or XOAUTH2 where it's a valid config - XOAUTH:username:
+ if (userInfoParts.length > 1) {
+ authenticationType = AuthType.valueOf(userInfoParts[0]);
+ username = decodeUtf8(userInfoParts[1]);
+ } else {
+ username = decodeUtf8(userInfoParts[0]);
+ }
+ } else if (userInfoParts.length == 2) {
+ // Old/standard style of encoding - PLAIN auth only:
+ // username:password
+ username = decodeUtf8(userInfoParts[0]);
+ password = decodeUtf8(userInfoParts[1]);
+ } else if (userInfoParts.length == 3) {
+ // Standard encoding
+ // PLAIN:username:password
+ // EXTERNAL:username:certAlias
+ authenticationType = AuthType.valueOf(userInfoParts[0]);
+ username = decodeUtf8(userInfoParts[1]);
+
+ if (AuthType.EXTERNAL == authenticationType) {
+ clientCertificateAlias = decodeUtf8(userInfoParts[2]);
+ } else {
+ password = decodeUtf8(userInfoParts[2]);
+ }
+ }
+ }
+
+ String path = imapUri.getPath();
+ if (path != null && path.length() > 1) {
+ // Strip off the leading "/"
+ String cleanPath = path.substring(1);
+
+ if (cleanPath.length() >= 2 && cleanPath.charAt(1) == '|') {
+ autoDetectNamespace = cleanPath.charAt(0) == '1';
+ if (!autoDetectNamespace) {
+ pathPrefix = cleanPath.substring(2);
+ }
+ } else {
+ if (cleanPath.length() > 0) {
+ pathPrefix = cleanPath;
+ autoDetectNamespace = false;
+ }
+ }
+ }
+
+ Map<String, String> extra = new HashMap<>();
+ extra.put("autoDetectNamespace", Boolean.toString(autoDetectNamespace));
+ extra.put("pathPrefix", pathPrefix);
+
+ return new ServerSettings("imap", host, port, connectionSecurity, authenticationType, username,
+ password, clientCertificateAlias, extra);
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/Pop3StoreUriDecoder.java b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/Pop3StoreUriDecoder.java
new file mode 100644
index 0000000..ca84839
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/Pop3StoreUriDecoder.java
@@ -0,0 +1,106 @@
+package com.fsck.k9.preferences.migrations.migration12;
+
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import com.fsck.k9.mail.AuthType;
+import com.fsck.k9.mail.ConnectionSecurity;
+import com.fsck.k9.mail.ServerSettings;
+
+import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
+
+
+public class Pop3StoreUriDecoder {
+ private static final int DEFAULT_PORT = 110;
+ private static final int DEFAULT_TLS_PORT = 995;
+
+ /**
+ * Decodes a Pop3Store URI.
+ *
+ * <p>Possible forms:</p>
+ * <pre>
+ * pop3://authType:user:password@server:port
+ * ConnectionSecurity.NONE
+ * pop3+tls+://authType:user:password@server:port
+ * ConnectionSecurity.STARTTLS_REQUIRED
+ * pop3+ssl+://authType:user:password@server:port
+ * ConnectionSecurity.SSL_TLS_REQUIRED
+ * </pre>
+ *
+ * e.g.
+ * <pre>pop3://PLAIN:admin:pass123@example.org:12345</pre>
+ */
+ public static ServerSettings decode(String uri) {
+ String host;
+ int port;
+ ConnectionSecurity connectionSecurity;
+ String username = "";
+ String password = null;
+ String clientCertificateAlias = null;
+
+ URI pop3Uri;
+ try {
+ pop3Uri = new URI(uri);
+ } catch (URISyntaxException use) {
+ throw new IllegalArgumentException("Invalid Pop3Store URI", use);
+ }
+
+ String scheme = pop3Uri.getScheme();
+ /*
+ * Currently available schemes are:
+ * pop3
+ * pop3+tls+
+ * pop3+ssl+
+ *
+ * The following are obsolete schemes that may be found in pre-existing
+ * settings from earlier versions or that may be found when imported. We
+ * continue to recognize them and re-map them appropriately:
+ * pop3+tls
+ * pop3+ssl
+ */
+ if (scheme.equals("pop3")) {
+ connectionSecurity = ConnectionSecurity.NONE;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("pop3+tls")) {
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("pop3+ssl")) {
+ connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
+ port = DEFAULT_TLS_PORT;
+ } else {
+ throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
+ }
+
+ host = pop3Uri.getHost();
+
+ if (pop3Uri.getPort() != -1) {
+ port = pop3Uri.getPort();
+ }
+
+ AuthType authType = AuthType.PLAIN;
+ if (pop3Uri.getUserInfo() != null) {
+ int userIndex = 0, passwordIndex = 1;
+ String userinfo = pop3Uri.getUserInfo();
+ String[] userInfoParts = userinfo.split(":");
+ if (userInfoParts.length > 2 || userinfo.endsWith(":") ) {
+ // If 'userinfo' ends with ":" the password is empty. This can only happen
+ // after an account was imported (so authType and username are present).
+ userIndex++;
+ passwordIndex++;
+ authType = AuthType.valueOf(userInfoParts[0]);
+ }
+ username = decodeUtf8(userInfoParts[userIndex]);
+ if (userInfoParts.length > passwordIndex) {
+ if (authType == AuthType.EXTERNAL) {
+ clientCertificateAlias = decodeUtf8(userInfoParts[passwordIndex]);
+ } else {
+ password = decodeUtf8(userInfoParts[passwordIndex]);
+ }
+ }
+ }
+
+ return new ServerSettings("pop3", host, port, connectionSecurity, authType, username,
+ password, clientCertificateAlias);
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/SmtpTransportUriDecoder.java b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/SmtpTransportUriDecoder.java
new file mode 100644
index 0000000..010159b
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/SmtpTransportUriDecoder.java
@@ -0,0 +1,108 @@
+package com.fsck.k9.preferences.migrations.migration12;
+
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+
+import com.fsck.k9.mail.AuthType;
+import com.fsck.k9.mail.ConnectionSecurity;
+import com.fsck.k9.mail.ServerSettings;
+
+
+public class SmtpTransportUriDecoder {
+ private static final int DEFAULT_PORT = 587;
+ private static final int DEFAULT_TLS_PORT = 465;
+
+ /**
+ * Decodes a SmtpTransport URI.
+ *
+ * NOTE: In contrast to ImapStore and Pop3Store, the authType is appended at the end!
+ *
+ * <p>Possible forms:</p>
+ * <pre>
+ * smtp://user:password:auth@server:port ConnectionSecurity.NONE
+ * smtp+tls+://user:password:auth@server:port ConnectionSecurity.STARTTLS_REQUIRED
+ * smtp+ssl+://user:password:auth@server:port ConnectionSecurity.SSL_TLS_REQUIRED
+ * </pre>
+ */
+ public static ServerSettings decodeSmtpUri(String uri) {
+ String host;
+ int port;
+ ConnectionSecurity connectionSecurity;
+ AuthType authType = AuthType.PLAIN;
+ String username = "";
+ String password = null;
+ String clientCertificateAlias = null;
+
+ URI smtpUri;
+ try {
+ smtpUri = new URI(uri);
+ } catch (URISyntaxException use) {
+ throw new IllegalArgumentException("Invalid SmtpTransport URI", use);
+ }
+
+ String scheme = smtpUri.getScheme();
+ /*
+ * Currently available schemes are:
+ * smtp
+ * smtp+tls+
+ * smtp+ssl+
+ *
+ * The following are obsolete schemes that may be found in pre-existing
+ * settings from earlier versions or that may be found when imported. We
+ * continue to recognize them and re-map them appropriately:
+ * smtp+tls
+ * smtp+ssl
+ */
+ if (scheme.equals("smtp")) {
+ connectionSecurity = ConnectionSecurity.NONE;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("smtp+tls")) {
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
+ port = DEFAULT_PORT;
+ } else if (scheme.startsWith("smtp+ssl")) {
+ connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
+ port = DEFAULT_TLS_PORT;
+ } else {
+ throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
+ }
+
+ host = smtpUri.getHost();
+
+ if (smtpUri.getPort() != -1) {
+ port = smtpUri.getPort();
+ }
+
+ if (smtpUri.getUserInfo() != null) {
+ String[] userInfoParts = smtpUri.getUserInfo().split(":");
+ if (userInfoParts.length == 1) {
+ username = decodeUtf8(userInfoParts[0]);
+ } else if (userInfoParts.length == 2) {
+ username = decodeUtf8(userInfoParts[0]);
+ password = decodeUtf8(userInfoParts[1]);
+ } else if (userInfoParts.length == 3) {
+ // NOTE: In SmtpTransport URIs, the authType comes last!
+ authType = AuthType.valueOf(userInfoParts[2]);
+ username = decodeUtf8(userInfoParts[0]);
+ if (authType == AuthType.EXTERNAL) {
+ clientCertificateAlias = decodeUtf8(userInfoParts[1]);
+ } else {
+ password = decodeUtf8(userInfoParts[1]);
+ }
+ }
+ }
+
+ return new ServerSettings("smtp", host, port, connectionSecurity,
+ authType, username, password, clientCertificateAlias);
+ }
+
+ private static String decodeUtf8(String s) {
+ try {
+ return URLDecoder.decode(s, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("UTF-8 not found");
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/WebDavStoreUriDecoder.java b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/WebDavStoreUriDecoder.java
new file mode 100644
index 0000000..a368ea5
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/migration12/WebDavStoreUriDecoder.java
@@ -0,0 +1,120 @@
+package com.fsck.k9.preferences.migrations.migration12;
+
+
+import com.fsck.k9.mail.AuthType;
+import com.fsck.k9.mail.ConnectionSecurity;
+import com.fsck.k9.mail.ServerSettings;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
+
+public class WebDavStoreUriDecoder {
+
+ /**
+ * Decodes a WebDavStore URI.
+ * <p/>
+ * <p>Possible forms:</p>
+ * <pre>
+ * webdav://user:password@server:port ConnectionSecurity.NONE
+ * webdav+ssl+://user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
+ * </pre>
+ */
+ public static ServerSettings decode(String uri) {
+ String host;
+ int port;
+ ConnectionSecurity connectionSecurity;
+ String username = null;
+ String password = null;
+ String alias = null;
+ String path = null;
+ String authPath = null;
+ String mailboxPath = null;
+
+
+ URI webDavUri;
+ try {
+ webDavUri = new URI(uri);
+ } catch (URISyntaxException use) {
+ throw new IllegalArgumentException("Invalid WebDavStore URI", use);
+ }
+
+ String scheme = webDavUri.getScheme();
+ /*
+ * Currently available schemes are:
+ * webdav
+ * webdav+ssl+
+ *
+ * The following are obsolete schemes that may be found in pre-existing
+ * settings from earlier versions or that may be found when imported. We
+ * continue to recognize them and re-map them appropriately:
+ * webdav+tls
+ * webdav+tls+
+ * webdav+ssl
+ */
+ if (scheme.equals("webdav")) {
+ connectionSecurity = ConnectionSecurity.NONE;
+ } else if (scheme.startsWith("webdav+")) {
+ connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
+ } else {
+ throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
+ }
+
+ host = webDavUri.getHost();
+ if (host.startsWith("http")) {
+ String[] hostParts = host.split("://", 2);
+ if (hostParts.length > 1) {
+ host = hostParts[1];
+ }
+ }
+
+ port = webDavUri.getPort();
+
+ String userInfo = webDavUri.getUserInfo();
+ if (userInfo != null) {
+ String[] userInfoParts = userInfo.split(":");
+ username = decodeUtf8(userInfoParts[0]);
+ String userParts[] = username.split("\\\\", 2);
+
+ if (userParts.length > 1) {
+ alias = userParts[1];
+ } else {
+ alias = username;
+ }
+ if (userInfoParts.length > 1) {
+ password = decodeUtf8(userInfoParts[1]);
+ }
+ }
+
+ String[] pathParts = webDavUri.getPath().split("\\|");
+ for (int i = 0, count = pathParts.length; i < count; i++) {
+ if (i == 0) {
+ if (pathParts[0] != null &&
+ pathParts[0].length() > 1) {
+ path = pathParts[0];
+ }
+ } else if (i == 1) {
+ if (pathParts[1] != null &&
+ pathParts[1].length() > 1) {
+ authPath = pathParts[1];
+ }
+ } else if (i == 2) {
+ if (pathParts[2] != null &&
+ pathParts[2].length() > 1) {
+ mailboxPath = pathParts[2];
+ }
+ }
+ }
+
+ Map<String, String> extra = new HashMap<>();
+ extra.put("alias", alias);
+ extra.put("path", path);
+ extra.put("authPath", authPath);
+ extra.put("mailboxPath", mailboxPath);
+
+ return new ServerSettings("webdav", host, port, connectionSecurity, AuthType.PLAIN, username, password, null, extra);
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/K9SchemaDefinitionFactory.kt b/app/storage/src/main/java/com/fsck/k9/storage/K9SchemaDefinitionFactory.kt
new file mode 100644
index 0000000..17e9930
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/K9SchemaDefinitionFactory.kt
@@ -0,0 +1,13 @@
+package com.fsck.k9.storage
+
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+import com.fsck.k9.mailstore.SchemaDefinitionFactory
+
+class K9SchemaDefinitionFactory : SchemaDefinitionFactory {
+ override val databaseVersion = StoreSchemaDefinition.DB_VERSION
+
+ override fun createSchemaDefinition(migrationsHelper: MigrationsHelper): LockableDatabase.SchemaDefinition {
+ return StoreSchemaDefinition(migrationsHelper)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt b/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt
new file mode 100644
index 0000000..7aff549
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt
@@ -0,0 +1,18 @@
+package com.fsck.k9.storage
+
+import com.fsck.k9.mailstore.MessageStoreFactory
+import com.fsck.k9.mailstore.SchemaDefinitionFactory
+import com.fsck.k9.notification.NotificationStoreProvider
+import com.fsck.k9.storage.messages.K9MessageStoreFactory
+import com.fsck.k9.storage.notifications.K9NotificationStoreProvider
+import org.koin.dsl.module
+
+val storageModule = module {
+ single<SchemaDefinitionFactory> { K9SchemaDefinitionFactory() }
+ single<MessageStoreFactory> {
+ K9MessageStoreFactory(localStoreProvider = get(), storageManager = get(), basicPartInfoExtractor = get())
+ }
+ single<NotificationStoreProvider> {
+ K9NotificationStoreProvider(localStoreProvider = get())
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java b/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java
new file mode 100644
index 0000000..88764f4
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java
@@ -0,0 +1,273 @@
+package com.fsck.k9.storage;
+
+
+import android.database.sqlite.SQLiteDatabase;
+
+import com.fsck.k9.K9;
+import com.fsck.k9.mail.FolderClass;
+import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition;
+import com.fsck.k9.mailstore.MigrationsHelper;
+import com.fsck.k9.storage.migrations.Migrations;
+import timber.log.Timber;
+
+
+class StoreSchemaDefinition implements SchemaDefinition {
+ static final int DB_VERSION = 84;
+
+ private final MigrationsHelper migrationsHelper;
+
+
+ StoreSchemaDefinition(MigrationsHelper migrationsHelper) {
+ this.migrationsHelper = migrationsHelper;
+ }
+
+ @Override
+ public int getVersion() {
+ return DB_VERSION;
+ }
+
+ @Override
+ public void doDbUpgrade(final SQLiteDatabase db) {
+ try {
+ upgradeDatabase(db);
+ } catch (Exception e) {
+ if (K9.DEVELOPER_MODE) {
+ throw new Error("Exception while upgrading database", e);
+ }
+
+ Timber.e(e, "Exception while upgrading database. Resetting the DB to v0");
+ db.setVersion(0);
+ upgradeDatabase(db);
+ }
+ }
+
+ private void upgradeDatabase(final SQLiteDatabase db) {
+ Timber.i("Upgrading database from version %d to version %d", db.getVersion(), DB_VERSION);
+
+ db.beginTransaction();
+ try {
+ if (db.getVersion() > DB_VERSION) {
+ String accountUuid = migrationsHelper.getAccount().getUuid();
+ throw new AssertionError("Database downgrades are not supported. " +
+ "Please fix the account database '" + accountUuid + "' manually or " +
+ "clear app data.");
+ }
+
+ // We only support upgrades from K-9 Mail 5.301. For upgrades from earlier versions we start from scratch.
+ if (db.getVersion() < 61) {
+ dbCreateDatabaseFromScratch(db);
+ } else {
+ Migrations.upgradeDatabase(db, migrationsHelper);
+ }
+
+ db.setVersion(DB_VERSION);
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+
+ if (db.getVersion() != DB_VERSION) {
+ throw new RuntimeException("Database upgrade failed!");
+ }
+ }
+
+ private static void dbCreateDatabaseFromScratch(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS account_extra_values");
+ db.execSQL("CREATE TABLE account_extra_values (" +
+ "name TEXT NOT NULL PRIMARY KEY, " +
+ "value_text TEXT, " +
+ "value_integer INTEGER " +
+ ")");
+
+ db.execSQL("DROP TABLE IF EXISTS folders");
+ db.execSQL("CREATE TABLE folders (" +
+ "id INTEGER PRIMARY KEY," +
+ "name TEXT, " +
+ "last_updated INTEGER, " +
+ "unread_count INTEGER, " +
+ "visible_limit INTEGER, " +
+ "status TEXT, " +
+ "flagged_count INTEGER default 0, " +
+ "integrate INTEGER, " +
+ "top_group INTEGER, " +
+ "poll_class TEXT, " +
+ "push_class TEXT, " +
+ "display_class TEXT, " +
+ "notify_class TEXT default '"+ FolderClass.INHERITED.name() + "', " +
+ "more_messages TEXT default \"unknown\", " +
+ "server_id TEXT, " +
+ "local_only INTEGER, " +
+ "type TEXT DEFAULT \"regular\"" +
+ ")");
+
+ db.execSQL("DROP INDEX IF EXISTS folder_server_id");
+ db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)");
+
+ db.execSQL("DROP TABLE IF EXISTS folder_extra_values");
+ db.execSQL("CREATE TABLE folder_extra_values (" +
+ "folder_id INTEGER NOT NULL, " +
+ "name TEXT NOT NULL, " +
+ "value_text TEXT, " +
+ "value_integer INTEGER, " +
+ "PRIMARY KEY (folder_id, name)" +
+ ")");
+
+ db.execSQL("DROP TABLE IF EXISTS messages");
+ db.execSQL("CREATE TABLE messages (" +
+ "id INTEGER PRIMARY KEY, " +
+ "deleted INTEGER default 0, " +
+ "folder_id INTEGER, " +
+ "uid TEXT, " +
+ "subject TEXT, " +
+ "date INTEGER, " +
+ "flags TEXT, " +
+ "sender_list TEXT, " +
+ "to_list TEXT, " +
+ "cc_list TEXT, " +
+ "bcc_list TEXT, " +
+ "reply_to_list TEXT, " +
+ "attachment_count INTEGER, " +
+ "internal_date INTEGER, " +
+ "message_id TEXT, " +
+ "preview_type TEXT default \"none\", " +
+ "preview TEXT, " +
+ "mime_type TEXT, "+
+ "normalized_subject_hash INTEGER, " +
+ "empty INTEGER default 0, " +
+ "read INTEGER default 0, " +
+ "flagged INTEGER default 0, " +
+ "answered INTEGER default 0, " +
+ "forwarded INTEGER default 0, " +
+ "message_part_id INTEGER," +
+ "encryption_type TEXT," +
+ "new_message INTEGER DEFAULT 0" +
+ ")");
+
+ db.execSQL("DROP INDEX IF EXISTS new_messages");
+ db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)");
+
+ db.execSQL("CREATE TRIGGER new_message_reset " +
+ "AFTER UPDATE OF read ON messages " +
+ "FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " +
+ "BEGIN " +
+ "UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " +
+ "END");
+
+ db.execSQL("DROP TABLE IF EXISTS message_parts");
+ db.execSQL("CREATE TABLE message_parts (" +
+ "id INTEGER PRIMARY KEY, " +
+ "type INTEGER NOT NULL, " +
+ "root INTEGER, " +
+ "parent INTEGER NOT NULL, " +
+ "seq INTEGER NOT NULL, " +
+ "mime_type TEXT, " +
+ "decoded_body_size INTEGER, " +
+ "display_name TEXT, " +
+ "header TEXT, " +
+ "encoding TEXT, " +
+ "charset TEXT, " +
+ "data_location INTEGER NOT NULL, " +
+ "data BLOB, " +
+ "preamble TEXT, " +
+ "epilogue TEXT, " +
+ "boundary TEXT, " +
+ "content_id TEXT, " +
+ "server_extra TEXT" +
+ ")");
+
+ db.execSQL("CREATE TRIGGER set_message_part_root " +
+ "AFTER INSERT ON message_parts " +
+ "BEGIN " +
+ "UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
+ "END");
+
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)");
+ db.execSQL("DROP INDEX IF EXISTS msg_folder_id");
+ db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_empty");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_read");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_flagged");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)");
+
+ db.execSQL("DROP INDEX IF EXISTS msg_composite");
+ db.execSQL("CREATE INDEX IF NOT EXISTS msg_composite ON messages (deleted, empty,folder_id,flagged,read)");
+
+ db.execSQL("DROP INDEX IF EXISTS message_parts_root");
+ db.execSQL("CREATE INDEX IF NOT EXISTS message_parts_root ON message_parts (root)");
+
+ db.execSQL("DROP TABLE IF EXISTS threads");
+ db.execSQL("CREATE TABLE threads (" +
+ "id INTEGER PRIMARY KEY, " +
+ "message_id INTEGER, " +
+ "root INTEGER, " +
+ "parent INTEGER" +
+ ")");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_message_id");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_root");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)");
+
+ db.execSQL("DROP INDEX IF EXISTS threads_parent");
+ db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)");
+
+ db.execSQL("DROP TRIGGER IF EXISTS set_thread_root");
+ db.execSQL("CREATE TRIGGER set_thread_root " +
+ "AFTER INSERT ON threads " +
+ "BEGIN " +
+ "UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
+ "END");
+
+ db.execSQL("DROP TABLE IF EXISTS outbox_state");
+ db.execSQL("CREATE TABLE outbox_state (" +
+ "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
+ "send_state TEXT," +
+ "number_of_send_attempts INTEGER DEFAULT 0," +
+ "error_timestamp INTEGER DEFAULT 0," +
+ "error TEXT)");
+
+ db.execSQL("DROP TABLE IF EXISTS pending_commands");
+ db.execSQL("CREATE TABLE pending_commands " +
+ "(id INTEGER PRIMARY KEY, command TEXT, data TEXT)");
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_folder");
+ db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;");
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values");
+ db.execSQL("CREATE TRIGGER delete_folder_extra_values " +
+ "BEFORE DELETE ON folders " +
+ "BEGIN " +
+ "DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
+ "END;");
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_message");
+ db.execSQL("CREATE TRIGGER delete_message " +
+ "BEFORE DELETE ON messages " +
+ "BEGIN " +
+ "DELETE FROM message_parts WHERE root = OLD.message_part_id; " +
+ "DELETE FROM messages_fulltext WHERE docid = OLD.id; " +
+ "DELETE FROM threads WHERE message_id = OLD.id; " +
+ "END");
+
+ db.execSQL("DROP TABLE IF EXISTS messages_fulltext");
+ db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)");
+
+ db.execSQL("DROP TABLE IF EXISTS notifications");
+ db.execSQL("CREATE TABLE notifications (" +
+ "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
+ "notification_id INTEGER UNIQUE," +
+ "timestamp INTEGER NOT NULL" +
+ ")");
+
+ db.execSQL("DROP INDEX IF EXISTS notifications_timestamp");
+ db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)");
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/AttachmentFileManager.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/AttachmentFileManager.kt
new file mode 100644
index 0000000..5c68509
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/AttachmentFileManager.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.K9
+import com.fsck.k9.helper.FileHelper
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider
+import java.io.File
+import timber.log.Timber
+
+internal class AttachmentFileManager(
+ private val storageManager: StorageManager,
+ private val accountUuid: String
+) {
+ fun deleteFile(messagePartId: Long) {
+ val file = getAttachmentFile(messagePartId)
+ if (file.exists() && !file.delete() && K9.isDebugLoggingEnabled) {
+ Timber.w("Couldn't delete message part file: %s", file.absolutePath)
+ }
+ }
+
+ fun moveTemporaryFile(temporaryFile: File, messagePartId: Long) {
+ val destinationFile = getAttachmentFile(messagePartId)
+ FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
+ }
+
+ fun copyFile(sourceMessagePartId: Long, destinationMessagePartId: Long) {
+ val sourceFile = getAttachmentFile(sourceMessagePartId)
+ val destinationFile = getAttachmentFile(destinationMessagePartId)
+ sourceFile.copyTo(destinationFile)
+ }
+
+ fun getAttachmentFile(messagePartId: Long): File {
+ val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, InternalStorageProvider.ID)
+ return File(attachmentDirectory, messagePartId.toString())
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt
new file mode 100644
index 0000000..37e0a87
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt
@@ -0,0 +1,34 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal class CheckFolderOperations(private val lockableDatabase: LockableDatabase) {
+ fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
+ return lockableDatabase.execute(false) { database ->
+ var allIncludedInUnifiedInbox = true
+
+ performChunkedOperation(
+ arguments = folderIds,
+ argumentTransformation = Long::toString
+ ) { selectionSet, selectionArguments ->
+ if (allIncludedInUnifiedInbox) {
+ database.rawQuery(
+ "SELECT COUNT(id) FROM folders WHERE integrate = 1 AND id $selectionSet",
+ selectionArguments
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val count = cursor.getInt(0)
+ if (count != selectionArguments.size) {
+ allIncludedInUnifiedInbox = false
+ }
+ } else {
+ allIncludedInUnifiedInbox = false
+ }
+ }
+ }
+ }
+
+ allIncludedInUnifiedInbox
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperations.kt
new file mode 100644
index 0000000..8730362
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperations.kt
@@ -0,0 +1,21 @@
+package com.fsck.k9.storage.messages
+
+internal fun <T> performChunkedOperation(
+ arguments: Collection<T>,
+ argumentTransformation: (T) -> String,
+ chunkSize: Int = 500,
+ operation: (selectionSet: String, selectionArguments: Array<String>) -> Unit
+) {
+ require(arguments.isNotEmpty()) { "'arguments' must not be empty" }
+ require(chunkSize in 1..1000) { "'chunkSize' needs to be in 1..1000" }
+
+ arguments.asSequence()
+ .map(argumentTransformation)
+ .chunked(chunkSize)
+ .forEach { selectionArguments ->
+ val selectionSet = selectionArguments.indices
+ .joinToString(separator = ",", prefix = "IN (", postfix = ")") { "?" }
+
+ operation(selectionSet, selectionArguments.toTypedArray())
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/CopyMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/CopyMessageOperations.kt
new file mode 100644
index 0000000..a01b2a3
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/CopyMessageOperations.kt
@@ -0,0 +1,315 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.database.getBlobOrNull
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import com.fsck.k9.K9
+import com.fsck.k9.mailstore.LockableDatabase
+import java.util.UUID
+
+internal class CopyMessageOperations(
+ private val lockableDatabase: LockableDatabase,
+ private val attachmentFileManager: AttachmentFileManager,
+ private val threadMessageOperations: ThreadMessageOperations
+) {
+ fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
+ return lockableDatabase.execute(true) { database ->
+ val newMessageId = copyMessage(database, messageId, destinationFolderId)
+
+ copyFulltextEntry(database, newMessageId, messageId)
+
+ newMessageId
+ }
+ }
+
+ private fun copyMessage(
+ database: SQLiteDatabase,
+ messageId: Long,
+ destinationFolderId: Long
+ ): Long {
+ val rootMessagePart = copyMessageParts(database, messageId)
+
+ val threadInfo = threadMessageOperations.doMessageThreading(
+ database,
+ folderId = destinationFolderId,
+ threadHeaders = threadMessageOperations.getMessageThreadHeaders(database, messageId)
+ )
+
+ return if (threadInfo?.messageId != null) {
+ updateMessageRow(
+ database,
+ sourceMessageId = messageId,
+ destinationMessageId = threadInfo.messageId,
+ destinationFolderId,
+ rootMessagePart
+ )
+ } else {
+ val newMessageId = insertMessageRow(
+ database,
+ sourceMessageId = messageId,
+ destinationFolderId = destinationFolderId,
+ rootMessagePartId = rootMessagePart
+ )
+
+ if (threadInfo?.threadId == null) {
+ threadMessageOperations.createThreadEntry(
+ database,
+ newMessageId,
+ threadInfo?.rootId,
+ threadInfo?.parentId
+ )
+ }
+
+ newMessageId
+ }
+ }
+
+ private fun copyMessageParts(database: SQLiteDatabase, messageId: Long): Long {
+ return database.rawQuery(
+ """
+SELECT
+ message_parts.id,
+ message_parts.type,
+ message_parts.root,
+ message_parts.parent,
+ message_parts.seq,
+ message_parts.mime_type,
+ message_parts.decoded_body_size,
+ message_parts.display_name,
+ message_parts.header,
+ message_parts.encoding,
+ message_parts.charset,
+ message_parts.data_location,
+ message_parts.data,
+ message_parts.preamble,
+ message_parts.epilogue,
+ message_parts.boundary,
+ message_parts.content_id,
+ message_parts.server_extra
+FROM messages
+JOIN message_parts ON (message_parts.root = messages.message_part_id)
+WHERE messages.id = ?
+ORDER BY message_parts.seq
+ """,
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ if (!cursor.moveToNext()) error("No message part found for message with ID $messageId")
+
+ val rootMessagePart = cursor.readMessagePart()
+
+ val rootMessagePartId = writeMessagePart(
+ database = database,
+ databaseMessagePart = rootMessagePart,
+ newRootId = null,
+ newParentId = -1
+ )
+
+ val messagePartIdMapping = mutableMapOf<Long, Long>()
+ messagePartIdMapping[rootMessagePart.id] = rootMessagePartId
+
+ while (cursor.moveToNext()) {
+ val messagePart = cursor.readMessagePart()
+
+ messagePartIdMapping[messagePart.id] = writeMessagePart(
+ database = database,
+ databaseMessagePart = messagePart,
+ newRootId = rootMessagePartId,
+ newParentId = messagePartIdMapping[messagePart.parent] ?: error("parent ID not found")
+ )
+ }
+
+ rootMessagePartId
+ }
+ }
+
+ private fun writeMessagePart(
+ database: SQLiteDatabase,
+ databaseMessagePart: DatabaseMessagePart,
+ newRootId: Long?,
+ newParentId: Long
+ ): Long {
+ val values = ContentValues().apply {
+ put("type", databaseMessagePart.type)
+ put("root", newRootId)
+ put("parent", newParentId)
+ put("seq", databaseMessagePart.seq)
+ put("mime_type", databaseMessagePart.mimeType)
+ put("decoded_body_size", databaseMessagePart.decodedBodySize)
+ put("display_name", databaseMessagePart.displayName)
+ put("header", databaseMessagePart.header)
+ put("encoding", databaseMessagePart.encoding)
+ put("charset", databaseMessagePart.charset)
+ put("data_location", databaseMessagePart.dataLocation)
+ put("data", databaseMessagePart.data)
+ put("preamble", databaseMessagePart.preamble)
+ put("epilogue", databaseMessagePart.epilogue)
+ put("boundary", databaseMessagePart.boundary)
+ put("content_id", databaseMessagePart.contentId)
+ put("server_extra", databaseMessagePart.serverExtra)
+ }
+
+ val messagePartId = database.insert("message_parts", null, values)
+
+ if (databaseMessagePart.dataLocation == DataLocation.ON_DISK) {
+ attachmentFileManager.copyFile(databaseMessagePart.id, messagePartId)
+ }
+
+ return messagePartId
+ }
+
+ private fun updateMessageRow(
+ database: SQLiteDatabase,
+ sourceMessageId: Long,
+ destinationMessageId: Long,
+ destinationFolderId: Long,
+ rootMessagePartId: Long
+ ): Long {
+ val values = readMessageToContentValues(database, sourceMessageId, destinationFolderId, rootMessagePartId)
+
+ database.update("messages", values, "id = ?", arrayOf(destinationMessageId.toString()))
+ return destinationMessageId
+ }
+
+ private fun insertMessageRow(
+ database: SQLiteDatabase,
+ sourceMessageId: Long,
+ destinationFolderId: Long,
+ rootMessagePartId: Long
+ ): Long {
+ val values = readMessageToContentValues(database, sourceMessageId, destinationFolderId, rootMessagePartId)
+
+ return database.insert("messages", null, values)
+ }
+
+ private fun readMessageToContentValues(
+ database: SQLiteDatabase,
+ sourceMessageId: Long,
+ destinationFolderId: Long,
+ rootMessagePartId: Long
+ ): ContentValues {
+ val values = readMessageToContentValues(database, sourceMessageId)
+
+ return values.apply {
+ put("folder_id", destinationFolderId)
+ put("uid", K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString())
+ put("message_part_id", rootMessagePartId)
+ }
+ }
+
+ private fun copyFulltextEntry(database: SQLiteDatabase, newMessageId: Long, messageId: Long) {
+ database.execSQL(
+ "INSERT OR REPLACE INTO messages_fulltext (docid, fulltext) " +
+ "SELECT ?, fulltext FROM messages_fulltext WHERE docid = ?",
+ arrayOf(newMessageId.toString(), messageId.toString())
+ )
+ }
+
+ private fun readMessageToContentValues(database: SQLiteDatabase, messageId: Long): ContentValues {
+ return database.query(
+ "messages",
+ arrayOf(
+ "deleted",
+ "subject",
+ "date",
+ "flags",
+ "sender_list",
+ "to_list",
+ "cc_list",
+ "bcc_list",
+ "reply_to_list",
+ "attachment_count",
+ "internal_date",
+ "message_id",
+ "preview_type",
+ "preview",
+ "mime_type",
+ "normalized_subject_hash",
+ "empty",
+ "read",
+ "flagged",
+ "answered",
+ "forwarded",
+ "encryption_type"
+ ),
+ "id = ?",
+ arrayOf(messageId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (!cursor.moveToNext()) error("Message with ID $messageId not found")
+
+ ContentValues().apply {
+ put("deleted", cursor.getInt(0))
+ put("subject", cursor.getStringOrNull(1))
+ put("date", cursor.getLong(2))
+ put("flags", cursor.getStringOrNull(3))
+ put("sender_list", cursor.getStringOrNull(4))
+ put("to_list", cursor.getStringOrNull(5))
+ put("cc_list", cursor.getStringOrNull(6))
+ put("bcc_list", cursor.getStringOrNull(7))
+ put("reply_to_list", cursor.getStringOrNull(8))
+ put("attachment_count", cursor.getInt(9))
+ put("internal_date", cursor.getLong(10))
+ put("message_id", cursor.getStringOrNull(11))
+ put("preview_type", cursor.getStringOrNull(12))
+ put("preview", cursor.getStringOrNull(13))
+ put("mime_type", cursor.getStringOrNull(14))
+ put("normalized_subject_hash", cursor.getLong(15))
+ put("empty", cursor.getInt(16))
+ put("read", cursor.getInt(17))
+ put("flagged", cursor.getInt(18))
+ put("answered", cursor.getInt(19))
+ put("forwarded", cursor.getInt(20))
+ put("encryption_type", cursor.getStringOrNull(21))
+ }
+ }
+ }
+
+ private fun Cursor.readMessagePart(): DatabaseMessagePart {
+ return DatabaseMessagePart(
+ id = getLong(0),
+ type = getInt(1),
+ root = getLong(2),
+ parent = getLong(3),
+ seq = getInt(4),
+ mimeType = getString(5),
+ decodedBodySize = getLongOrNull(6),
+ displayName = getStringOrNull(7),
+ header = getBlobOrNull(8),
+ encoding = getStringOrNull(9),
+ charset = getStringOrNull(10),
+ dataLocation = getInt(11),
+ data = getBlobOrNull(12),
+ preamble = getBlobOrNull(13),
+ epilogue = getBlobOrNull(14),
+ boundary = getStringOrNull(15),
+ contentId = getStringOrNull(16),
+ serverExtra = getStringOrNull(17)
+ )
+ }
+}
+
+private class DatabaseMessagePart(
+ val id: Long,
+ val type: Int,
+ val root: Long,
+ val parent: Long,
+ val seq: Int,
+ val mimeType: String?,
+ val decodedBodySize: Long?,
+ val displayName: String?,
+ val header: ByteArray?,
+ val encoding: String?,
+ val charset: String?,
+ val dataLocation: Int,
+ val data: ByteArray?,
+ val preamble: ByteArray?,
+ val epilogue: ByteArray?,
+ val boundary: String?,
+ val contentId: String?,
+ val serverExtra: String?
+)
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/CreateFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/CreateFolderOperations.kt
new file mode 100644
index 0000000..e57d282
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/CreateFolderOperations.kt
@@ -0,0 +1,31 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import com.fsck.k9.mailstore.CreateFolderInfo
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.toDatabaseFolderType
+
+internal class CreateFolderOperations(private val lockableDatabase: LockableDatabase) {
+ fun createFolders(folders: List<CreateFolderInfo>) {
+ lockableDatabase.execute(true) { db ->
+ for (folder in folders) {
+ val folderSettings = folder.settings
+ val values = ContentValues().apply {
+ put("name", folder.name)
+ put("visible_limit", folderSettings.visibleLimit)
+ put("integrate", folderSettings.integrate)
+ put("top_group", folderSettings.inTopGroup)
+ put("poll_class", folderSettings.syncClass.name)
+ put("push_class", folderSettings.pushClass.name)
+ put("display_class", folderSettings.displayClass.name)
+ put("notify_class", folderSettings.notifyClass.name)
+ put("server_id", folder.serverId)
+ put("local_only", false)
+ put("type", folder.type.toDatabaseFolderType())
+ }
+
+ db.insert("folders", null, values)
+ }
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/DataLocation.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/DataLocation.kt
new file mode 100644
index 0000000..0afa4c1
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/DataLocation.kt
@@ -0,0 +1,3 @@
+package com.fsck.k9.storage.messages
+
+internal const val DATA_LOCATION_ON_DISK = 2
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt
new file mode 100644
index 0000000..1558190
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt
@@ -0,0 +1,40 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.StorageManager
+import timber.log.Timber
+
+internal class DatabaseOperations(
+ private val lockableDatabase: LockableDatabase,
+ val storageManager: StorageManager,
+ val accountUuid: String
+) {
+ fun getSize(): Long {
+ val storageProviderId = lockableDatabase.storageProviderId
+ val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, storageProviderId)
+
+ return lockableDatabase.execute(false) {
+ val attachmentFiles = attachmentDirectory.listFiles() ?: emptyArray()
+ val attachmentsSize = attachmentFiles.asSequence()
+ .filter { file -> file.exists() }
+ .fold(initial = 0L) { accumulatedSize, file ->
+ accumulatedSize + file.length()
+ }
+
+ val databaseFile = storageManager.getDatabase(accountUuid, storageProviderId)
+ val databaseSize = databaseFile.length()
+
+ databaseSize + attachmentsSize
+ }
+ }
+
+ fun compact() {
+ Timber.i("Before compaction size = %d", getSize())
+
+ lockableDatabase.execute(false) { database ->
+ database.execSQL("VACUUM")
+ }
+
+ Timber.i("After compaction size = %d", getSize())
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteFolderOperations.kt
new file mode 100644
index 0000000..c05c27f
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteFolderOperations.kt
@@ -0,0 +1,44 @@
+package com.fsck.k9.storage.messages
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal class DeleteFolderOperations(
+ private val lockableDatabase: LockableDatabase,
+ private val attachmentFileManager: AttachmentFileManager
+) {
+ fun deleteFolders(folderServerIds: List<String>) {
+ lockableDatabase.execute(true) { db ->
+ for (folderServerId in folderServerIds) {
+ db.deleteMessagePartFiles(folderServerId)
+ db.deleteFolder(folderServerId)
+ }
+ }
+ }
+
+ private fun SQLiteDatabase.deleteMessagePartFiles(folderServerId: String) {
+ rawQuery(
+ """
+SELECT message_parts.id
+FROM folders
+JOIN messages ON (messages.folder_id = folders.id)
+JOIN message_parts ON (
+ message_parts.root = messages.message_part_id
+ AND
+ message_parts.data_location = $DATA_LOCATION_ON_DISK
+)
+WHERE folders.server_id = ?
+ """,
+ arrayOf(folderServerId)
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val messagePartId = cursor.getLong(0)
+ attachmentFileManager.deleteFile(messagePartId)
+ }
+ }
+ }
+
+ private fun SQLiteDatabase.deleteFolder(folderServerId: String) {
+ delete("folders", "server_id = ?", arrayOf(folderServerId))
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteMessageOperations.kt
new file mode 100644
index 0000000..d39150e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/DeleteMessageOperations.kt
@@ -0,0 +1,170 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal class DeleteMessageOperations(
+ private val lockableDatabase: LockableDatabase,
+ private val attachmentFileManager: AttachmentFileManager
+) {
+ fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) {
+ for (messageServerId in messageServerIds) {
+ destroyMessage(folderId, messageServerId)
+ }
+ }
+
+ private fun destroyMessage(folderId: Long, messageServerId: String) {
+ val (messageId, rootMessagePartId, hasThreadChildren) = getMessageData(folderId, messageServerId) ?: return
+
+ lockableDatabase.execute(true) { database ->
+ database.deleteMessagePartFiles(rootMessagePartId)
+
+ if (hasThreadChildren) {
+ // We're not deleting the 'messages' row so we'll have to manually delete the associated
+ // 'message_parts' and 'messages_fulltext' rows.
+ database.deleteMessagePartRows(rootMessagePartId)
+ database.deleteFulltextIndexEntry(messageId)
+
+ // This message has children in the thread structure so we need to make it an empty message.
+ database.convertToEmptyMessage(messageId)
+ } else {
+ database.deleteMessageRows(messageId)
+ }
+ }
+ }
+
+ private fun getMessageData(folderId: Long, messageServerId: String): MessageData? {
+ return lockableDatabase.execute(false) { database ->
+ database.rawQuery(
+ """
+SELECT messages.id, messages.message_part_id, COUNT(threads2.id)
+FROM messages
+LEFT JOIN threads threads1 ON (threads1.message_id = messages.id)
+LEFT JOIN threads threads2 ON (threads2.parent = threads1.id)
+WHERE folder_id = ? AND uid = ?
+ """,
+ arrayOf(folderId.toString(), messageServerId)
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ MessageData(
+ messageId = cursor.getLong(0),
+ messagePartId = cursor.getLong(1),
+ hasThreadChildren = !cursor.isNull(2) && cursor.getInt(2) > 0
+ )
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ private fun SQLiteDatabase.deleteMessagePartFiles(rootMessagePartId: Long) {
+ query(
+ "message_parts",
+ arrayOf("id"),
+ "root = ? AND data_location = $DATA_LOCATION_ON_DISK",
+ arrayOf(rootMessagePartId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val messagePartId = cursor.getLong(0)
+ attachmentFileManager.deleteFile(messagePartId)
+ }
+ }
+ }
+
+ private fun SQLiteDatabase.deleteMessagePartRows(rootMessagePartId: Long) {
+ delete("message_parts", "root = ?", arrayOf(rootMessagePartId.toString()))
+ }
+
+ private fun SQLiteDatabase.deleteFulltextIndexEntry(messageId: Long) {
+ delete("messages_fulltext", "docid = ?", arrayOf(messageId.toString()))
+ }
+
+ private fun SQLiteDatabase.convertToEmptyMessage(messageId: Long) {
+ val values = ContentValues().apply {
+ put("deleted", 0)
+ put("empty", 1)
+ putNull("subject")
+ putNull("date")
+ putNull("flags")
+ putNull("sender_list")
+ putNull("to_list")
+ putNull("cc_list")
+ putNull("bcc_list")
+ putNull("reply_to_list")
+ putNull("attachment_count")
+ putNull("internal_date")
+ put("preview_type", "none")
+ putNull("preview")
+ putNull("mime_type")
+ putNull("normalized_subject_hash")
+ putNull("message_part_id")
+ putNull("encryption_type")
+ }
+
+ update("messages", values, "id = ?", arrayOf(messageId.toString()))
+ }
+
+ private fun SQLiteDatabase.deleteMessageRows(messageId: Long) {
+ // Delete the message and all empty parent messages that have no other children in the thread structure
+ var currentMessageId: Long? = messageId
+ while (currentMessageId != null) {
+ val nextMessageId = getEmptyThreadParent(currentMessageId)
+
+ deleteMessageRow(currentMessageId)
+
+ if (nextMessageId == null || hasThreadChildren(nextMessageId)) {
+ return
+ }
+
+ currentMessageId = nextMessageId
+ }
+ }
+
+ private fun SQLiteDatabase.getEmptyThreadParent(messageId: Long): Long? {
+ return rawQuery(
+ """
+SELECT messages.id
+FROM threads threads1
+JOIN threads threads2 ON (threads1.parent = threads2.id)
+JOIN messages ON (threads2.message_id = messages.id AND messages.empty = 1)
+WHERE threads1.message_id = ?
+ """,
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst() && !cursor.isNull(0)) {
+ cursor.getLong(0)
+ } else {
+ null
+ }
+ }
+ }
+
+ private fun SQLiteDatabase.deleteMessageRow(messageId: Long) {
+ delete("messages", "id = ?", arrayOf(messageId.toString()))
+ }
+
+ private fun SQLiteDatabase.hasThreadChildren(messageId: Long): Boolean {
+ return rawQuery(
+ """
+SELECT COUNT(threads2.id)
+FROM threads threads1
+JOIN threads threads2 ON (threads2.parent = threads1.id)
+WHERE threads1.message_id = ?
+ """,
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L
+ }
+ }
+}
+
+private data class MessageData(
+ val messageId: Long,
+ val messagePartId: Long,
+ val hasThreadChildren: Boolean
+)
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt
new file mode 100644
index 0000000..3abb91d
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt
@@ -0,0 +1,111 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal val SPECIAL_FLAGS = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
+
+internal class FlagMessageOperations(private val lockableDatabase: LockableDatabase) {
+
+ fun setFlag(messageIds: Collection<Long>, flag: Flag, set: Boolean) {
+ require(messageIds.isNotEmpty()) { "'messageIds' must not be empty" }
+
+ if (flag in SPECIAL_FLAGS) {
+ setSpecialFlags(messageIds, flag, set)
+ } else {
+ throw UnsupportedOperationException("not implemented")
+ }
+ }
+
+ fun setMessageFlag(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) {
+ when (flag) {
+ Flag.DELETED -> setBoolean(folderId, messageServerId, "deleted", set)
+ Flag.SEEN -> setBoolean(folderId, messageServerId, "read", set)
+ Flag.FLAGGED -> setBoolean(folderId, messageServerId, "flagged", set)
+ Flag.ANSWERED -> setBoolean(folderId, messageServerId, "answered", set)
+ Flag.FORWARDED -> setBoolean(folderId, messageServerId, "forwarded", set)
+ else -> rebuildFlagsColumnValue(folderId, messageServerId, flag, set)
+ }
+ }
+
+ private fun setSpecialFlags(messageIds: Collection<Long>, flag: Flag, set: Boolean) {
+ val columnName = when (flag) {
+ Flag.SEEN -> "read"
+ Flag.FLAGGED -> "flagged"
+ Flag.ANSWERED -> "answered"
+ Flag.FORWARDED -> "forwarded"
+ else -> error("Unsupported flag: $flag")
+ }
+ val columnValue = if (set) 1 else 0
+
+ val contentValues = ContentValues().apply {
+ put(columnName, columnValue)
+ }
+
+ lockableDatabase.execute(true) { database ->
+ performChunkedOperation(
+ arguments = messageIds,
+ argumentTransformation = Long::toString
+ ) { selectionSet, selectionArguments ->
+ database.update("messages", contentValues, "id $selectionSet", selectionArguments)
+ }
+ }
+ }
+
+ private fun rebuildFlagsColumnValue(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) {
+ lockableDatabase.execute(true) { database ->
+ val oldFlags = database.readFlagsColumn(folderId, messageServerId)
+
+ val newFlags = if (set) oldFlags + flag else oldFlags - flag
+ val newFlagsString = newFlags.joinToString(separator = ",")
+
+ val values = ContentValues().apply {
+ put("flags", newFlagsString)
+ }
+
+ database.update(
+ "messages",
+ values,
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId)
+ )
+ }
+ }
+
+ private fun SQLiteDatabase.readFlagsColumn(folderId: Long, messageServerId: String): Set<Flag> {
+ return query(
+ "messages",
+ arrayOf("flags"),
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) error("Message not found $folderId:$messageServerId")
+
+ if (!cursor.isNull(0)) {
+ cursor.getString(0).split(',').map { flagString -> Flag.valueOf(flagString) }.toSet()
+ } else {
+ emptySet()
+ }
+ }
+ }
+
+ private fun setBoolean(folderId: Long, messageServerId: String, columnName: String, value: Boolean) {
+ lockableDatabase.execute(false) { database ->
+ val values = ContentValues().apply {
+ put(columnName, if (value) 1 else 0)
+ }
+
+ database.update(
+ "messages",
+ values,
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId)
+ )
+ }
+ }
+}
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
new file mode 100644
index 0000000..c258dbf
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
@@ -0,0 +1,287 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.Account.FolderMode
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.FolderClass
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mail.Header
+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
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.message.extractors.BasicPartInfoExtractor
+import com.fsck.k9.search.ConditionsTreeNode
+import java.util.Date
+
+class K9MessageStore(
+ database: LockableDatabase,
+ storageManager: StorageManager,
+ basicPartInfoExtractor: BasicPartInfoExtractor,
+ accountUuid: String
+) : MessageStore {
+ private val attachmentFileManager = AttachmentFileManager(storageManager, accountUuid)
+ private val threadMessageOperations = ThreadMessageOperations()
+ private val saveMessageOperations = SaveMessageOperations(
+ database,
+ attachmentFileManager,
+ basicPartInfoExtractor,
+ threadMessageOperations
+ )
+ private val copyMessageOperations = CopyMessageOperations(database, attachmentFileManager, threadMessageOperations)
+ private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
+ 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)
+ private val checkFolderOperations = CheckFolderOperations(database)
+ private val updateFolderOperations = UpdateFolderOperations(database)
+ private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager)
+ private val keyValueStoreOperations = KeyValueStoreOperations(database)
+ private val databaseOperations = DatabaseOperations(database, storageManager, accountUuid)
+
+ override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
+ saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData)
+ }
+
+ override fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
+ return saveMessageOperations.saveLocalMessage(folderId, messageData, existingMessageId)
+ }
+
+ override fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
+ return copyMessageOperations.copyMessage(messageId, destinationFolderId)
+ }
+
+ override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
+ return moveMessageOperations.moveMessage(messageId, destinationFolderId)
+ }
+
+ override fun setFlag(messageIds: Collection<Long>, flag: Flag, set: Boolean) {
+ flagMessageOperations.setFlag(messageIds, flag, set)
+ }
+
+ override fun setMessageFlag(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) {
+ flagMessageOperations.setMessageFlag(folderId, messageServerId, flag, set)
+ }
+
+ override fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) {
+ updateMessageOperations.setNewMessageState(folderId, messageServerId, newMessage)
+ }
+
+ override fun clearNewMessageState() {
+ updateMessageOperations.clearNewMessageState()
+ }
+
+ override fun getMessageServerId(messageId: Long): String? {
+ return retrieveMessageOperations.getMessageServerId(messageId)
+ }
+
+ override fun getMessageServerIds(messageIds: Collection<Long>): Map<Long, String> {
+ return retrieveMessageOperations.getMessageServerIds(messageIds)
+ }
+
+ override fun getMessageServerIds(folderId: Long): Set<String> {
+ return retrieveMessageOperations.getMessageServerIds(folderId)
+ }
+
+ override fun isMessagePresent(folderId: Long, messageServerId: String): Boolean {
+ return retrieveMessageOperations.isMessagePresent(folderId, messageServerId)
+ }
+
+ override fun getMessageFlags(folderId: Long, messageServerId: String): Set<Flag> {
+ return retrieveMessageOperations.getMessageFlags(folderId, messageServerId)
+ }
+
+ override fun getAllMessagesAndEffectiveDates(folderId: Long): Map<String, Long?> {
+ return retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId)
+ }
+
+ override fun <T> getMessages(
+ selection: String,
+ selectionArgs: Array<String>,
+ sortOrder: String,
+ messageMapper: MessageMapper<out T?>
+ ): List<T> {
+ return retrieveMessageListOperations.getMessages(selection, selectionArgs, sortOrder, messageMapper)
+ }
+
+ override fun <T> getThreadedMessages(
+ selection: String,
+ selectionArgs: Array<String>,
+ sortOrder: String,
+ messageMapper: MessageMapper<out T?>
+ ): List<T> {
+ return retrieveMessageListOperations.getThreadedMessages(selection, selectionArgs, sortOrder, messageMapper)
+ }
+
+ override fun <T> getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper<out T?>): List<T> {
+ return retrieveMessageListOperations.getThread(threadId, sortOrder, messageMapper)
+ }
+
+ override fun getOldestMessageDate(folderId: Long): Date? {
+ return retrieveMessageOperations.getOldestMessageDate(folderId)
+ }
+
+ override fun getHeaders(folderId: Long, messageServerId: String): List<Header> {
+ return retrieveMessageOperations.getHeaders(folderId, messageServerId)
+ }
+
+ override fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>): List<Header> {
+ return retrieveMessageOperations.getHeaders(folderId, messageServerId, headerNames)
+ }
+
+ override fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) {
+ deleteMessageOperations.destroyMessages(folderId, messageServerIds)
+ }
+
+ override fun createFolders(folders: List<CreateFolderInfo>) {
+ createFolderOperations.createFolders(folders)
+ }
+
+ override fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
+ return retrieveFolderOperations.getFolder(folderId, mapper)
+ }
+
+ override fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
+ return retrieveFolderOperations.getFolder(folderServerId, mapper)
+ }
+
+ override fun <T> getFolders(excludeLocalOnly: Boolean, mapper: FolderMapper<T>): List<T> {
+ return retrieveFolderOperations.getFolders(excludeLocalOnly, mapper)
+ }
+
+ override fun <T> getDisplayFolders(
+ displayMode: FolderMode,
+ outboxFolderId: Long?,
+ mapper: FolderMapper<T>
+ ): List<T> {
+ return retrieveFolderOperations.getDisplayFolders(displayMode, outboxFolderId, mapper)
+ }
+
+ override fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
+ return checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
+ }
+
+ override fun getFolderId(folderServerId: String): Long? {
+ return retrieveFolderOperations.getFolderId(folderServerId)
+ }
+
+ override fun getFolderServerId(folderId: Long): String? {
+ return retrieveFolderOperations.getFolderServerId(folderId)
+ }
+
+ override fun getMessageCount(folderId: Long): Int {
+ return retrieveFolderOperations.getMessageCount(folderId)
+ }
+
+ override fun getUnreadMessageCount(folderId: Long): Int {
+ return retrieveFolderOperations.getUnreadMessageCount(folderId)
+ }
+
+ override fun getUnreadMessageCount(conditions: ConditionsTreeNode?): Int {
+ return retrieveFolderOperations.getUnreadMessageCount(conditions)
+ }
+
+ override fun getStarredMessageCount(conditions: ConditionsTreeNode?): Int {
+ return retrieveFolderOperations.getStarredMessageCount(conditions)
+ }
+
+ override fun getSize(): Long {
+ return databaseOperations.getSize()
+ }
+
+ override fun changeFolder(folderServerId: String, name: String, type: FolderType) {
+ updateFolderOperations.changeFolder(folderServerId, name, type)
+ }
+
+ override fun updateFolderSettings(folderDetails: FolderDetails) {
+ updateFolderOperations.updateFolderSettings(folderDetails)
+ }
+
+ override fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
+ updateFolderOperations.setIncludeInUnifiedInbox(folderId, includeInUnifiedInbox)
+ }
+
+ override fun setDisplayClass(folderId: Long, folderClass: FolderClass) {
+ updateFolderOperations.setDisplayClass(folderId, folderClass)
+ }
+
+ override fun setSyncClass(folderId: Long, folderClass: FolderClass) {
+ updateFolderOperations.setSyncClass(folderId, folderClass)
+ }
+
+ override fun setPushClass(folderId: Long, folderClass: FolderClass) {
+ updateFolderOperations.setPushClass(folderId, folderClass)
+ }
+
+ override fun setNotificationClass(folderId: Long, folderClass: FolderClass) {
+ 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)
+ }
+
+ override fun setLastChecked(folderId: Long, timestamp: Long) {
+ updateFolderOperations.setLastChecked(folderId, timestamp)
+ }
+
+ override fun setStatus(folderId: Long, status: String?) {
+ updateFolderOperations.setStatus(folderId, status)
+ }
+
+ override fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
+ updateFolderOperations.setVisibleLimit(folderId, visibleLimit)
+ }
+
+ override fun deleteFolders(folderServerIds: List<String>) {
+ deleteFolderOperations.deleteFolders(folderServerIds)
+ }
+
+ override fun getExtraString(name: String): String? {
+ return keyValueStoreOperations.getExtraString(name)
+ }
+
+ override fun setExtraString(name: String, value: String) {
+ keyValueStoreOperations.setExtraString(name, value)
+ }
+
+ override fun getExtraNumber(name: String): Long? {
+ return keyValueStoreOperations.getExtraNumber(name)
+ }
+
+ override fun setExtraNumber(name: String, value: Long) {
+ keyValueStoreOperations.setExtraNumber(name, value)
+ }
+
+ override fun getFolderExtraString(folderId: Long, name: String): String? {
+ return keyValueStoreOperations.getFolderExtraString(folderId, name)
+ }
+
+ override fun setFolderExtraString(folderId: Long, name: String, value: String?) {
+ return keyValueStoreOperations.setFolderExtraString(folderId, name, value)
+ }
+
+ override fun getFolderExtraNumber(folderId: Long, name: String): Long? {
+ return keyValueStoreOperations.getFolderExtraNumber(folderId, name)
+ }
+
+ override fun setFolderExtraNumber(folderId: Long, name: String, value: Long) {
+ return keyValueStoreOperations.setFolderExtraNumber(folderId, name, value)
+ }
+
+ override fun compact() {
+ return databaseOperations.compact()
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStoreFactory.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStoreFactory.kt
new file mode 100644
index 0000000..02ebdde
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStoreFactory.kt
@@ -0,0 +1,22 @@
+package com.fsck.k9.storage.messages
+
+import com.fsck.k9.Account
+import com.fsck.k9.mailstore.ListenableMessageStore
+import com.fsck.k9.mailstore.LocalStoreProvider
+import com.fsck.k9.mailstore.MessageStoreFactory
+import com.fsck.k9.mailstore.NotifierMessageStore
+import com.fsck.k9.mailstore.StorageManager
+import com.fsck.k9.message.extractors.BasicPartInfoExtractor
+
+class K9MessageStoreFactory(
+ private val localStoreProvider: LocalStoreProvider,
+ private val storageManager: StorageManager,
+ private val basicPartInfoExtractor: BasicPartInfoExtractor
+) : MessageStoreFactory {
+ override fun create(account: Account): ListenableMessageStore {
+ val localStore = localStoreProvider.getInstance(account)
+ val messageStore = K9MessageStore(localStore.database, storageManager, basicPartInfoExtractor, account.uuid)
+ val notifierMessageStore = NotifierMessageStore(messageStore, localStore)
+ return ListenableMessageStore(notifierMessageStore)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/KeyValueStoreOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/KeyValueStoreOperations.kt
new file mode 100644
index 0000000..cb2e4e4
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/KeyValueStoreOperations.kt
@@ -0,0 +1,131 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal class KeyValueStoreOperations(private val lockableDatabase: LockableDatabase) {
+ fun getExtraString(name: String): String? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "account_extra_values",
+ arrayOf("value_text"),
+ "name = ?",
+ arrayOf(name),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getStringOrNull(0)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun setExtraString(name: String, value: String) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("name", name)
+ put("value_text", value)
+ }
+ db.insertWithOnConflict("account_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+ }
+
+ fun getExtraNumber(name: String): Long? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "account_extra_values",
+ arrayOf("value_integer"),
+ "name = ?",
+ arrayOf(name),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getLongOrNull(0)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun setExtraNumber(name: String, value: Long) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("name", name)
+ put("value_integer", value)
+ }
+ db.insertWithOnConflict("account_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+ }
+
+ fun getFolderExtraString(folderId: Long, name: String): String? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "folder_extra_values",
+ arrayOf("value_text"),
+ "name = ? AND folder_id = ?",
+ arrayOf(name, folderId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getStringOrNull(0)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun setFolderExtraString(folderId: Long, name: String, value: String?) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("folder_id", folderId)
+ put("name", name)
+ put("value_text", value)
+ }
+ db.insertWithOnConflict("folder_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+ }
+
+ fun getFolderExtraNumber(folderId: Long, name: String): Long? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "folder_extra_values",
+ arrayOf("value_integer"),
+ "name = ? AND folder_id = ?",
+ arrayOf(name, folderId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getLongOrNull(0)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun setFolderExtraNumber(folderId: Long, name: String, value: Long) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("folder_id", folderId)
+ put("name", name)
+ put("value_integer", value)
+ }
+ db.insertWithOnConflict("folder_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/MoveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/MoveMessageOperations.kt
new file mode 100644
index 0000000..106cb3a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/MoveMessageOperations.kt
@@ -0,0 +1,132 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.getIntOrNull
+import app.k9mail.core.android.common.database.getLongOrNull
+import app.k9mail.core.android.common.database.getStringOrNull
+import com.fsck.k9.K9
+import com.fsck.k9.mailstore.LockableDatabase
+import java.util.UUID
+import timber.log.Timber
+
+internal class MoveMessageOperations(
+ private val database: LockableDatabase,
+ private val threadMessageOperations: ThreadMessageOperations
+) {
+ fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
+ Timber.d("Moving message [ID: $messageId] to folder [ID: $destinationFolderId]")
+
+ return database.execute(true) { database ->
+ val threadInfo = threadMessageOperations.createOrUpdateParentThreadEntries(database, messageId, destinationFolderId)
+ val destinationMessageId = createMessageEntry(database, messageId, destinationFolderId, threadInfo)
+ threadMessageOperations.createThreadEntryIfNecessary(database, destinationMessageId, threadInfo)
+
+ convertOriginalMessageEntryToPlaceholderEntry(database, messageId)
+ moveFulltextEntry(database, messageId, destinationMessageId)
+
+ destinationMessageId
+ }
+ }
+
+ private fun moveFulltextEntry(database: SQLiteDatabase, messageId: Long, destinationMessageId: Long) {
+ val values = ContentValues().apply {
+ put("docid", destinationMessageId)
+ }
+
+ database.update("messages_fulltext", values, "docid = ?", arrayOf(messageId.toString()))
+ }
+
+ private fun createMessageEntry(
+ database: SQLiteDatabase,
+ messageId: Long,
+ destinationFolderId: Long,
+ threadInfo: ThreadInfo?
+ ): Long {
+ val destinationUid = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
+
+ val contentValues = database.query(
+ "messages",
+ arrayOf(
+ "subject", "date", "flags", "sender_list", "to_list", "cc_list", "bcc_list", "reply_to_list",
+ "attachment_count", "internal_date", "message_id", "preview_type", "preview", "mime_type",
+ "normalized_subject_hash", "read", "flagged", "answered", "forwarded", "message_part_id",
+ "encryption_type"
+ ),
+ "id = ?",
+ arrayOf(messageId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) {
+ error("Couldn't find local message [ID: $messageId]")
+ }
+
+ ContentValues().apply {
+ put("uid", destinationUid)
+ put("folder_id", destinationFolderId)
+ put("deleted", 0)
+ put("empty", 0)
+ put("subject", cursor.getStringOrNull("subject"))
+ put("date", cursor.getLongOrNull("date"))
+ put("flags", cursor.getStringOrNull("flags"))
+ put("sender_list", cursor.getStringOrNull("sender_list"))
+ put("to_list", cursor.getStringOrNull("to_list"))
+ put("cc_list", cursor.getStringOrNull("cc_list"))
+ put("bcc_list", cursor.getStringOrNull("bcc_list"))
+ put("reply_to_list", cursor.getStringOrNull("reply_to_list"))
+ put("attachment_count", cursor.getIntOrNull("attachment_count"))
+ put("internal_date", cursor.getLongOrNull("internal_date"))
+ put("message_id", cursor.getStringOrNull("message_id"))
+ put("preview_type", cursor.getStringOrNull("preview_type"))
+ put("preview", cursor.getStringOrNull("preview"))
+ put("mime_type", cursor.getStringOrNull("mime_type"))
+ put("normalized_subject_hash", cursor.getLongOrNull("normalized_subject_hash"))
+ put("read", cursor.getIntOrNull("read"))
+ put("flagged", cursor.getIntOrNull("flagged"))
+ put("answered", cursor.getIntOrNull("answered"))
+ put("forwarded", cursor.getIntOrNull("forwarded"))
+ put("message_part_id", cursor.getLongOrNull("message_part_id"))
+ put("encryption_type", cursor.getStringOrNull("encryption_type"))
+ }
+ }
+
+ val placeHolderMessageId = threadInfo?.messageId
+ return if (placeHolderMessageId != null) {
+ database.update("messages", contentValues, "id = ?", arrayOf(placeHolderMessageId.toString()))
+ placeHolderMessageId
+ } else {
+ database.insert("messages", null, contentValues)
+ }
+ }
+
+ private fun convertOriginalMessageEntryToPlaceholderEntry(database: SQLiteDatabase, messageId: Long) {
+ val contentValues = ContentValues().apply {
+ put("deleted", 1)
+ put("empty", 0)
+ put("read", 1)
+ putNull("subject")
+ putNull("date")
+ putNull("flags")
+ putNull("sender_list")
+ putNull("to_list")
+ putNull("cc_list")
+ putNull("bcc_list")
+ putNull("reply_to_list")
+ putNull("attachment_count")
+ putNull("internal_date")
+ put("preview_type", "none")
+ putNull("preview")
+ putNull("mime_type")
+ putNull("normalized_subject_hash")
+ putNull("flagged")
+ putNull("answered")
+ putNull("forwarded")
+ putNull("message_part_id")
+ putNull("encryption_type")
+ }
+
+ database.update("messages", contentValues, "id = ?", arrayOf(messageId.toString()))
+ }
+}
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
new file mode 100644
index 0000000..c48f707
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt
@@ -0,0 +1,284 @@
+package com.fsck.k9.storage.messages
+
+import android.database.Cursor
+import androidx.core.database.getLongOrNull
+import app.k9mail.core.android.common.database.map
+import com.fsck.k9.Account.FolderMode
+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
+import com.fsck.k9.search.ConditionsTreeNode
+import com.fsck.k9.search.SqlQueryBuilder
+
+internal class RetrieveFolderOperations(private val lockableDatabase: LockableDatabase) {
+ fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
+ return getFolder(
+ selection = "id = ?",
+ selectionArguments = arrayOf(folderId.toString()),
+ mapper = mapper
+ )
+ }
+
+ fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
+ return getFolder(
+ selection = "server_id = ?",
+ selectionArguments = arrayOf(folderServerId),
+ mapper = mapper
+ )
+ }
+
+ private fun <T> getFolder(selection: String, selectionArguments: Array<String>, mapper: FolderMapper<T>): T? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "folders",
+ FOLDER_COLUMNS,
+ selection,
+ selectionArguments,
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val cursorFolderAccessor = CursorFolderAccessor(cursor)
+ mapper.map(cursorFolderAccessor)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun <T> getFolders(excludeLocalOnly: Boolean = false, mapper: FolderMapper<T>): List<T> {
+ val selection = if (excludeLocalOnly) "local_only = 0" else null
+ return lockableDatabase.execute(false) { db ->
+ db.query("folders", FOLDER_COLUMNS, selection, null, null, null, "id").use { cursor ->
+ val cursorFolderAccessor = CursorFolderAccessor(cursor)
+ cursor.map {
+ mapper.map(cursorFolderAccessor)
+ }
+ }
+ }
+ }
+
+ fun <T> getDisplayFolders(displayMode: FolderMode, outboxFolderId: Long?, mapper: FolderMapper<T>): List<T> {
+ return lockableDatabase.execute(false) { db ->
+ val displayModeSelection = getDisplayModeSelection(displayMode)
+ val outboxFolderIdOrZero = outboxFolderId ?: 0
+
+ val query =
+ """
+SELECT ${FOLDER_COLUMNS.joinToString()}, (
+ SELECT COUNT(messages.id)
+ FROM messages
+ WHERE messages.folder_id = folders.id
+ AND messages.empty = 0 AND messages.deleted = 0
+ AND (messages.read = 0 OR folders.id = ?)
+), (
+ SELECT COUNT(messages.id)
+ FROM messages
+ WHERE messages.folder_id = folders.id
+ AND messages.empty = 0 AND messages.deleted = 0
+ AND messages.flagged = 1
+)
+FROM folders
+$displayModeSelection
+ """
+
+ db.rawQuery(query, arrayOf(outboxFolderIdOrZero.toString())).use { cursor ->
+ val cursorFolderAccessor = CursorFolderAccessor(cursor)
+ cursor.map {
+ mapper.map(cursorFolderAccessor)
+ }
+ }
+ }
+ }
+
+ private fun getDisplayModeSelection(displayMode: FolderMode): String {
+ return when (displayMode) {
+ FolderMode.ALL -> {
+ ""
+ }
+ FolderMode.FIRST_CLASS -> {
+ "WHERE display_class = '${FolderClass.FIRST_CLASS.name}'"
+ }
+ FolderMode.FIRST_AND_SECOND_CLASS -> {
+ "WHERE display_class IN ('${FolderClass.FIRST_CLASS.name}', '${FolderClass.SECOND_CLASS.name}')"
+ }
+ FolderMode.NOT_SECOND_CLASS -> {
+ "WHERE display_class != '${FolderClass.SECOND_CLASS.name}'"
+ }
+ FolderMode.NONE -> {
+ throw AssertionError("Invalid folder display mode: $displayMode")
+ }
+ }
+ }
+
+ fun getFolderId(folderServerId: String): Long? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "folders",
+ arrayOf("id"),
+ "server_id = ?",
+ arrayOf(folderServerId),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) cursor.getLong(0) else null
+ }
+ }
+ }
+
+ fun getFolderServerId(folderId: Long): String? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "folders",
+ arrayOf("server_id"),
+ "id = ?",
+ arrayOf(folderId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) cursor.getString(0) else null
+ }
+ }
+ }
+
+ fun getMessageCount(folderId: Long): Int {
+ return lockableDatabase.execute(false) { db ->
+ db.rawQuery(
+ "SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND folder_id = ?",
+ arrayOf(folderId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst()) cursor.getInt(0) else 0
+ }
+ }
+ }
+
+ fun getUnreadMessageCount(folderId: Long): Int {
+ return lockableDatabase.execute(false) { db ->
+ db.rawQuery(
+ "SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND read = 0 AND folder_id = ?",
+ arrayOf(folderId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst()) cursor.getInt(0) else 0
+ }
+ }
+ }
+
+ fun getUnreadMessageCount(conditions: ConditionsTreeNode?): Int {
+ return getMessageCount(condition = "messages.read = 0", conditions)
+ }
+
+ fun getStarredMessageCount(conditions: ConditionsTreeNode?): Int {
+ return getMessageCount(condition = "messages.flagged = 1", conditions)
+ }
+
+ private fun getMessageCount(condition: String, extraConditions: ConditionsTreeNode?): Int {
+ val whereBuilder = StringBuilder()
+ val queryArgs = mutableListOf<String>()
+ SqlQueryBuilder.buildWhereClause(extraConditions, whereBuilder, queryArgs)
+
+ val where = if (whereBuilder.isNotEmpty()) "AND ($whereBuilder)" else ""
+ val selectionArgs = queryArgs.toTypedArray()
+
+ val query =
+ """
+SELECT COUNT(messages.id)
+FROM messages
+JOIN folders ON (folders.id = messages.folder_id)
+WHERE (messages.empty = 0 AND messages.deleted = 0 AND $condition) $where
+ """
+
+ return lockableDatabase.execute(false) { db ->
+ db.rawQuery(query, selectionArgs).use { cursor ->
+ if (cursor.moveToFirst()) cursor.getInt(0) else 0
+ }
+ }
+ }
+
+ fun hasMoreMessages(folderId: Long): MoreMessages {
+ return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId)
+ }
+}
+
+private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor {
+ override val id: Long
+ get() = cursor.getLong(0)
+
+ override val name: String
+ get() = cursor.getString(1)
+
+ override val type: FolderType
+ get() = cursor.getString(2).toFolderType()
+
+ override val serverId: String?
+ get() = cursor.getString(3)
+
+ override val isLocalOnly: Boolean
+ get() = cursor.getInt(4) == 1
+
+ override val isInTopGroup: Boolean
+ get() = cursor.getInt(5) == 1
+
+ override val isIntegrate: Boolean
+ get() = cursor.getInt(6) == 1
+
+ override val syncClass: FolderClass
+ get() = cursor.getString(7).toFolderClass(FolderClass.INHERITED)
+
+ override val displayClass: FolderClass
+ get() = cursor.getString(8).toFolderClass(FolderClass.NO_CLASS)
+
+ override val notifyClass: FolderClass
+ get() = cursor.getString(9).toFolderClass(FolderClass.INHERITED)
+
+ override val pushClass: FolderClass
+ get() = cursor.getString(10).toFolderClass(FolderClass.SECOND_CLASS)
+
+ override val visibleLimit: Int
+ get() = cursor.getInt(11)
+
+ override val moreMessages: MoreMessages
+ get() = MoreMessages.fromDatabaseName(cursor.getString(12))
+
+ override val lastChecked: Long?
+ get() = cursor.getLongOrNull(13)
+
+ override val unreadMessageCount: Int
+ get() = cursor.getInt(14)
+
+ override val starredMessageCount: Int
+ get() = cursor.getInt(15)
+
+ override fun serverIdOrThrow(): String {
+ return serverId ?: error("No server ID found for folder '$name' ($id)")
+ }
+}
+
+private fun String?.toFolderClass(defaultValue: FolderClass): FolderClass {
+ return if (this == null) defaultValue else FolderClass.valueOf(this)
+}
+
+private val FOLDER_COLUMNS = arrayOf(
+ "id",
+ "name",
+ "type",
+ "server_id",
+ "local_only",
+ "top_group",
+ "integrate",
+ "poll_class",
+ "display_class",
+ "notify_class",
+ "push_class",
+ "visible_limit",
+ "more_messages",
+ "last_updated"
+)
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 0000000..01b0d93
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt
@@ -0,0 +1,242 @@
+package com.fsck.k9.storage.messages
+
+import android.database.Cursor
+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<out 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
+ """,
+ 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<out 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
+ """,
+ 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<out 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
+ """,
+ 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/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
new file mode 100644
index 0000000..49e39e7
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
@@ -0,0 +1,197 @@
+package com.fsck.k9.storage.messages
+
+import androidx.core.database.getLongOrNull
+import com.fsck.k9.K9
+import com.fsck.k9.helper.mapToSet
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Header
+import com.fsck.k9.mail.internet.MimeHeader
+import com.fsck.k9.mail.message.MessageHeaderParser
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MessageNotFoundException
+import java.util.Date
+
+internal class RetrieveMessageOperations(private val lockableDatabase: LockableDatabase) {
+
+ fun getMessageServerId(messageId: Long): String? {
+ return lockableDatabase.execute(false) { database ->
+ database.query(
+ "messages",
+ arrayOf("uid"),
+ "id = ?",
+ arrayOf(messageId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getString(0)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun getMessageServerIds(messageIds: Collection<Long>): Map<Long, String> {
+ if (messageIds.isEmpty()) return emptyMap()
+
+ return lockableDatabase.execute(false) { database ->
+ val databaseIdToServerIdMapping = mutableMapOf<Long, String>()
+ performChunkedOperation(
+ arguments = messageIds,
+ argumentTransformation = Long::toString
+ ) { selectionSet, selectionArguments ->
+ database.query(
+ "messages",
+ arrayOf("id", "uid"),
+ "id $selectionSet",
+ selectionArguments,
+ null,
+ null,
+ null
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val databaseId = cursor.getLong(0)
+ val serverId = cursor.getString(1)
+
+ databaseIdToServerIdMapping[databaseId] = serverId
+ }
+ }
+ }
+
+ databaseIdToServerIdMapping
+ }
+ }
+
+ fun getMessageServerIds(folderId: Long): Set<String> {
+ return lockableDatabase.execute(false) { database ->
+ database.rawQuery(
+ "SELECT uid FROM messages" +
+ " WHERE empty = 0 AND deleted = 0 AND folder_id = ? AND uid NOT LIKE '${K9.LOCAL_UID_PREFIX}%'",
+ arrayOf(folderId.toString())
+ ).use { cursor ->
+ val result = mutableSetOf<String>()
+ while (cursor.moveToNext()) {
+ val uid = cursor.getString(0)
+ result.add(uid)
+ }
+ result
+ }
+ }
+ }
+
+ fun isMessagePresent(folderId: Long, messageServerId: String): Boolean {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "messages",
+ arrayOf("id"),
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ cursor.moveToFirst()
+ }
+ }
+ }
+
+ fun getMessageFlags(folderId: Long, messageServerId: String): Set<Flag> {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "messages",
+ arrayOf("deleted", "read", "flagged", "answered", "forwarded", "flags"),
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) error("Couldn't read flags for $folderId:$messageServerId")
+
+ val deleted = cursor.getInt(0) == 1
+ val read = cursor.getInt(1) == 1
+ val flagged = cursor.getInt(2) == 1
+ val answered = cursor.getInt(3) == 1
+ val forwarded = cursor.getInt(4) == 1
+ val flagsColumnValue = cursor.getString(5)
+
+ val otherFlags = if (flagsColumnValue.isNullOrBlank()) {
+ emptySet()
+ } else {
+ flagsColumnValue.split(',').map { Flag.valueOf(it) }
+ }
+
+ otherFlags
+ .toMutableSet()
+ .apply {
+ if (deleted) add(Flag.DELETED)
+ if (read) add(Flag.SEEN)
+ if (flagged) add(Flag.FLAGGED)
+ if (answered) add(Flag.ANSWERED)
+ if (forwarded) add(Flag.FORWARDED)
+ }
+ }
+ }
+ }
+
+ fun getAllMessagesAndEffectiveDates(folderId: Long): Map<String, Long?> {
+ return lockableDatabase.execute(false) { database ->
+ database.rawQuery(
+ "SELECT uid, date FROM messages" +
+ " WHERE empty = 0 AND deleted = 0 AND folder_id = ? AND uid NOT LIKE '${K9.LOCAL_UID_PREFIX}%'",
+ arrayOf(folderId.toString())
+ ).use { cursor ->
+ val result = mutableMapOf<String, Long?>()
+ while (cursor.moveToNext()) {
+ val uid = cursor.getString(0)
+ val date = cursor.getLongOrNull(1)
+ result[uid] = date
+ }
+ result
+ }
+ }
+ }
+
+ fun getOldestMessageDate(folderId: Long): Date? {
+ return lockableDatabase.execute(false) { database ->
+ database.rawQuery(
+ "SELECT MIN(date) FROM messages WHERE folder_id = ?",
+ arrayOf(folderId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val timestamp = cursor.getLong(0)
+ if (timestamp != 0L) Date(timestamp) else null
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>? = null): List<Header> {
+ return lockableDatabase.execute(false) { database ->
+ database.rawQuery(
+ "SELECT message_parts.header FROM messages" +
+ " LEFT JOIN message_parts ON (messages.message_part_id = message_parts.id)" +
+ " WHERE messages.folder_id = ? AND messages.uid = ?",
+ arrayOf(folderId.toString(), messageServerId)
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) throw MessageNotFoundException(folderId, messageServerId)
+
+ val headerBytes = cursor.getBlob(0)
+ val lowercaseHeaderNames = headerNames?.mapToSet(headerNames.size) { it.lowercase() }
+
+ val header = MimeHeader()
+ MessageHeaderParser.parse(headerBytes.inputStream()) { name, value ->
+ if (lowercaseHeaderNames == null || name.lowercase() in lowercaseHeaderNames) {
+ header.addRawHeader(name, value)
+ }
+ }
+
+ header.headers
+ }
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt
new file mode 100644
index 0000000..c6e95bc
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt
@@ -0,0 +1,539 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.K9
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mail.Body
+import com.fsck.k9.mail.BoundaryGenerator
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.Message.RecipientType
+import com.fsck.k9.mail.MessageDownloadState
+import com.fsck.k9.mail.Multipart
+import com.fsck.k9.mail.Part
+import com.fsck.k9.mail.filter.CountingOutputStream
+import com.fsck.k9.mail.internet.BinaryTempFileBody
+import com.fsck.k9.mail.internet.MimeHeader
+import com.fsck.k9.mail.internet.MimeUtility
+import com.fsck.k9.mail.internet.SizeAware
+import com.fsck.k9.mailstore.DatabasePreviewType
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.SaveMessageData
+import com.fsck.k9.message.extractors.BasicPartInfoExtractor
+import com.fsck.k9.message.extractors.PreviewResult.PreviewType
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.util.Stack
+import java.util.UUID
+import org.apache.commons.io.IOUtils
+import org.apache.james.mime4j.codec.Base64InputStream
+import org.apache.james.mime4j.codec.QuotedPrintableInputStream
+import org.apache.james.mime4j.util.MimeUtil
+
+internal const val MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024L
+
+internal class SaveMessageOperations(
+ private val lockableDatabase: LockableDatabase,
+ private val attachmentFileManager: AttachmentFileManager,
+ private val partInfoExtractor: BasicPartInfoExtractor,
+ private val threadMessageOperations: ThreadMessageOperations
+) {
+ fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
+ saveMessage(folderId, messageServerId, messageData)
+ }
+
+ fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
+ return if (existingMessageId == null) {
+ saveLocalMessage(folderId, messageData)
+ } else {
+ replaceLocalMessage(folderId, existingMessageId, messageData)
+ }
+ }
+
+ private fun saveLocalMessage(folderId: Long, messageData: SaveMessageData): Long {
+ val fakeServerId = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
+ return saveMessage(folderId, fakeServerId, messageData)
+ }
+
+ private fun replaceLocalMessage(folderId: Long, messageId: Long, messageData: SaveMessageData): Long {
+ return lockableDatabase.execute(true) { database ->
+ val (messageServerId, rootMessagePartId) = getLocalMessageInfo(folderId, messageId)
+
+ replaceMessage(
+ database,
+ folderId,
+ messageServerId,
+ existingMessageId = messageId,
+ existingRootMessagePartId = rootMessagePartId,
+ messageData
+ )
+
+ messageId
+ }
+ }
+
+ private fun saveMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData): Long {
+ return lockableDatabase.execute(true) { database ->
+ val message = messageData.message
+
+ val existingMessageInfo = getMessage(folderId, messageServerId)
+ return@execute if (existingMessageInfo != null) {
+ val (existingMessageId, existingRootMessagePartId) = existingMessageInfo
+ replaceMessage(
+ database,
+ folderId,
+ messageServerId,
+ existingMessageId,
+ existingRootMessagePartId,
+ messageData
+ )
+
+ existingMessageId
+ } else {
+ insertMessage(database, folderId, messageServerId, message, messageData)
+ }
+ }
+ }
+
+ private fun insertMessage(
+ database: SQLiteDatabase,
+ folderId: Long,
+ messageServerId: String,
+ message: Message,
+ messageData: SaveMessageData
+ ): Long {
+ val threadInfo = threadMessageOperations.doMessageThreading(database, folderId, message.toThreadHeaders())
+
+ val rootMessagePartId = saveMessageParts(database, message)
+ val messageId = saveMessage(
+ database,
+ folderId,
+ messageServerId,
+ rootMessagePartId,
+ messageData,
+ replaceMessageId = threadInfo?.messageId
+ )
+
+ if (threadInfo?.threadId == null) {
+ threadMessageOperations.createThreadEntry(database, messageId, threadInfo?.rootId, threadInfo?.parentId)
+ }
+
+ createOrReplaceFulltextEntry(database, messageId, messageData)
+
+ return messageId
+ }
+
+ private fun replaceMessage(
+ database: SQLiteDatabase,
+ folderId: Long,
+ messageServerId: String,
+ existingMessageId: Long,
+ existingRootMessagePartId: Long?,
+ messageData: SaveMessageData
+ ) {
+ if (existingRootMessagePartId != null) {
+ deleteMessagePartsAndDataFromDisk(database, existingRootMessagePartId)
+ }
+
+ val rootMessagePartId = saveMessageParts(database, messageData.message)
+ val messageId = saveMessage(
+ database,
+ folderId,
+ messageServerId,
+ rootMessagePartId,
+ messageData,
+ replaceMessageId = existingMessageId
+ )
+
+ createOrReplaceFulltextEntry(database, messageId, messageData)
+ }
+
+ private fun saveMessageParts(database: SQLiteDatabase, message: Message): Long {
+ val rootPartContainer = PartContainer(parentId = null, part = message)
+ val rootId = saveMessagePart(database, rootPartContainer, rootId = null, order = 0)
+
+ val partsToSave = Stack<PartContainer>()
+ addChildrenToStack(partsToSave, part = message, parentId = rootId)
+
+ var order = 1
+ while (partsToSave.isNotEmpty()) {
+ val partContainer = partsToSave.pop()
+ val messagePartId = saveMessagePart(database, partContainer, rootId, order)
+ order++
+ addChildrenToStack(partsToSave, partContainer.part, parentId = messagePartId)
+ }
+
+ return rootId
+ }
+
+ private fun saveMessagePart(
+ database: SQLiteDatabase,
+ partContainer: PartContainer,
+ rootId: Long?,
+ order: Int
+ ): Long {
+ val part = partContainer.part
+ val values = ContentValues().apply {
+ put("root", rootId)
+ put("parent", partContainer.parentId ?: -1) // -1 for compatibility with previous code
+ put("seq", order)
+ put("server_extra", part.serverExtra)
+ }
+
+ return updateOrInsertMessagePart(database, values, part, existingMessagePartId = null)
+ }
+
+ private fun updateOrInsertMessagePart(
+ database: SQLiteDatabase,
+ values: ContentValues,
+ part: Part,
+ existingMessagePartId: Long?
+ ): Long {
+ val headerBytes = getHeaderBytes(part)
+ values.put("mime_type", part.mimeType)
+ values.put("header", headerBytes)
+ values.put("type", MessagePartType.UNKNOWN)
+
+ val file: File? = when (val body = part.body) {
+ is Multipart -> multipartToContentValues(values, body)
+ is Message -> messageMarkerToContentValues(values)
+ null -> missingPartToContentValues(values, part)
+ else -> leafPartToContentValues(values, part, body)
+ }
+
+ val messagePartId = if (existingMessagePartId != null) {
+ database.update("message_parts", values, "id = ?", arrayOf(existingMessagePartId.toString()))
+ existingMessagePartId
+ } else {
+ database.insertOrThrow("message_parts", null, values)
+ }
+
+ if (file != null) {
+ attachmentFileManager.moveTemporaryFile(file, messagePartId)
+ }
+
+ return messagePartId
+ }
+
+ private fun multipartToContentValues(values: ContentValues, multipart: Multipart): File? {
+ values.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
+ values.put("preamble", multipart.preamble)
+ values.put("epilogue", multipart.epilogue)
+ values.put("boundary", multipart.boundary)
+
+ return null
+ }
+
+ private fun messageMarkerToContentValues(cv: ContentValues): File? {
+ cv.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
+
+ return null
+ }
+
+ private fun missingPartToContentValues(values: ContentValues, part: Part): File? {
+ val partInfo = partInfoExtractor.extractPartInfo(part)
+ values.put("display_name", partInfo.displayName)
+ values.put("data_location", DataLocation.MISSING)
+ values.put("decoded_body_size", partInfo.size)
+
+ if (MimeUtility.isMultipart(part.mimeType)) {
+ values.put("boundary", BoundaryGenerator.getInstance().generateBoundary())
+ }
+
+ return null
+ }
+
+ private fun leafPartToContentValues(values: ContentValues, part: Part, body: Body): File? {
+ val displayName = partInfoExtractor.extractDisplayName(part)
+ values.put("display_name", displayName)
+
+ val encoding = getTransferEncoding(part)
+ values.put("encoding", encoding)
+ values.put("content_id", part.contentId)
+
+ check(body is SizeAware) { "Body needs to implement SizeAware" }
+ val sizeAwareBody = body as SizeAware
+ val fileSize = sizeAwareBody.size
+
+ return if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) {
+ values.put("data_location", DataLocation.ON_DISK)
+ val file = writeBodyToDiskIfNecessary(part)
+ val size = decodeAndCountBytes(file, encoding, fileSize)
+ values.put("decoded_body_size", size)
+
+ file
+ } else {
+ values.put("data_location", DataLocation.IN_DATABASE)
+ val bodyData = getBodyBytes(body)
+ values.put("data", bodyData)
+ val size = decodeAndCountBytes(bodyData.inputStream(), encoding, bodyData.size.toLong())
+ values.put("decoded_body_size", size)
+
+ null
+ }
+ }
+
+ private fun writeBodyToDiskIfNecessary(part: Part): File? {
+ val body = part.body
+ return if (body is BinaryTempFileBody) {
+ body.file
+ } else {
+ writeBodyToDisk(body)
+ }
+ }
+
+ private fun writeBodyToDisk(body: Body): File? {
+ val file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory())
+ FileOutputStream(file).use { outputStream ->
+ body.writeTo(outputStream)
+ }
+
+ return file
+ }
+
+ private fun decodeAndCountBytes(file: File?, encoding: String, fallbackValue: Long): Long {
+ return FileInputStream(file).use { inputStream ->
+ decodeAndCountBytes(inputStream, encoding, fallbackValue)
+ }
+ }
+
+ private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long {
+ return try {
+ getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream ->
+ CountingOutputStream().use { countingOutputStream ->
+ IOUtils.copy(decodingInputStream, countingOutputStream)
+ countingOutputStream.count
+ }
+ }
+ } catch (e: IOException) {
+ fallbackValue
+ }
+ }
+
+ private fun getDecodingInputStream(rawInputStream: InputStream, encoding: String?): InputStream {
+ return when (encoding) {
+ MimeUtil.ENC_BASE64 -> {
+ object : Base64InputStream(rawInputStream) {
+ override fun close() {
+ super.close()
+ rawInputStream.close()
+ }
+ }
+ }
+ MimeUtil.ENC_QUOTED_PRINTABLE -> {
+ object : QuotedPrintableInputStream(rawInputStream) {
+ override fun close() {
+ super.close()
+ rawInputStream.close()
+ }
+ }
+ }
+ else -> {
+ rawInputStream
+ }
+ }
+ }
+
+ private fun getHeaderBytes(part: Part): ByteArray {
+ val output = ByteArrayOutputStream()
+ part.writeHeaderTo(output)
+
+ return output.toByteArray()
+ }
+
+ private fun getBodyBytes(body: Body): ByteArray {
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ return output.toByteArray()
+ }
+
+ private fun getTransferEncoding(part: Part): String {
+ val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull()
+ return contentTransferEncoding?.lowercase() ?: MimeUtil.ENC_7BIT
+ }
+
+ private fun addChildrenToStack(stack: Stack<PartContainer>, part: Part, parentId: Long) {
+ when (val body = part.body) {
+ is Multipart -> {
+ for (i in body.count - 1 downTo 0) {
+ val childPart = body.getBodyPart(i)
+ stack.push(PartContainer(parentId, childPart))
+ }
+ }
+ is Message -> {
+ stack.push(PartContainer(parentId, body))
+ }
+ }
+ }
+
+ private fun saveMessage(
+ database: SQLiteDatabase,
+ folderId: Long,
+ messageServerId: String,
+ rootMessagePartId: Long,
+ messageData: SaveMessageData,
+ replaceMessageId: Long?
+ ): Long {
+ val message = messageData.message
+
+ when (messageData.downloadState) {
+ MessageDownloadState.ENVELOPE -> Unit
+ MessageDownloadState.PARTIAL -> message.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
+ MessageDownloadState.FULL -> message.setFlag(Flag.X_DOWNLOADED_FULL, true)
+ }
+
+ val values = ContentValues().apply {
+ put("folder_id", folderId)
+ put("uid", messageServerId)
+ put("deleted", 0)
+ put("empty", 0)
+ put("message_part_id", rootMessagePartId)
+ put("date", messageData.date)
+ put("internal_date", messageData.internalDate)
+ put("subject", messageData.subject)
+ put("flags", message.flags.toDatabaseValue())
+ put("read", message.isSet(Flag.SEEN).toDatabaseValue())
+ put("flagged", message.isSet(Flag.FLAGGED).toDatabaseValue())
+ put("answered", message.isSet(Flag.ANSWERED).toDatabaseValue())
+ put("forwarded", message.isSet(Flag.FORWARDED).toDatabaseValue())
+ put("sender_list", Address.pack(message.from))
+ put("to_list", Address.pack(message.getRecipients(RecipientType.TO)))
+ put("cc_list", Address.pack(message.getRecipients(RecipientType.CC)))
+ put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC)))
+ put("reply_to_list", Address.pack(message.replyTo))
+ put("attachment_count", messageData.attachmentCount)
+ put("message_id", message.messageId)
+ put("mime_type", message.mimeType)
+ put("encryption_type", messageData.encryptionType)
+
+ val previewResult = messageData.previewResult
+ put("preview_type", previewResult.previewType.toDatabaseValue())
+ if (previewResult.isPreviewTextAvailable) {
+ put("preview", previewResult.previewText)
+ } else {
+ putNull("preview")
+ }
+ }
+
+ return if (replaceMessageId != null) {
+ values.put("id", replaceMessageId)
+ database.replace("messages", null, values)
+ replaceMessageId
+ } else {
+ database.insert("messages", null, values)
+ }
+ }
+
+ private fun createOrReplaceFulltextEntry(database: SQLiteDatabase, messageId: Long, messageData: SaveMessageData) {
+ val fulltext = messageData.textForSearchIndex ?: return
+
+ val values = ContentValues().apply {
+ put("docid", messageId)
+ put("fulltext", fulltext)
+ }
+
+ database.replace("messages_fulltext", null, values)
+ }
+
+ private fun getMessage(folderId: Long, messageServerId: String): Pair<Long, Long?>? {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "messages",
+ arrayOf("id", "message_part_id"),
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val messageId = cursor.getLong(0)
+ val messagePartId = cursor.getLong(1)
+ messageId to messagePartId
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ private fun getLocalMessageInfo(folderId: Long, messageId: Long): Pair<String, Long?> {
+ return lockableDatabase.execute(false) { db ->
+ db.query(
+ "messages",
+ arrayOf("uid", "message_part_id"),
+ "folder_id = ? AND id = ?",
+ arrayOf(folderId.toString(), messageId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) error("Local message not found $folderId:$messageId")
+
+ val messageServerId = cursor.getString(0)!!
+ val messagePartId = cursor.getLong(1)
+ messageServerId to messagePartId
+ }
+ }
+ }
+
+ private fun deleteMessagePartsAndDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
+ deleteMessageDataFromDisk(database, rootMessagePartId)
+ deleteMessageParts(database, rootMessagePartId)
+ }
+
+ private fun deleteMessageDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
+ database.query(
+ "message_parts",
+ arrayOf("id"),
+ "root = ? AND data_location = " + DataLocation.ON_DISK,
+ arrayOf(rootMessagePartId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val messagePartId = cursor.getLong(0)
+ attachmentFileManager.deleteFile(messagePartId)
+ }
+ }
+ }
+
+ private fun deleteMessageParts(database: SQLiteDatabase, rootMessagePartId: Long) {
+ database.delete("message_parts", "root = ?", arrayOf(rootMessagePartId.toString()))
+ }
+}
+
+private fun Set<Flag>.toDatabaseValue(): String {
+ return this
+ .filter { it !in SPECIAL_FLAGS }
+ .joinToString(separator = ",")
+}
+
+private fun Boolean.toDatabaseValue() = if (this) 1 else 0
+
+private fun PreviewType.toDatabaseValue(): String {
+ return DatabasePreviewType.fromPreviewType(this).databaseValue
+}
+
+// Note: The contents of the 'message_parts' table depend on these values.
+// TODO: currently unused, might be used for caching at a later point
+internal object MessagePartType {
+ const val UNKNOWN = 0
+}
+
+// Note: The contents of the 'message_parts' table depend on these values.
+internal object DataLocation {
+ const val MISSING = 0
+ const val IN_DATABASE = 1
+ const val ON_DISK = 2
+ const val CHILD_PART_CONTAINS_DATA = 3
+}
+
+private data class PartContainer(val parentId: Long?, val part: Part)
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt
new file mode 100644
index 0000000..e032756
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt
@@ -0,0 +1,223 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.helper.Utility
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.message.MessageHeaderParser
+
+internal class ThreadMessageOperations {
+
+ fun createOrUpdateParentThreadEntries(
+ database: SQLiteDatabase,
+ messageId: Long,
+ destinationFolderId: Long
+ ): ThreadInfo? {
+ val threadHeaders = getMessageThreadHeaders(database, messageId)
+ return doMessageThreading(database, destinationFolderId, threadHeaders)
+ }
+
+ fun getMessageThreadHeaders(database: SQLiteDatabase, messageId: Long): ThreadHeaders {
+ return database.rawQuery(
+ """
+SELECT messages.message_id, message_parts.header
+FROM messages
+LEFT JOIN message_parts ON (messages.message_part_id = message_parts.id)
+WHERE messages.id = ?
+ """,
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ if (!cursor.moveToFirst()) error("Message not found: $messageId")
+
+ val messageIdHeader = cursor.getString(0)
+ val headerBytes = cursor.getBlob(1)
+
+ var inReplyToHeader: String? = null
+ var referencesHeader: String? = null
+ if (headerBytes != null) {
+ MessageHeaderParser.parse(headerBytes.inputStream()) { name, value ->
+ when (name.lowercase()) {
+ "in-reply-to" -> inReplyToHeader = value
+ "references" -> referencesHeader = value
+ }
+ }
+ }
+
+ ThreadHeaders(messageIdHeader, inReplyToHeader, referencesHeader)
+ }
+ }
+
+ fun createThreadEntryIfNecessary(database: SQLiteDatabase, messageId: Long, threadInfo: ThreadInfo?) {
+ if (threadInfo?.threadId == null) {
+ createThreadEntry(database, messageId, threadInfo?.rootId, threadInfo?.parentId)
+ }
+ }
+
+ fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
+ val values = ContentValues().apply {
+ put("message_id", messageId)
+ put("root", rootId)
+ put("parent", parentId)
+ }
+
+ return database.insert("threads", null, values)
+ }
+
+ // TODO: Use MessageIdParser
+ fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo? {
+ val messageIdHeader = threadHeaders.messageIdHeader
+ val msgThreadInfo = getThreadInfo(database, folderId, messageIdHeader, onlyEmpty = true)
+
+ val references = threadHeaders.referencesHeader.extractMessageIdValues()
+ val inReplyTo = threadHeaders.inReplyToHeader.extractMessageIdValue()
+
+ val messageIdValues = if (inReplyTo == null || inReplyTo in references) {
+ references
+ } else {
+ references + inReplyTo
+ }
+
+ if (messageIdValues.isEmpty()) {
+ // This is not a reply, nothing to do for us.
+ return msgThreadInfo
+ }
+
+ var rootId: Long? = null
+ var parentId: Long? = null
+ for (reference in messageIdValues) {
+ val threadInfo = getThreadInfo(database, folderId, reference, onlyEmpty = false)
+ if (threadInfo == null) {
+ parentId = createEmptyMessage(database, folderId, reference, rootId, parentId)
+ if (rootId == null) {
+ rootId = parentId
+ }
+ } else {
+ if (rootId == null) {
+ rootId = threadInfo.rootId
+ } else if (threadInfo.rootId != rootId) {
+ // Merge this thread into our thread
+ updateThreadToNewRoot(database, threadInfo.rootId, rootId, parentId)
+ }
+ parentId = threadInfo.threadId
+ }
+ }
+
+ msgThreadInfo?.threadId?.let { threadId ->
+ // msgThreadInfo.rootId might be outdated. Fetch current value.
+ val oldRootId = getThreadRoot(database, threadId)
+ if (oldRootId != rootId) {
+ // Connect the existing thread to the newly created thread
+ updateThreadToNewRoot(database, oldRootId, rootId!!, parentId)
+ }
+ }
+
+ return ThreadInfo(msgThreadInfo?.threadId, msgThreadInfo?.messageId, rootId!!, parentId)
+ }
+
+ private fun updateThreadToNewRoot(database: SQLiteDatabase, oldRootId: Long, rootId: Long, parentId: Long?) {
+ // Let all children know who's the new root
+ val values = ContentValues()
+ values.put("root", rootId)
+ database.update("threads", values, "root = ?", arrayOf(oldRootId.toString()))
+
+ // Connect the message to the current parent
+ values.put("parent", parentId)
+ database.update("threads", values, "id = ?", arrayOf(oldRootId.toString()))
+ }
+
+ private fun createEmptyMessage(
+ database: SQLiteDatabase,
+ folderId: Long,
+ messageIdHeader: String,
+ rootId: Long?,
+ parentId: Long?
+ ): Long {
+ val messageValues = ContentValues().apply {
+ put("message_id", messageIdHeader)
+ put("folder_id", folderId)
+ put("empty", 1)
+ }
+ val messageId = database.insert("messages", null, messageValues)
+
+ val threadValues = ContentValues().apply {
+ put("message_id", messageId)
+ put("root", rootId)
+ put("parent", parentId)
+ }
+ return database.insert("threads", null, threadValues)
+ }
+
+ private fun getThreadInfo(
+ db: SQLiteDatabase,
+ folderId: Long,
+ messageIdHeader: String?,
+ onlyEmpty: Boolean
+ ): ThreadInfo? {
+ if (messageIdHeader == null) return null
+
+ return db.rawQuery(
+ """
+SELECT t.id, t.message_id, t.root, t.parent
+FROM messages m
+LEFT JOIN threads t ON (t.message_id = m.id)
+WHERE m.folder_id = ? AND m.message_id = ?
+${if (onlyEmpty) "AND m.empty = 1 " else ""}
+ORDER BY m.id
+LIMIT 1
+ """,
+ arrayOf(folderId.toString(), messageIdHeader)
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val threadId = cursor.getLong(0)
+ val messageId = cursor.getLong(1)
+ val rootId = cursor.getLong(2)
+ val parentId = if (cursor.isNull(3)) null else cursor.getLong(3)
+ ThreadInfo(threadId, messageId, rootId, parentId)
+ } else {
+ null
+ }
+ }
+ }
+
+ private fun getThreadRoot(database: SQLiteDatabase, threadId: Long): Long {
+ return database.rawQuery(
+ "SELECT root FROM threads WHERE id = ?",
+ arrayOf(threadId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getLong(0)
+ } else {
+ error("Thread with ID $threadId not found")
+ }
+ }
+ }
+
+ private fun String?.extractMessageIdValues(): List<String> {
+ return this?.let { headerValue -> Utility.extractMessageIds(headerValue) } ?: emptyList()
+ }
+
+ private fun String?.extractMessageIdValue(): String? {
+ return this?.let { headerValue -> Utility.extractMessageId(headerValue) }
+ }
+}
+
+internal data class ThreadInfo(
+ val threadId: Long?,
+ val messageId: Long?,
+ val rootId: Long,
+ val parentId: Long?
+)
+
+internal data class ThreadHeaders(
+ val messageIdHeader: String?,
+ val inReplyToHeader: String?,
+ val referencesHeader: String?
+)
+
+internal fun Message.toThreadHeaders(): ThreadHeaders {
+ return ThreadHeaders(
+ messageIdHeader = messageId,
+ inReplyToHeader = getHeader("In-Reply-To").firstOrNull(),
+ referencesHeader = getHeader("References").firstOrNull()
+ )
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt
new file mode 100644
index 0000000..5d42aae
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt
@@ -0,0 +1,105 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import com.fsck.k9.mail.FolderClass
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mailstore.FolderDetails
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.mailstore.MoreMessages
+import com.fsck.k9.mailstore.toDatabaseFolderType
+
+internal class UpdateFolderOperations(private val lockableDatabase: LockableDatabase) {
+ fun changeFolder(folderServerId: String, name: String, type: FolderType) {
+ lockableDatabase.execute(false) { db ->
+ val values = ContentValues().apply {
+ put("name", name)
+ put("type", type.toDatabaseFolderType())
+ }
+
+ db.update("folders", values, "server_id = ?", arrayOf(folderServerId))
+ }
+ }
+
+ fun updateFolderSettings(folderDetails: FolderDetails) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("top_group", folderDetails.isInTopGroup)
+ put("integrate", folderDetails.isIntegrate)
+ put("poll_class", folderDetails.syncClass.name)
+ put("display_class", folderDetails.displayClass.name)
+ put("notify_class", folderDetails.notifyClass.name)
+ put("push_class", folderDetails.pushClass.name)
+ }
+
+ db.update("folders", contentValues, "id = ?", arrayOf(folderDetails.folder.id.toString()))
+ }
+ }
+
+ fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("integrate", includeInUnifiedInbox)
+ }
+
+ db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
+ }
+ }
+
+ fun setDisplayClass(folderId: Long, folderClass: FolderClass) {
+ setString(folderId = folderId, columnName = "display_class", value = folderClass.name)
+ }
+
+ fun setSyncClass(folderId: Long, folderClass: FolderClass) {
+ setString(folderId = folderId, columnName = "poll_class", value = folderClass.name)
+ }
+
+ fun setPushClass(folderId: Long, folderClass: FolderClass) {
+ setString(folderId = folderId, columnName = "push_class", value = folderClass.name)
+ }
+
+ fun setNotificationClass(folderId: Long, folderClass: FolderClass) {
+ setString(folderId = folderId, columnName = "notify_class", value = folderClass.name)
+ }
+
+ fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
+ setString(folderId = folderId, columnName = "more_messages", value = moreMessages.databaseName)
+ }
+
+ fun setLastChecked(folderId: Long, timestamp: Long) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("last_updated", timestamp)
+ }
+
+ db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
+ }
+ }
+
+ fun setStatus(folderId: Long, status: String?) {
+ setString(folderId = folderId, columnName = "status", value = status)
+ }
+
+ fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ put("visible_limit", visibleLimit)
+ }
+
+ db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
+ }
+ }
+
+ private fun setString(folderId: Long, columnName: String, value: String?) {
+ lockableDatabase.execute(false) { db ->
+ val contentValues = ContentValues().apply {
+ if (value == null) {
+ putNull(columnName)
+ } else {
+ put(columnName, value)
+ }
+ }
+
+ db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt
new file mode 100644
index 0000000..9c5891e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt
@@ -0,0 +1,28 @@
+package com.fsck.k9.storage.messages
+
+import android.content.ContentValues
+import com.fsck.k9.mailstore.LockableDatabase
+
+internal class UpdateMessageOperations(private val lockableDatabase: LockableDatabase) {
+
+ fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) {
+ lockableDatabase.execute(false) { database ->
+ val values = ContentValues().apply {
+ put("new_message", if (newMessage) 1 else 0)
+ }
+
+ database.update(
+ "messages",
+ values,
+ "folder_id = ? AND uid = ?",
+ arrayOf(folderId.toString(), messageServerId)
+ )
+ }
+ }
+
+ fun clearNewMessageState() {
+ lockableDatabase.execute(false) { database ->
+ database.execSQL("UPDATE messages SET new_message = 0")
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingAppend.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingAppend.java
new file mode 100644
index 0000000..e2c8b90
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingAppend.java
@@ -0,0 +1,6 @@
+package com.fsck.k9.storage.migrations;
+
+class LegacyPendingAppend extends LegacyPendingCommand {
+ public String folder;
+ public String uid;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingCommand.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingCommand.java
new file mode 100644
index 0000000..27465ff
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingCommand.java
@@ -0,0 +1,4 @@
+package com.fsck.k9.storage.migrations;
+
+abstract class LegacyPendingCommand {
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingDelete.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingDelete.java
new file mode 100644
index 0000000..7c5127e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingDelete.java
@@ -0,0 +1,24 @@
+package com.fsck.k9.storage.migrations;
+
+import java.util.List;
+
+import static com.fsck.k9.controller.Preconditions.requireNotNull;
+import static com.fsck.k9.controller.Preconditions.requireValidUids;
+
+
+class LegacyPendingDelete extends LegacyPendingCommand {
+ public final String folder;
+ public final List<String> uids;
+
+
+ static LegacyPendingDelete create(String folder, List<String> uids) {
+ requireNotNull(folder);
+ requireValidUids(uids);
+ return new LegacyPendingDelete(folder, uids);
+ }
+
+ private LegacyPendingDelete(String folder, List<String> uids) {
+ this.folder = folder;
+ this.uids = uids;
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingExpunge.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingExpunge.java
new file mode 100644
index 0000000..78350dd
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingExpunge.java
@@ -0,0 +1,5 @@
+package com.fsck.k9.storage.migrations;
+
+class LegacyPendingExpunge extends LegacyPendingCommand {
+ public String folder;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMarkAllAsRead.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMarkAllAsRead.java
new file mode 100644
index 0000000..731a912
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMarkAllAsRead.java
@@ -0,0 +1,5 @@
+package com.fsck.k9.storage.migrations;
+
+class LegacyPendingMarkAllAsRead extends LegacyPendingCommand {
+ public String folder;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveAndMarkAsRead.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveAndMarkAsRead.java
new file mode 100644
index 0000000..07e6718
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveAndMarkAsRead.java
@@ -0,0 +1,9 @@
+package com.fsck.k9.storage.migrations;
+
+import java.util.Map;
+
+class LegacyPendingMoveAndMarkAsRead extends LegacyPendingCommand {
+ public String srcFolder;
+ public String destFolder;
+ public Map<String, String> newUidMap;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveOrCopy.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveOrCopy.java
new file mode 100644
index 0000000..e6792ca
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingMoveOrCopy.java
@@ -0,0 +1,10 @@
+package com.fsck.k9.storage.migrations;
+
+import java.util.Map;
+
+class LegacyPendingMoveOrCopy extends LegacyPendingCommand {
+ public String srcFolder;
+ public String destFolder;
+ public boolean isCopy;
+ public Map<String, String> newUidMap;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingSetFlag.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingSetFlag.java
new file mode 100644
index 0000000..92473fc
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/LegacyPendingSetFlag.java
@@ -0,0 +1,12 @@
+package com.fsck.k9.storage.migrations;
+
+import com.fsck.k9.mail.Flag;
+
+import java.util.List;
+
+class LegacyPendingSetFlag extends LegacyPendingCommand {
+ public String folder;
+ public boolean newState;
+ public Flag flag;
+ public List<String> uids;
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo62.java b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo62.java
new file mode 100644
index 0000000..ebd492d
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo62.java
@@ -0,0 +1,15 @@
+package com.fsck.k9.storage.migrations;
+
+
+import android.database.sqlite.SQLiteDatabase;
+
+
+class MigrationTo62 {
+ public static void addServerIdColumnToFoldersTable(SQLiteDatabase db) {
+ db.execSQL("ALTER TABLE folders ADD server_id TEXT");
+ db.execSQL("UPDATE folders SET server_id = name");
+
+ db.execSQL("DROP INDEX IF EXISTS folder_name");
+ db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)");
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo64.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo64.kt
new file mode 100644
index 0000000..d79117a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo64.kt
@@ -0,0 +1,34 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal object MigrationTo64 {
+ @JvmStatic
+ fun addExtraValuesTables(db: SQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE account_extra_values (" +
+ "name TEXT NOT NULL PRIMARY KEY, " +
+ "value_text TEXT, " +
+ "value_integer INTEGER " +
+ ")"
+ )
+
+ db.execSQL(
+ "CREATE TABLE folder_extra_values (" +
+ "folder_id INTEGER NOT NULL, " +
+ "name TEXT NOT NULL, " +
+ "value_text TEXT, " +
+ "value_integer INTEGER, " +
+ "PRIMARY KEY (folder_id, name)" +
+ ")"
+ )
+
+ db.execSQL(
+ "CREATE TRIGGER delete_folder_extra_values " +
+ "BEFORE DELETE ON folders " +
+ "BEGIN " +
+ "DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
+ "END;"
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo65.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo65.kt
new file mode 100644
index 0000000..a93ed68
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo65.kt
@@ -0,0 +1,22 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+import com.fsck.k9.preferences.Protocols
+
+internal object MigrationTo65 {
+ @JvmStatic
+ fun addLocalOnlyColumnToFoldersTable(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
+ db.execSQL("ALTER TABLE folders ADD local_only INTEGER")
+
+ if (isPop3Account(migrationsHelper)) {
+ db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'INBOX' THEN 0 ELSE 1 END")
+ } else {
+ db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'K9MAIL_INTERNAL_OUTBOX' THEN 1 ELSE 0 END")
+ }
+ }
+
+ private fun isPop3Account(migrationsHelper: MigrationsHelper): Boolean {
+ return migrationsHelper.account.incomingServerSettings.type == Protocols.POP3
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo66.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo66.kt
new file mode 100644
index 0000000..84be1a7
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo66.kt
@@ -0,0 +1,12 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal object MigrationTo66 {
+ @JvmStatic
+ fun addEncryptionTypeColumnToMessagesTable(db: SQLiteDatabase) {
+ db.execSQL("ALTER TABLE messages ADD encryption_type TEXT")
+
+ db.execSQL("UPDATE messages SET encryption_type = 'openpgp' WHERE preview_type = 'encrypted'")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo67.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo67.kt
new file mode 100644
index 0000000..2039218
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo67.kt
@@ -0,0 +1,26 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+
+internal object MigrationTo67 {
+ @JvmStatic
+ fun addTypeColumnToFoldersTable(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
+ db.execSQL("ALTER TABLE folders ADD type TEXT DEFAULT \"regular\"")
+
+ val account = migrationsHelper.account
+ setFolderType(db, account.legacyInboxFolder, "inbox")
+ setFolderType(db, "K9MAIL_INTERNAL_OUTBOX", "outbox")
+ setFolderType(db, account.importedTrashFolder, "trash")
+ setFolderType(db, account.importedDraftsFolder, "drafts")
+ setFolderType(db, account.importedSpamFolder, "spam")
+ setFolderType(db, account.importedSentFolder, "sent")
+ setFolderType(db, account.importedArchiveFolder, "archive")
+ }
+
+ private fun setFolderType(db: SQLiteDatabase, serverId: String?, type: String) {
+ if (serverId != null) {
+ db.execSQL("UPDATE folders SET type = ? WHERE server_id = ?", arrayOf(type, serverId))
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo68.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo68.kt
new file mode 100644
index 0000000..1c3774c
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo68.kt
@@ -0,0 +1,33 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal object MigrationTo68 {
+ @JvmStatic
+ fun addOutboxStateTable(db: SQLiteDatabase) {
+ createOutboxStateTable(db)
+ createOutboxStateEntries(db)
+ }
+
+ private fun createOutboxStateTable(db: SQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE outbox_state (" +
+ "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
+ "send_state TEXT," +
+ "number_of_send_attempts INTEGER DEFAULT 0," +
+ "error_timestamp INTEGER DEFAULT 0," +
+ "error TEXT)"
+ )
+ }
+
+ private fun createOutboxStateEntries(db: SQLiteDatabase) {
+ db.execSQL(
+ """
+ INSERT INTO outbox_state (message_id, send_state)
+ SELECT messages.id, 'ready' FROM folders
+ JOIN messages ON (folders.id = messages.folder_id)
+ WHERE folders.server_id = 'K9MAIL_INTERNAL_OUTBOX'
+ """.trimIndent()
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo69.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo69.kt
new file mode 100644
index 0000000..2042132
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo69.kt
@@ -0,0 +1,39 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mail.Flag
+import com.squareup.moshi.Moshi
+
+internal class MigrationTo69(private val db: SQLiteDatabase) {
+ fun createPendingDelete() {
+ val moshi = Moshi.Builder().build()
+ val pendingSetFlagAdapter = moshi.adapter(LegacyPendingSetFlag::class.java)
+
+ val pendingSetFlagsToConvert = mutableMapOf<Long, LegacyPendingSetFlag>()
+
+ db.rawQuery("SELECT id, data FROM pending_commands WHERE command = 'set_flag'", null).use { cursor ->
+ while (cursor.moveToNext()) {
+ val databaseId = cursor.getLong(0)
+ val data = cursor.getString(1)
+
+ val pendingSetFlag = pendingSetFlagAdapter.fromJson(data) ?: error("Can't deserialize pending command")
+ if (pendingSetFlag.flag == Flag.DELETED && pendingSetFlag.newState) {
+ pendingSetFlagsToConvert[databaseId] = pendingSetFlag
+ }
+ }
+ }
+
+ val pendingDeleteAdapter = moshi.adapter(LegacyPendingDelete::class.java)
+
+ for ((databaseId, pendingSetFlag) in pendingSetFlagsToConvert) {
+ val pendingDelete = LegacyPendingDelete.create(pendingSetFlag.folder, pendingSetFlag.uids)
+ val contentValues = ContentValues().apply {
+ put("command", "delete")
+ put("data", pendingDeleteAdapter.toJson(pendingDelete))
+ }
+
+ db.update("pending_commands", contentValues, "id = ?", arrayOf(databaseId.toString()))
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo70.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo70.kt
new file mode 100644
index 0000000..8c041a2
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo70.kt
@@ -0,0 +1,93 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mail.FolderClass
+
+internal class MigrationTo70(private val db: SQLiteDatabase) {
+ fun removePushState() {
+ renameFoldersTable()
+ createNewFoldersTable()
+ copyFoldersData()
+ dropOldFoldersTable()
+ recreateFoldersIndex()
+ recreateFoldersTriggers()
+ }
+
+ private fun renameFoldersTable() {
+ db.execSQL("ALTER TABLE folders RENAME TO folders_old")
+ }
+
+ private fun createNewFoldersTable() {
+ db.execSQL(
+ "CREATE TABLE folders (" +
+ "id INTEGER PRIMARY KEY," +
+ "name TEXT, " +
+ "last_updated INTEGER, " +
+ "unread_count INTEGER, " +
+ "visible_limit INTEGER, " +
+ "status TEXT, " +
+ "flagged_count INTEGER default 0, " +
+ "integrate INTEGER, " +
+ "top_group INTEGER, " +
+ "poll_class TEXT, " +
+ "push_class TEXT, " +
+ "display_class TEXT, " +
+ "notify_class TEXT default '" + FolderClass.INHERITED.name + "', " +
+ "more_messages TEXT default \"unknown\", " +
+ "server_id TEXT, " +
+ "local_only INTEGER, " +
+ "type TEXT DEFAULT \"regular\"" +
+ ")"
+ )
+ }
+
+ private fun copyFoldersData() {
+ db.execSQL(
+ """
+ INSERT INTO folders
+ SELECT
+ id,
+ name,
+ last_updated,
+ unread_count,
+ visible_limit,
+ status,
+ flagged_count,
+ integrate,
+ top_group,
+ poll_class,
+ push_class,
+ display_class,
+ notify_class,
+ more_messages,
+ server_id,
+ local_only,
+ type
+ FROM folders_old
+ """.trimIndent()
+ )
+ }
+
+ private fun dropOldFoldersTable() {
+ db.execSQL("DROP TABLE folders_old")
+ }
+
+ private fun recreateFoldersIndex() {
+ db.execSQL("DROP INDEX IF EXISTS folder_server_id")
+ db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)")
+ }
+
+ private fun recreateFoldersTriggers() {
+ db.execSQL("DROP TRIGGER IF EXISTS delete_folder")
+ db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;")
+
+ db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values")
+ db.execSQL(
+ "CREATE TRIGGER delete_folder_extra_values " +
+ "BEFORE DELETE ON folders " +
+ "BEGIN " +
+ "DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
+ "END;"
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo71.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo71.kt
new file mode 100644
index 0000000..df01e94
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo71.kt
@@ -0,0 +1,24 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal class MigrationTo71(private val db: SQLiteDatabase) {
+ fun cleanUpFolderClass() {
+ db.execSQL(
+ "UPDATE folders SET poll_class = 'NO_CLASS' " +
+ "WHERE poll_class NOT IN ('NO_CLASS', 'INHERITED', 'FIRST_CLASS', 'SECOND_CLASS')"
+ )
+ db.execSQL(
+ "UPDATE folders SET push_class = 'NO_CLASS' " +
+ "WHERE push_class NOT IN ('NO_CLASS', 'INHERITED', 'FIRST_CLASS', 'SECOND_CLASS')"
+ )
+ db.execSQL(
+ "UPDATE folders SET display_class = 'NO_CLASS' " +
+ "WHERE display_class NOT IN ('NO_CLASS', 'INHERITED', 'FIRST_CLASS', 'SECOND_CLASS')"
+ )
+ db.execSQL(
+ "UPDATE folders SET notify_class = 'NO_CLASS' " +
+ "WHERE notify_class NOT IN ('NO_CLASS', 'INHERITED', 'FIRST_CLASS', 'SECOND_CLASS')"
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo72.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo72.kt
new file mode 100644
index 0000000..5afa87e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo72.kt
@@ -0,0 +1,10 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+internal class MigrationTo72(private val db: SQLiteDatabase) {
+ fun createMessagePartsRootIndex() {
+ db.execSQL("DROP INDEX IF EXISTS message_parts_root")
+ db.execSQL("CREATE INDEX IF NOT EXISTS message_parts_root ON message_parts (root)")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo73.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo73.kt
new file mode 100644
index 0000000..8b64f9a
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo73.kt
@@ -0,0 +1,169 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.map
+import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend
+import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand
+import com.fsck.k9.controller.MessagingControllerCommands.PendingDelete
+import com.fsck.k9.controller.MessagingControllerCommands.PendingExpunge
+import com.fsck.k9.controller.MessagingControllerCommands.PendingMarkAllAsRead
+import com.fsck.k9.controller.MessagingControllerCommands.PendingMoveAndMarkAsRead
+import com.fsck.k9.controller.MessagingControllerCommands.PendingMoveOrCopy
+import com.fsck.k9.controller.MessagingControllerCommands.PendingSetFlag
+import com.fsck.k9.controller.PendingCommandSerializer
+import com.squareup.moshi.Moshi
+import timber.log.Timber
+
+internal class MigrationTo73(private val db: SQLiteDatabase) {
+ private val serializer = PendingCommandSerializer.getInstance()
+ private val moshi = Moshi.Builder().build()
+ private val legacyAdapters = listOf(
+ "append" to moshi.adapter(LegacyPendingAppend::class.java),
+ "mark_all_as_read" to moshi.adapter(LegacyPendingMarkAllAsRead::class.java),
+ "set_flag" to moshi.adapter(LegacyPendingSetFlag::class.java),
+ "delete" to moshi.adapter(LegacyPendingDelete::class.java),
+ "expunge" to moshi.adapter(LegacyPendingExpunge::class.java),
+ "move_or_copy" to moshi.adapter(LegacyPendingMoveOrCopy::class.java),
+ "move_and_mark_as_read" to moshi.adapter(LegacyPendingMoveAndMarkAsRead::class.java)
+ ).toMap()
+
+ fun rewritePendingCommandsToUseFolderIds() {
+ val pendingCommands = loadPendingCommands()
+ rewritePendingCommands(pendingCommands)
+ }
+
+ private fun loadPendingCommands(): Map<Long, LegacyPendingCommand> {
+ return db.rawQuery(
+ "SELECT id, command, data FROM pending_commands WHERE command != 'empty_trash'",
+ null
+ ).use { cursor ->
+ cursor.map {
+ val commandId = cursor.getLong(0)
+ val command = cursor.getString(1)
+ val data = cursor.getString(2)
+
+ val pendingCommand = deserialize(command, data)
+ commandId to pendingCommand
+ }.toMap()
+ }
+ }
+
+ private fun rewritePendingCommands(pendingCommands: Map<Long, LegacyPendingCommand>) {
+ for ((commandId, pendingCommand) in pendingCommands) {
+ when (pendingCommand) {
+ is LegacyPendingAppend -> rewritePendingAppend(commandId, pendingCommand)
+ is LegacyPendingMarkAllAsRead -> rewritePendingMarkAllAsRead(commandId, pendingCommand)
+ is LegacyPendingSetFlag -> rewritePendingSetFlag(commandId, pendingCommand)
+ is LegacyPendingDelete -> rewritePendingDelete(commandId, pendingCommand)
+ is LegacyPendingExpunge -> rewritePendingExpunge(commandId, pendingCommand)
+ is LegacyPendingMoveOrCopy -> rewritePendingMoveOrCopy(commandId, pendingCommand)
+ is LegacyPendingMoveAndMarkAsRead -> rewritePendingMoveAndMarkAsRead(commandId, pendingCommand)
+ }
+ }
+ }
+
+ private fun rewritePendingAppend(commandId: Long, legacyPendingCommand: LegacyPendingAppend) {
+ rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
+ PendingAppend.create(folderId, legacyPendingCommand.uid)
+ }
+ }
+
+ private fun rewritePendingMarkAllAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMarkAllAsRead) {
+ rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
+ PendingMarkAllAsRead.create(folderId)
+ }
+ }
+
+ private fun rewritePendingSetFlag(commandId: Long, legacyPendingCommand: LegacyPendingSetFlag) {
+ rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
+ PendingSetFlag.create(
+ folderId,
+ legacyPendingCommand.newState,
+ legacyPendingCommand.flag,
+ legacyPendingCommand.uids
+ )
+ }
+ }
+
+ private fun rewritePendingDelete(commandId: Long, legacyPendingCommand: LegacyPendingDelete) {
+ rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
+ PendingDelete.create(folderId, legacyPendingCommand.uids)
+ }
+ }
+
+ private fun rewritePendingExpunge(commandId: Long, legacyPendingCommand: LegacyPendingExpunge) {
+ rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
+ PendingExpunge.create(folderId)
+ }
+ }
+
+ private fun rewritePendingMoveOrCopy(commandId: Long, legacyPendingCommand: LegacyPendingMoveOrCopy) {
+ rewriteOrRemovePendingCommand(
+ commandId,
+ legacyPendingCommand.srcFolder,
+ legacyPendingCommand.destFolder
+ ) { (srcFolderId, destFolderId) ->
+ PendingMoveOrCopy.create(
+ srcFolderId,
+ destFolderId,
+ legacyPendingCommand.isCopy,
+ legacyPendingCommand.newUidMap
+ )
+ }
+ }
+
+ private fun rewritePendingMoveAndMarkAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMoveAndMarkAsRead) {
+ rewriteOrRemovePendingCommand(
+ commandId,
+ legacyPendingCommand.srcFolder,
+ legacyPendingCommand.destFolder
+ ) { (srcFolderId, destFolderId) ->
+ PendingMoveAndMarkAsRead.create(srcFolderId, destFolderId, legacyPendingCommand.newUidMap)
+ }
+ }
+
+ private fun rewriteOrRemovePendingCommand(
+ commandId: Long,
+ vararg folderServerIds: String,
+ convertPendingCommand: (folderIds: List<Long>) -> PendingCommand
+ ) {
+ val folderIds = folderServerIds.map {
+ loadFolderId(it)
+ }
+
+ if (folderIds.any { it == null }) {
+ Timber.w("Couldn't find folder ID for pending command with database ID $commandId. Removing entry.")
+ removePendingCommand(commandId)
+ } else {
+ val pendingCommand = convertPendingCommand(folderIds.filterNotNull())
+ updatePendingCommand(commandId, pendingCommand)
+ }
+ }
+
+ private fun updatePendingCommand(commandId: Long, pendingCommand: PendingCommand) {
+ val contentValues = ContentValues().apply {
+ put("data", serializer.serialize(pendingCommand))
+ }
+ db.update("pending_commands", contentValues, "id = ?", arrayOf(commandId.toString()))
+ }
+
+ private fun removePendingCommand(commandId: Long) {
+ db.delete("pending_commands", "id = ?", arrayOf(commandId.toString()))
+ }
+
+ private fun loadFolderId(folderServerId: String): Long? {
+ return db.rawQuery("SELECT id from folders WHERE server_id = ?", arrayOf(folderServerId)).use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getLong(0)
+ } else {
+ null
+ }
+ }
+ }
+
+ private fun deserialize(commandName: String, data: String): LegacyPendingCommand {
+ val adapter = legacyAdapters[commandName] ?: error("Unsupported pending command type!")
+ return adapter.fromJson(data) ?: error("Error deserializing pending command")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo74.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo74.kt
new file mode 100644
index 0000000..6d45d37
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo74.kt
@@ -0,0 +1,124 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.Account
+import com.fsck.k9.Account.DeletePolicy
+
+/**
+ * Remove all placeholder entries in 'messages' table
+ *
+ * When the user moves or deletes an email, the row in the 'messages' table is first updated with 'deleted = 1', turning
+ * it into a placeholder message. After the remote operation has completed, the row is removed.
+ *
+ * Placeholder messages are created to prevent an email from being downloaded again during a sync before the remote
+ * operation has finished. The placeholder message is also necessary to remember deleted messages when using a delete
+ * policy other than "delete from server".
+ *
+ * Previously these placeholder messages often weren't removed when they should have been. This will slowly grow the
+ * database.
+ * Here we remove all placeholder messages when the delete policy is "delete from server". This might also include
+ * placeholder messages that shouldn't be removed, because the remote operation hasn't been performed yet.
+ * However, since the app tries to execute all pending remote operations before doing a message sync, it's unlikely that
+ * deleted messages are re-downloaded. And if they are, the next sync after the remote operation has completed will
+ * remove them again.
+ */
+internal class MigrationTo74(private val db: SQLiteDatabase, private val account: Account) {
+
+ fun removeDeletedMessages() {
+ if (account.deletePolicy != DeletePolicy.ON_DELETE) return
+
+ db.query("messages", arrayOf("id"), "deleted = 1", null, null, null, null).use { cursor ->
+ while (cursor.moveToNext()) {
+ destroyMessage(messageId = cursor.getLong(0))
+ }
+ }
+ }
+
+ private fun destroyMessage(messageId: Long) {
+ if (hasThreadChildren(messageId)) {
+ // This message has children in the thread structure so we need to make it an empty message.
+ val cv = ContentValues().apply {
+ put("deleted", 0)
+ put("empty", 1)
+ put("preview_type", "none")
+ put("read", 0)
+ put("flagged", 0)
+ put("answered", 0)
+ put("forwarded", 0)
+ putNull("subject")
+ putNull("sender_list")
+ putNull("date")
+ putNull("to_list")
+ putNull("cc_list")
+ putNull("bcc_list")
+ putNull("preview")
+ putNull("reply_to_list")
+ putNull("message_part_id")
+ putNull("flags")
+ putNull("attachment_count")
+ putNull("internal_date")
+ putNull("mime_type")
+ putNull("encryption_type")
+ }
+ db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
+
+ // Nothing else to do
+ return
+ }
+
+ // Get the message ID of the parent message if it's empty
+ var currentId = getEmptyThreadParent(messageId)
+
+ // Delete the placeholder message
+ deleteMessageRow(messageId)
+
+ // Walk the thread tree to delete all empty parents without children
+ while (currentId != -1L) {
+ if (hasThreadChildren(currentId)) {
+ // We made sure there are no empty leaf nodes and can stop now.
+ break
+ }
+
+ // Get ID of the (empty) parent for the next iteration
+ val newId = getEmptyThreadParent(currentId)
+
+ // Delete the empty message
+ deleteMessageRow(currentId)
+ currentId = newId
+ }
+ }
+
+ private fun hasThreadChildren(messageId: Long): Boolean {
+ return db.rawQuery(
+ "SELECT COUNT(t2.id) " +
+ "FROM threads t1 " +
+ "JOIN threads t2 ON (t2.parent = t1.id) " +
+ "WHERE t1.message_id = ?",
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L
+ }
+ }
+
+ private fun getEmptyThreadParent(messageId: Long): Long {
+ return db.rawQuery(
+ "SELECT m.id " +
+ "FROM threads t1 " +
+ "JOIN threads t2 ON (t1.parent = t2.id) " +
+ "LEFT JOIN messages m ON (t2.message_id = m.id) " +
+ "WHERE t1.message_id = ? AND m.empty = 1",
+ arrayOf(messageId.toString())
+ ).use { cursor ->
+ if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else -1
+ }
+ }
+
+ private fun deleteMessageRow(messageId: Long) {
+ // Delete the message
+ db.delete("messages", "id = ?", arrayOf(messageId.toString()))
+
+ // Delete row in 'threads' table
+ db.delete("threads", "message_id = ?", arrayOf(messageId.toString()))
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo75.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo75.kt
new file mode 100644
index 0000000..fe7efbd
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo75.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+
+internal class MigrationTo75(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
+ fun updateAccountWithSpecialFolderIds() {
+ val account = migrationsHelper.account
+
+ account.inboxFolderId = getFolderId(account.legacyInboxFolder)
+ account.outboxFolderId = getFolderId("K9MAIL_INTERNAL_OUTBOX")
+ account.draftsFolderId = getFolderId(account.importedDraftsFolder)
+ account.sentFolderId = getFolderId(account.importedSentFolder)
+ account.trashFolderId = getFolderId(account.importedTrashFolder)
+ account.archiveFolderId = getFolderId(account.importedArchiveFolder)
+ account.spamFolderId = getFolderId(account.importedSpamFolder)
+ account.autoExpandFolderId = getFolderId(account.importedAutoExpandFolder)
+
+ account.importedDraftsFolder = null
+ account.importedSentFolder = null
+ account.importedTrashFolder = null
+ account.importedArchiveFolder = null
+ account.importedSpamFolder = null
+ account.importedAutoExpandFolder = null
+
+ migrationsHelper.saveAccount()
+ }
+
+ private fun getFolderId(serverId: String?): Long? {
+ if (serverId == null) return null
+
+ return db.query("folders", arrayOf("id"), "server_id = ?", arrayOf(serverId), null, null, null).use { cursor ->
+ if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null
+ }
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo76.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo76.kt
new file mode 100644
index 0000000..6a37017
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo76.kt
@@ -0,0 +1,131 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import app.k9mail.core.android.common.database.map
+import com.fsck.k9.Account
+import com.fsck.k9.mailstore.MigrationsHelper
+import com.fsck.k9.preferences.Protocols
+import timber.log.Timber
+
+/**
+ * Clean up special local folders
+ *
+ * In the past local special folders were not always created. For example, when importing settings or when setting up
+ * an account, but checking the server settings didn't succeed and the user decided to continue anyway.
+ *
+ * Clicking "Next" in the incoming server settings screen would check the server settings and, in the case of success,
+ * create new special local folders even if they already existed. So it's also possible existing installations have
+ * multiple special local folders of one type.
+ *
+ * Here, we clean up local special folders to have exactly one of each type. Messages in additional folders will be
+ * moved to the folder we keep and then the other folders will be deleted. An exception are messages in old Outbox
+ * folders. They will be deleted and not be moved to the new/current Outbox folder because this would cause potentially
+ * very old messages to be sent. The right thing would be to move them to the Drafts folder. But this is much more
+ * complicated. They'd have to be uploaded if the Drafts folder is not a local folder. It's also not clear what should
+ * happen if there is no Drafts folder configured.
+ */
+internal class MigrationTo76(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
+ fun cleanUpSpecialLocalFolders() {
+ val account = migrationsHelper.account
+
+ Timber.v("Cleaning up Outbox folder")
+ val outboxFolderId = account.outboxFolderId ?: createFolder("Outbox", "K9MAIL_INTERNAL_OUTBOX", OUTBOX_FOLDER_TYPE)
+ deleteOtherOutboxFolders(outboxFolderId)
+ account.outboxFolderId = outboxFolderId
+
+ if (account.isPop3()) {
+ Timber.v("Cleaning up Drafts folder")
+ val draftsFolderId = account.draftsFolderId ?: createFolder("Drafts", "Drafts", DRAFTS_FOLDER_TYPE)
+ moveMessages(DRAFTS_FOLDER_TYPE, draftsFolderId)
+ account.draftsFolderId = draftsFolderId
+
+ Timber.v("Cleaning up Sent folder")
+ val sentFolderId = account.sentFolderId ?: createFolder("Sent", "Sent", SENT_FOLDER_TYPE)
+ moveMessages(SENT_FOLDER_TYPE, sentFolderId)
+ account.sentFolderId = sentFolderId
+
+ Timber.v("Cleaning up Trash folder")
+ val trashFolderId = account.trashFolderId ?: createFolder("Trash", "Trash", TRASH_FOLDER_TYPE)
+ moveMessages(TRASH_FOLDER_TYPE, trashFolderId)
+ account.trashFolderId = trashFolderId
+ }
+
+ migrationsHelper.saveAccount()
+ }
+
+ private fun createFolder(name: String, serverId: String, type: String): Long {
+ Timber.v(" Creating new local folder (name=$name, serverId=$serverId, type=$type)…")
+ val values = ContentValues().apply {
+ put("name", name)
+ put("visible_limit", 25)
+ put("integrate", 0)
+ put("top_group", 0)
+ put("poll_class", "NO_CLASS")
+ put("push_class", "SECOND_CLASS")
+ put("display_class", "NO_CLASS")
+ put("server_id", serverId)
+ put("local_only", 1)
+ put("type", type)
+ }
+
+ val folderId = db.insert("folders", null, values)
+ Timber.v(" Created folder with ID $folderId")
+
+ return folderId
+ }
+
+ private fun deleteOtherOutboxFolders(outboxFolderId: Long) {
+ val otherFolderIds = getOtherFolders(OUTBOX_FOLDER_TYPE, outboxFolderId)
+ for (folderId in otherFolderIds) {
+ deleteFolder(folderId)
+ }
+ }
+
+ private fun getOtherFolders(folderType: String, excludeFolderId: Long): List<Long> {
+ return db.query(
+ "folders",
+ arrayOf("id"),
+ "local_only = 1 AND type = ? AND id != ?",
+ arrayOf(folderType, excludeFolderId.toString()),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ cursor.map { cursor.getLong(0) }
+ }
+ }
+
+ private fun moveMessages(folderType: String, destinationFolderId: Long) {
+ val sourceFolderIds = getOtherFolders(folderType, destinationFolderId)
+ for (sourceFolderId in sourceFolderIds) {
+ moveMessages(sourceFolderId, destinationFolderId)
+ deleteFolder(sourceFolderId)
+ }
+ }
+
+ private fun moveMessages(sourceFolderId: Long, destinationFolderId: Long) {
+ Timber.v(" Moving messages from folder [$sourceFolderId] to folder [$destinationFolderId]…")
+
+ val values = ContentValues().apply {
+ put("folder_id", destinationFolderId)
+ }
+ val rows = db.update("messages", values, "folder_id = ?", arrayOf(sourceFolderId.toString()))
+
+ Timber.v(" $rows messages moved.")
+ }
+
+ private fun deleteFolder(folderId: Long) {
+ Timber.v(" Deleting folder [$folderId]")
+ db.delete("folders", "id = ?", arrayOf(folderId.toString()))
+ }
+
+ private fun Account.isPop3() = incomingServerSettings.type == Protocols.POP3
+
+ companion object {
+ private const val OUTBOX_FOLDER_TYPE = "outbox"
+ private const val DRAFTS_FOLDER_TYPE = "drafts"
+ private const val SENT_FOLDER_TYPE = "sent"
+ private const val TRASH_FOLDER_TYPE = "trash"
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo78.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo78.kt
new file mode 100644
index 0000000..0943d9d
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo78.kt
@@ -0,0 +1,12 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Set 'server_id' value to NULL for local folders
+ */
+internal class MigrationTo78(private val db: SQLiteDatabase) {
+ fun removeServerIdFromLocalFolders() {
+ db.execSQL("UPDATE folders SET server_id = NULL WHERE local_only = 1")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo79.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo79.kt
new file mode 100644
index 0000000..47af752
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo79.kt
@@ -0,0 +1,21 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Add 'threads' table to 'delete_message' trigger
+ */
+internal class MigrationTo79(private val db: SQLiteDatabase) {
+ fun updateDeleteMessageTrigger() {
+ db.execSQL("DROP TRIGGER IF EXISTS delete_message")
+ db.execSQL(
+ "CREATE TRIGGER delete_message " +
+ "BEFORE DELETE ON messages " +
+ "BEGIN " +
+ "DELETE FROM message_parts WHERE root = OLD.message_part_id; " +
+ "DELETE FROM messages_fulltext WHERE docid = OLD.id; " +
+ "DELETE FROM threads WHERE message_id = OLD.id; " +
+ "END"
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt
new file mode 100644
index 0000000..c435d9e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt
@@ -0,0 +1,12 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Rewrite 'last_update' column to NULL when the value is 0
+ */
+internal class MigrationTo80(private val db: SQLiteDatabase) {
+ fun rewriteLastUpdatedColumn() {
+ db.execSQL("UPDATE folders SET last_updated = NULL WHERE last_updated = 0")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt
new file mode 100644
index 0000000..60e67c7
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt
@@ -0,0 +1,22 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Add 'notifications' table to keep track of notifications.
+ */
+internal class MigrationTo81(private val db: SQLiteDatabase) {
+ fun addNotificationsTable() {
+ db.execSQL("DROP TABLE IF EXISTS notifications")
+ db.execSQL(
+ "CREATE TABLE notifications (" +
+ "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
+ "notification_id INTEGER UNIQUE," +
+ "timestamp INTEGER NOT NULL" +
+ ")"
+ )
+
+ db.execSQL("DROP INDEX IF EXISTS notifications_timestamp")
+ db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt
new file mode 100644
index 0000000..8d84acd
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt
@@ -0,0 +1,27 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+
+/**
+ * Add 'new_message' column to 'messages' table.
+ */
+internal class MigrationTo82(private val db: SQLiteDatabase) {
+ fun addNewMessageColumn() {
+ db.execSQL("ALTER TABLE messages ADD new_message INTEGER DEFAULT 0")
+
+ db.execSQL("DROP INDEX IF EXISTS new_messages")
+ db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)")
+
+ db.execSQL(
+ "CREATE TRIGGER new_message_reset " +
+ "AFTER UPDATE OF read ON messages " +
+ "FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " +
+ "BEGIN " +
+ "UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " +
+ "END"
+ )
+
+ // Mark messages with existing notifications as "new"
+ db.execSQL("UPDATE messages SET new_message = 1 WHERE id in (SELECT message_id FROM notifications)")
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt
new file mode 100644
index 0000000..0754739
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt
@@ -0,0 +1,42 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.database.getLongOrNull
+import app.k9mail.core.android.common.database.map
+import com.fsck.k9.mailstore.MigrationsHelper
+
+private const val EXTRA_HIGHEST_KNOWN_UID = "imapHighestKnownUid"
+
+/**
+ * Write the highest known IMAP message UID to the 'folder_extra_values' table.
+ */
+internal class MigrationTo83(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
+ fun rewriteHighestKnownUid() {
+ if (migrationsHelper.account.incomingServerSettings.type != "imap") return
+
+ val highestKnownUids = db.rawQuery(
+ "SELECT folder_id, MAX(CAST(uid AS INTEGER)) FROM messages GROUP BY folder_id",
+ null
+ ).use { cursor ->
+ cursor.map {
+ it.getLong(0) to it.getLongOrNull(1)
+ }.toMap()
+ }
+
+ for ((folderId, highestKnownUid) in highestKnownUids) {
+ if (highestKnownUid != null && highestKnownUid > 0L) {
+ rewriteHighestKnownUid(folderId, highestKnownUid)
+ }
+ }
+ }
+
+ private fun rewriteHighestKnownUid(folderId: Long, highestKnownUid: Long) {
+ val contentValues = ContentValues().apply {
+ put("folder_id", folderId)
+ put("name", EXTRA_HIGHEST_KNOWN_UID)
+ put("value_integer", highestKnownUid)
+ }
+ db.insertWithOnConflict("folder_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo84.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo84.kt
new file mode 100644
index 0000000..8dbbd6e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo84.kt
@@ -0,0 +1,59 @@
+package com.fsck.k9.storage.migrations
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.database.getStringOrNull
+import app.k9mail.core.android.common.database.map
+
+/**
+ * Write the address fields to use ASCII 1 instead of ASCII 0 as separator.
+ * Separator was previously ASCII 0 but this caused problems with LIKE and searching.
+ */
+internal class MigrationTo84(private val db: SQLiteDatabase) {
+
+ fun rewriteAddresses() {
+ val addressSets = db.rawQuery(
+ "SELECT id, to_list, cc_list, bcc_list, reply_to_list, sender_list FROM messages WHERE empty = 0 AND deleted = 0",
+ null
+ ).use { cursor ->
+ cursor.map {
+ val messageId = it.getLong(0)
+
+ messageId to AddressSet(
+ toList = it.getStringOrNull(1),
+ ccList = it.getStringOrNull(2),
+ bccList = it.getStringOrNull(3),
+ replyToList = it.getStringOrNull(4),
+ senderList = it.getStringOrNull(5)
+ )
+ }.toMap()
+ }
+
+ for ((messageId, addressSet) in addressSets) {
+ rewriteAddresses(messageId, addressSet)
+ }
+ }
+
+ private fun rewriteAddress(inAddress: String?): String? {
+ return inAddress?.replace(oldChar = '\u0000', newChar = '\u0001')
+ }
+
+ private fun rewriteAddresses(messageId: Long, addressSet: AddressSet) {
+ val cv = ContentValues().apply {
+ put("to_list", rewriteAddress(addressSet.toList))
+ put("cc_list", rewriteAddress(addressSet.ccList))
+ put("bcc_list", rewriteAddress(addressSet.bccList))
+ put("reply_to_list", rewriteAddress(addressSet.replyToList))
+ put("sender_list", rewriteAddress(addressSet.senderList))
+ }
+ db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
+ }
+}
+
+private data class AddressSet(
+ val toList: String?,
+ val ccList: String?,
+ val bccList: String?,
+ val replyToList: String?,
+ val senderList: String?
+)
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt
new file mode 100644
index 0000000..64c020e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt
@@ -0,0 +1,34 @@
+package com.fsck.k9.storage.migrations
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.mailstore.MigrationsHelper
+
+object Migrations {
+ @JvmStatic
+ fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
+ val oldVersion = db.version
+
+ if (oldVersion < 62) MigrationTo62.addServerIdColumnToFoldersTable(db)
+ if (oldVersion < 64) MigrationTo64.addExtraValuesTables(db)
+ if (oldVersion < 65) MigrationTo65.addLocalOnlyColumnToFoldersTable(db, migrationsHelper)
+ if (oldVersion < 66) MigrationTo66.addEncryptionTypeColumnToMessagesTable(db)
+ if (oldVersion < 67) MigrationTo67.addTypeColumnToFoldersTable(db, migrationsHelper)
+ if (oldVersion < 68) MigrationTo68.addOutboxStateTable(db)
+ if (oldVersion < 69) MigrationTo69(db).createPendingDelete()
+ if (oldVersion < 70) MigrationTo70(db).removePushState()
+ if (oldVersion < 71) MigrationTo71(db).cleanUpFolderClass()
+ if (oldVersion < 72) MigrationTo72(db).createMessagePartsRootIndex()
+ if (oldVersion < 73) MigrationTo73(db).rewritePendingCommandsToUseFolderIds()
+ if (oldVersion < 74) MigrationTo74(db, migrationsHelper.account).removeDeletedMessages()
+ if (oldVersion < 75) MigrationTo75(db, migrationsHelper).updateAccountWithSpecialFolderIds()
+ if (oldVersion < 76) MigrationTo76(db, migrationsHelper).cleanUpSpecialLocalFolders()
+ // 77: No longer necessary
+ if (oldVersion < 78) MigrationTo78(db).removeServerIdFromLocalFolders()
+ if (oldVersion < 79) MigrationTo79(db).updateDeleteMessageTrigger()
+ if (oldVersion < 80) MigrationTo80(db).rewriteLastUpdatedColumn()
+ if (oldVersion < 81) MigrationTo81(db).addNotificationsTable()
+ if (oldVersion < 82) MigrationTo82(db).addNewMessageColumn()
+ if (oldVersion < 83) MigrationTo83(db, migrationsHelper).rewriteHighestKnownUid()
+ if (oldVersion < 84) MigrationTo84(db).rewriteAddresses()
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt
new file mode 100644
index 0000000..4f3527e
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt
@@ -0,0 +1,72 @@
+package com.fsck.k9.storage.notifications
+
+import android.database.sqlite.SQLiteDatabase
+import com.fsck.k9.controller.MessageReference
+import com.fsck.k9.mailstore.LockableDatabase
+import com.fsck.k9.notification.NotificationStore
+import com.fsck.k9.notification.NotificationStoreOperation
+
+class K9NotificationStore(private val lockableDatabase: LockableDatabase) : NotificationStore {
+ override fun persistNotificationChanges(operations: List<NotificationStoreOperation>) {
+ lockableDatabase.execute(true) { db ->
+ for (operation in operations) {
+ when (operation) {
+ is NotificationStoreOperation.Add -> {
+ addNotification(db, operation.messageReference, operation.notificationId, operation.timestamp)
+ }
+ is NotificationStoreOperation.ChangeToActive -> {
+ setNotificationId(db, operation.messageReference, operation.notificationId)
+ }
+ is NotificationStoreOperation.ChangeToInactive -> {
+ clearNotificationId(db, operation.messageReference)
+ }
+ is NotificationStoreOperation.Remove -> {
+ removeNotification(db, operation.messageReference)
+ }
+ }
+ }
+ }
+ }
+
+ override fun clearNotifications() {
+ lockableDatabase.execute(false) { db ->
+ db.delete("notifications", null, null)
+ }
+ }
+
+ private fun addNotification(
+ database: SQLiteDatabase,
+ messageReference: MessageReference,
+ notificationId: Int,
+ timestamp: Long
+ ) {
+ database.execSQL(
+ "INSERT INTO notifications(message_id, notification_id, timestamp) " +
+ "SELECT id, ?, ? FROM messages WHERE folder_id = ? AND uid = ?",
+ arrayOf(notificationId, timestamp, messageReference.folderId, messageReference.uid)
+ )
+ }
+
+ private fun setNotificationId(database: SQLiteDatabase, messageReference: MessageReference, notificationId: Int) {
+ database.execSQL(
+ "UPDATE notifications SET notification_id = ? WHERE message_id IN " +
+ "(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
+ arrayOf(notificationId, messageReference.folderId, messageReference.uid)
+ )
+ }
+
+ private fun clearNotificationId(database: SQLiteDatabase, messageReference: MessageReference) {
+ database.execSQL(
+ "UPDATE notifications SET notification_id = NULL WHERE message_id IN " +
+ "(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
+ arrayOf(messageReference.folderId, messageReference.uid)
+ )
+ }
+
+ private fun removeNotification(database: SQLiteDatabase, messageReference: MessageReference) {
+ database.execSQL(
+ "DELETE FROM notifications WHERE message_id IN (SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
+ arrayOf(messageReference.folderId, messageReference.uid)
+ )
+ }
+}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt
new file mode 100644
index 0000000..09a642f
--- /dev/null
+++ b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt
@@ -0,0 +1,13 @@
+package com.fsck.k9.storage.notifications
+
+import com.fsck.k9.Account
+import com.fsck.k9.mailstore.LocalStoreProvider
+import com.fsck.k9.notification.NotificationStore
+import com.fsck.k9.notification.NotificationStoreProvider
+
+class K9NotificationStoreProvider(private val localStoreProvider: LocalStoreProvider) : NotificationStoreProvider {
+ override fun getNotificationStore(account: Account): NotificationStore {
+ val localStore = localStoreProvider.getInstance(account)
+ return K9NotificationStore(lockableDatabase = localStore.database)
+ }
+}