diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /app/storage/src/main/java/com | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'app/storage/src/main/java/com')
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) + } +} |
